diff --git a/core/itch_client.gd b/core/itch_client.gd index 5af9df4..39490df 100644 --- a/core/itch_client.gd +++ b/core/itch_client.gd @@ -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// 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 " ()", 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)) diff --git a/core/library_itch.gd b/core/library_itch.gd index 6813c92..44f570c 100644 --- a/core/library_itch.gd +++ b/core/library_itch.gd @@ -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 diff --git a/plugin.json b/plugin.json index e6d4d11..3826d28 100644 --- a/plugin.json +++ b/plugin.json @@ -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",