Fix install of bought games and launch of games with spaces in paths
Some checks failed
Build plugin / build (push) Failing after 10s
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:
parent
c15d49bfd1
commit
bbc9b248bb
3 changed files with 103 additions and 41 deletions
|
|
@ -16,6 +16,10 @@ extends NodeThread
|
|||
## butler update that changes a field, diff against that spec.
|
||||
|
||||
const broth_base := "https://broth.itch.zone/butler"
|
||||
## Separator between an upload name and its size in the install-options
|
||||
## dropdown ("bubble-tower-linux.zip (31.3 MB)"). Shared with the library so
|
||||
## the value the user picks can be matched back to the upload it came from.
|
||||
const UPLOAD_SIZE_SEPARATOR := " ("
|
||||
## Persistent storage for the downloaded butler binary + butler.db. Kept OUT
|
||||
## of user://plugins/<id>/ because OGPU wipes that directory (and everything
|
||||
## inside it) whenever the plugin is updated or re-extracted.
|
||||
|
|
@ -725,6 +729,47 @@ func _resolve_cave(game: Dictionary, cave_id: String) -> String:
|
|||
return ""
|
||||
|
||||
|
||||
## Asks butlerd to queue an install of `upload` for `game`.
|
||||
func _queue_install(
|
||||
game: Dictionary, upload: Dictionary, location_id: String, reason: String, cave_id: String
|
||||
) -> Dictionary:
|
||||
var queue_params := {
|
||||
"game": game,
|
||||
"upload": upload,
|
||||
"installLocationId": location_id,
|
||||
"reason": reason,
|
||||
"queueDownload": true,
|
||||
}
|
||||
if cave_id != "":
|
||||
queue_params["caveId"] = cave_id
|
||||
return await _rpc_call("Install.Queue", queue_params)
|
||||
|
||||
|
||||
## itch.io's answer when butlerd asks for a build without a download key the
|
||||
## account holds: the game is in the library but butlerd never learned its key.
|
||||
const ITCHIO_MISSING_DOWNLOAD_KEY_ERROR := "you must buy this game to download"
|
||||
|
||||
|
||||
func _is_missing_download_key_error(queue_res: Dictionary) -> bool:
|
||||
if not queue_res.has("error"):
|
||||
return false
|
||||
return ITCHIO_MISSING_DOWNLOAD_KEY_ERROR in _rpc_error_message(queue_res)
|
||||
|
||||
|
||||
## butlerd only learns the itch.io download keys from Fetch.ProfileOwnedKeys.
|
||||
## When that call never ran (or ran long ago), butlerd queues installs without
|
||||
## credentials and itch.io rejects them, so pull a fresh copy of the keys.
|
||||
func _sync_owned_keys() -> void:
|
||||
if not is_logged_in:
|
||||
return
|
||||
var profile_id: int = int(profile.get("user", {}).get("id", profile.get("id", 0)))
|
||||
if profile_id == 0:
|
||||
return
|
||||
var res := await _rpc_call("Fetch.ProfileOwnedKeys", {"profileId": profile_id, "fresh": true})
|
||||
if "error" in res:
|
||||
logger.warn("Fetch.ProfileOwnedKeys (fresh) failed: " + str(res["error"]))
|
||||
|
||||
|
||||
func _install(game: Dictionary, cave_id: String, options: Dictionary) -> bool:
|
||||
var game_id: int = game.get("id", 0)
|
||||
cave_id = await _resolve_cave(game, cave_id)
|
||||
|
|
@ -756,17 +801,14 @@ func _install(game: Dictionary, cave_id: String, options: Dictionary) -> bool:
|
|||
if location_id == "":
|
||||
location_id = await _ensure_install_location()
|
||||
|
||||
var queue_params := {
|
||||
"game": game,
|
||||
"upload": upload,
|
||||
"installLocationId": location_id,
|
||||
"reason": reason,
|
||||
"queueDownload": true,
|
||||
}
|
||||
if cave_id != "":
|
||||
queue_params["caveId"] = cave_id
|
||||
|
||||
var queue_res := await _rpc_call("Install.Queue", queue_params)
|
||||
var queue_res := await _queue_install(game, upload, location_id, reason, cave_id)
|
||||
# butlerd sends the build request without an itch.io download key when its
|
||||
# database doesn't hold one for this game, and itch.io answers "you must buy
|
||||
# this game to download". Sync the owned keys and give it one more try.
|
||||
if _is_missing_download_key_error(queue_res):
|
||||
logger.info("Install of game " + str(game_id) + " was rejected for lack of a download key. Syncing owned keys and retrying.")
|
||||
await _sync_owned_keys()
|
||||
queue_res = await _queue_install(game, upload, location_id, reason, cave_id)
|
||||
if "error" in queue_res:
|
||||
var queue_error: String = _rpc_error_message(queue_res)
|
||||
logger.error("Install.Queue failed: " + str(queue_res["error"]))
|
||||
|
|
@ -906,15 +948,28 @@ func _wipe_dir(path: String) -> void:
|
|||
|
||||
|
||||
## Returns the upload whose displayName or filename matches the user's pick
|
||||
## from the install-options dropdown, or {} when nothing matches.
|
||||
func _pick_upload(uploads: Array, chosen: String) -> Dictionary:
|
||||
## from the install-options dropdown, or {} when nothing matches. The dropdown
|
||||
## renders each upload as "<name> (<size>)", so the bare name prefix counts as
|
||||
## a match too.
|
||||
static func _pick_upload(uploads: Array, chosen: String) -> Dictionary:
|
||||
for u in uploads:
|
||||
var upload: Dictionary = u
|
||||
if upload.get("displayName", "") == chosen or upload.get("filename", "") == chosen:
|
||||
if upload_name_matches(upload, chosen):
|
||||
return upload
|
||||
return {}
|
||||
|
||||
|
||||
## True when `chosen` (an install-options dropdown value) names `upload`.
|
||||
static func upload_name_matches(upload: Dictionary, chosen: String) -> bool:
|
||||
for name_key in ["displayName", "filename"]:
|
||||
var name: String = upload.get(name_key, "")
|
||||
if name == "":
|
||||
continue
|
||||
if chosen == name or chosen.begins_with(name + UPLOAD_SIZE_SEPARATOR):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Uninstalls the given cave. Returns true on success.
|
||||
func uninstall(cave_id: String) -> bool:
|
||||
return await thread_group.exec(_uninstall.bind(cave_id))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"plugin.id": "itch",
|
||||
"plugin.name": "itch.io",
|
||||
"plugin.version": "0.1.39",
|
||||
"plugin.version": "0.1.41",
|
||||
"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