- itch: UPPER_CASE constants, CACHE_NAMESPACE, butler_process/rpc_socket/connection_state, LOGIN_METHOD enum, SETTINGS_SECTION, signal handler renames - artprovider: UPPER_CASE constants, _enriched_meta, _find_game_dict, dead code removal, named poll intervals - session-switch: MAX_POLL_FRAMES, _poll_for_power_menu, BUTTON_LABEL const
1005 lines
37 KiB
GDScript
1005 lines
37 KiB
GDScript
extends NodeThread
|
|
|
|
## Godot interface for butlerd
|
|
##
|
|
## Provides a Godot interface to butlerd, itch.io's official JSON-RPC 2.0
|
|
## launcher daemon (`butler daemon --json ...`). This class relies on
|
|
## [InteractiveProcess] (same mechanism the Steam plugin uses for steamcmd)
|
|
## purely to spawn butlerd and read its single-line startup handshake off
|
|
## stdout. All *actual* communication after that happens over a raw TCP
|
|
## socket, per butlerd's wire protocol:
|
|
## https://itch.io/docs/butler/launcher-integration.html
|
|
##
|
|
## NOTE ON FIELD NAMES: the JSON field names used below follow butlerd's
|
|
## documented camelCase convention (mirroring the Go struct field names in
|
|
## https://pkg.go.dev/github.com/itchio/butler/butlerd). If itch.io ships a
|
|
## butler update that changes a field, diff against that spec.
|
|
|
|
const BROTH_BASE := "https://broth.itch.zone/butler"
|
|
## 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.
|
|
const BUTLER_DIR := "user://butler"
|
|
## Where installed games live. This is intentionally NOT user://plugins/itch:
|
|
## OGPU moves the entire extracted plugin directory (plugins/<id>/) to the
|
|
## trash on every plugin update, so any install kept under it silently loses
|
|
## its files on update while butler.db keeps the (now orphaned) cave, which
|
|
## used to break both fresh installs ("That upload is already installed!")
|
|
## and launches. Keeping games next to butler.db (user://butler) means they
|
|
## survive plugin updates untouched.
|
|
const GAMES_DIR := "user://butler/games"
|
|
const CACHE_NAMESPACE := "itch"
|
|
const CONNECTION_TIMEOUT_ITERATIONS := 50
|
|
const CONNECTION_POLL_DELAY_MS := 20
|
|
const PERCENTAGE_SCALE := 100
|
|
|
|
enum STATE {
|
|
BOOT,
|
|
WAITING_HANDSHAKE,
|
|
CONNECTED,
|
|
}
|
|
|
|
enum LOGIN_STATUS {
|
|
OK,
|
|
FAILED,
|
|
INVALID_KEY,
|
|
}
|
|
|
|
# butlerd process/socket signals
|
|
signal rpc_response(id: int, result: Variant, error: Variant)
|
|
signal rpc_notification(method: String, params: Variant)
|
|
|
|
# Main thread signals
|
|
signal bootstrap_finished
|
|
signal client_ready
|
|
signal logged_in(status: LOGIN_STATUS, profile: Dictionary)
|
|
signal install_progressed(cave_or_game_id: String, current: int, total: int)
|
|
signal install_failed(cave_or_game_id: String, game_title: String, message: String)
|
|
signal app_installed(cave_id: String, success: bool)
|
|
signal app_updated(cave_id: String, success: bool)
|
|
signal app_uninstalled(cave_id: String, success: bool)
|
|
signal launch_exited(cave_id: String)
|
|
|
|
var butler_process: InteractiveProcess
|
|
var rpc_socket: StreamPeerTCP
|
|
var connection_state: STATE = STATE.BOOT
|
|
var client_started := false
|
|
var is_logged_in := false
|
|
var profile: Dictionary = {}
|
|
|
|
var _proc_buffer := ""
|
|
var _recv_buffer := ""
|
|
var _next_id := 1
|
|
var _cached_install_location_id := ""
|
|
|
|
var logger := Log.get_logger("ItchClient", Log.LEVEL.INFO)
|
|
|
|
|
|
func _ready() -> void:
|
|
add_to_group("itch_client")
|
|
thread_group = SharedThread.new()
|
|
thread_group.name = "ItchClient"
|
|
bootstrap()
|
|
|
|
|
|
## Bootstraps the butler binary if it isn't present, then spawns butlerd.
|
|
func bootstrap() -> void:
|
|
var butler_dir_global := ProjectSettings.globalize_path(BUTLER_DIR)
|
|
_migrate_butler(butler_dir_global)
|
|
var butler_bin := "/".join([butler_dir_global, "butler"])
|
|
if not FileAccess.file_exists(butler_bin):
|
|
logger.info("The butler binary wasn't found. Trying to install it.")
|
|
var success := await _install_butler()
|
|
if not success:
|
|
logger.error("Unable to install butler")
|
|
bootstrap_finished.emit()
|
|
return
|
|
logger.info("Successfully installed butler")
|
|
|
|
var dbpath := "/".join([butler_dir_global, "butler.db"])
|
|
var args := [
|
|
"daemon",
|
|
"--json",
|
|
"--transport", "tcp",
|
|
"--keep-alive",
|
|
"--dbpath", dbpath,
|
|
"--address", "https://itch.io",
|
|
"--user-agent", _get_user_agent(),
|
|
"--destiny-pid", str(OS.get_process_id()),
|
|
]
|
|
|
|
butler_process = InteractiveProcess.new(butler_bin, args)
|
|
if butler_process.start() != OK:
|
|
logger.error("Unable to spawn butlerd")
|
|
return
|
|
client_started = true
|
|
connection_state = STATE.WAITING_HANDSHAKE
|
|
bootstrap_finished.emit()
|
|
|
|
|
|
## Moves a butler install left over in the plugin directory (OGPU wipes
|
|
## plugins/<id>/ on every plugin update, which is why older builds kept
|
|
## losing butler) to the persistent user://butler location.
|
|
func _migrate_butler(dest_dir: String) -> void:
|
|
var old_dir := ProjectSettings.globalize_path("user://plugins/itch/assets/butler")
|
|
if old_dir == dest_dir:
|
|
return
|
|
if not DirAccess.dir_exists_absolute(old_dir):
|
|
return
|
|
if not FileAccess.file_exists("/".join([old_dir, "butler"])):
|
|
return
|
|
if FileAccess.file_exists("/".join([dest_dir, "butler"])):
|
|
return
|
|
DirAccess.make_dir_recursive_absolute(dest_dir)
|
|
for file in ["butler", "7z.so", "libc7zip.so"]:
|
|
var src := "/".join([old_dir, file])
|
|
if FileAccess.file_exists(src):
|
|
DirAccess.copy_absolute(src, "/".join([dest_dir, file]))
|
|
logger.info("Migrated butler from " + old_dir + " to " + dest_dir)
|
|
|
|
|
|
## Downloads a butler binary for this platform from itch's broth distribution
|
|
## channel (the same one the official itch.io app uses to self-update).
|
|
func _install_butler() -> bool:
|
|
var os_name := "linux"
|
|
var arch_name := "amd64"
|
|
if OS.get_name() == "Windows":
|
|
os_name = "windows"
|
|
if OS.get_name() == "macOS":
|
|
os_name = "darwin"
|
|
# Engine.get_architecture_name() is only available on newer Godot 4.x
|
|
# builds; guard it so this still works if OpenGamepadUI is running on an
|
|
# older engine version.
|
|
if Engine.has_method("get_architecture_name"):
|
|
var arch: String = Engine.get_architecture_name()
|
|
if "arm64" in arch or "aarch64" in arch:
|
|
arch_name = "arm64"
|
|
|
|
var platform_slug := os_name + "-" + arch_name
|
|
var latest_url := "/".join([BROTH_BASE, platform_slug, "LATEST"])
|
|
|
|
var http := HTTPRequest.new()
|
|
add_child.call_deferred(http)
|
|
await http.ready
|
|
if http.request(latest_url) != OK:
|
|
logger.error("Error fetching latest butler version: " + latest_url)
|
|
remove_child(http)
|
|
http.queue_free()
|
|
return false
|
|
var args: Array = await http.request_completed
|
|
var result: int = args[0]
|
|
var response_code: int = args[1]
|
|
var body: PackedByteArray = args[3]
|
|
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
|
|
logger.error("Unable to determine latest butler version for " + platform_slug)
|
|
remove_child(http)
|
|
http.queue_free()
|
|
return false
|
|
var version := body.get_string_from_utf8().strip_edges()
|
|
|
|
var archive_url := "/".join([BROTH_BASE, platform_slug, version, "archive", "default"])
|
|
if http.request(archive_url) != OK:
|
|
logger.error("Error downloading butler: " + archive_url)
|
|
remove_child(http)
|
|
http.queue_free()
|
|
return false
|
|
args = await http.request_completed
|
|
result = args[0]
|
|
response_code = args[1]
|
|
body = args[3]
|
|
remove_child(http)
|
|
http.queue_free()
|
|
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
|
|
logger.error("butler couldn't be downloaded: " + archive_url)
|
|
return false
|
|
|
|
var globalized_dir := ProjectSettings.globalize_path(BUTLER_DIR)
|
|
DirAccess.make_dir_recursive_absolute(globalized_dir)
|
|
var zip_path := "/tmp/butler-" + version + ".zip"
|
|
var file := FileAccess.open(zip_path, FileAccess.WRITE_READ)
|
|
file.store_buffer(body)
|
|
file.close()
|
|
|
|
var out := []
|
|
OS.execute("unzip", ["-o", zip_path, "-d", globalized_dir], out)
|
|
OS.execute("chmod", ["+x", "/".join([globalized_dir, "butler"])], out)
|
|
|
|
return true
|
|
|
|
|
|
func _get_user_agent() -> String:
|
|
var version := "unknown"
|
|
var file := FileAccess.open("res://plugin.json", FileAccess.READ)
|
|
if file:
|
|
var json: Variant = JSON.parse_string(file.get_as_text())
|
|
file.close()
|
|
if json is Dictionary:
|
|
version = json.get("plugin.version", "unknown")
|
|
return "OpenGamepadUI-itch/" + version
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Wire protocol: spawn -> read handshake off stdout -> connect TCP -> auth
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _thread_process(_delta: float) -> void:
|
|
if connection_state == STATE.WAITING_HANDSHAKE and butler_process:
|
|
_proc_buffer += butler_process.read()
|
|
if not _proc_buffer.contains("\n"):
|
|
return
|
|
var lines := _proc_buffer.split("\n")
|
|
_proc_buffer = lines[-1]
|
|
for i in range(lines.size() - 1):
|
|
var line: String = lines[i].strip_edges()
|
|
if line == "":
|
|
continue
|
|
var parsed: Variant = _normalize_json(JSON.parse_string(line))
|
|
if typeof(parsed) != TYPE_DICTIONARY:
|
|
continue
|
|
var msg: Dictionary = parsed
|
|
if msg.get("type", "") != "butlerd/listen-notification":
|
|
continue
|
|
var secret: String = msg.get("secret", "")
|
|
var address: String = msg.get("tcp", {}).get("address", "")
|
|
_connect_and_authenticate.call_deferred(address, secret)
|
|
return
|
|
|
|
if connection_state == STATE.CONNECTED and rpc_socket:
|
|
rpc_socket.poll()
|
|
var available := rpc_socket.get_available_bytes()
|
|
if available <= 0:
|
|
return
|
|
var chunk := rpc_socket.get_partial_data(available)
|
|
if chunk[0] != OK:
|
|
return
|
|
_recv_buffer += (chunk[1] as PackedByteArray).get_string_from_utf8()
|
|
while _recv_buffer.contains("\n"):
|
|
var idx := _recv_buffer.find("\n")
|
|
var line := _recv_buffer.substr(0, idx)
|
|
_recv_buffer = _recv_buffer.substr(idx + 1)
|
|
if line.strip_edges() == "":
|
|
continue
|
|
var parsed: Variant = _normalize_json(JSON.parse_string(line))
|
|
if typeof(parsed) != TYPE_DICTIONARY:
|
|
continue
|
|
var msg: Dictionary = parsed
|
|
if "id" in msg:
|
|
rpc_response.emit.call_deferred(int(msg["id"]), msg.get("result"), msg.get("error"))
|
|
elif "method" in msg:
|
|
rpc_notification.emit.call_deferred(msg["method"], msg.get("params", {}))
|
|
|
|
|
|
func _connect_and_authenticate(address: String, secret: String) -> void:
|
|
var parts := address.split(":")
|
|
var host: String = parts[0]
|
|
var port: int = int(parts[1])
|
|
|
|
rpc_socket = StreamPeerTCP.new()
|
|
if rpc_socket.connect_to_host(host, port) != OK:
|
|
logger.error("Unable to connect to butlerd at " + address)
|
|
return
|
|
|
|
# Wait for the connection to establish
|
|
var timeout := CONNECTION_TIMEOUT_ITERATIONS
|
|
while rpc_socket.get_status() == StreamPeerTCP.STATUS_CONNECTING and timeout > 0:
|
|
rpc_socket.poll()
|
|
OS.delay_msec(CONNECTION_POLL_DELAY_MS)
|
|
timeout -= 1
|
|
if rpc_socket.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
|
logger.error("Timed out connecting to butlerd")
|
|
return
|
|
|
|
connection_state = STATE.CONNECTED
|
|
|
|
var res := await _rpc_call("Meta.Authenticate", {"secret": secret})
|
|
if "error" in res:
|
|
logger.error("butlerd authentication failed: " + str(res["error"]))
|
|
return
|
|
|
|
logger.info("Connected and authenticated with butlerd")
|
|
client_ready.emit.call_deferred()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Low-level JSON-RPC helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _send_line(text: String) -> void:
|
|
if not rpc_socket:
|
|
return
|
|
rpc_socket.put_data((text + "\n").to_utf8_buffer())
|
|
|
|
|
|
## Godot's JSON.parse decodes every JSON number as a float (so 7670 becomes
|
|
## 7670.0), and JSON.stringify then re-serializes it as "7670.0". butlerd's Go
|
|
## structs use int64 for ids, and Go's encoding/json rejects "7670.0" for an
|
|
## int64 field (RPC error -32700). Normalizing integral floats back to ints at
|
|
## the parse boundary keeps every id (profileId, game.id, upload.id, ...)
|
|
## typed correctly so downstream RPC params serialize without the trailing
|
|
## ".0".
|
|
func _normalize_json(value: Variant) -> Variant:
|
|
match typeof(value):
|
|
TYPE_DICTIONARY:
|
|
var out := {}
|
|
for key in value:
|
|
out[key] = _normalize_json(value[key])
|
|
return out
|
|
TYPE_ARRAY:
|
|
var out := []
|
|
for item in value:
|
|
out.append(_normalize_json(item))
|
|
return out
|
|
TYPE_FLOAT:
|
|
if is_finite(value) and value == floor(value):
|
|
return int(value)
|
|
return value
|
|
_:
|
|
return value
|
|
|
|
|
|
## Sends a JSON-RPC request and waits (via signal await, not a busy loop, so
|
|
## [method _thread_process] keeps ticking and can deliver the response) for
|
|
## the matching response. Mirrors the `_wait_for_command` pattern used by the
|
|
## Steam plugin's steamcmd wrapper, adapted for id-based JSON-RPC instead of
|
|
## line-scraping a REPL.
|
|
func _rpc_call(method: String, params: Dictionary = {}) -> Dictionary:
|
|
var id := _next_id
|
|
_next_id += 1
|
|
var req := {"jsonrpc": "2.0", "id": id, "method": method, "params": params}
|
|
# Params can carry game dicts that went through a JSON cache roundtrip
|
|
# (Godot's JSON.parse_string decodes every number as a float), so an id
|
|
# like 936521 comes back as 936521.0 and butlerd's int64 fields reject it
|
|
# with RPC error -32700. Normalizing before serializing guarantees every
|
|
# integral number is sent as an int, mirroring the inbound normalization.
|
|
_send_line(JSON.stringify(_normalize_json(req)))
|
|
|
|
var out: Array = [-1, null, null]
|
|
while out[0] != id:
|
|
out = await rpc_response
|
|
|
|
if out[2] != null:
|
|
return {"error": out[2]}
|
|
var result: Variant = out[1]
|
|
if typeof(result) == TYPE_DICTIONARY:
|
|
return result
|
|
return {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public, high-level API. Each of these hops onto the SharedThread via
|
|
# thread_group.exec(), same convention the Steam/Epic plugins use.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func login_with_api_key(api_key: String) -> void:
|
|
await thread_group.exec(_login_with_api_key.bind(api_key))
|
|
|
|
|
|
func _login_with_api_key(api_key: String) -> void:
|
|
var res := await _rpc_call("Profile.LoginWithAPIKey", {"apiKey": api_key})
|
|
if "error" in res:
|
|
is_logged_in = false
|
|
emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {})
|
|
return
|
|
profile = res.get("profile", {})
|
|
is_logged_in = true
|
|
emit_signal.call_deferred("logged_in", LOGIN_STATUS.OK, profile)
|
|
|
|
|
|
func login_with_password(username: String, password: String) -> void:
|
|
await thread_group.exec(_login_with_password.bind(username, password))
|
|
|
|
|
|
func _login_with_password(username: String, password: String) -> void:
|
|
var params := {"username": username, "password": password}
|
|
var res := await _rpc_call("Profile.LoginWithPassword", params)
|
|
if "error" in res:
|
|
is_logged_in = false
|
|
emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {})
|
|
return
|
|
profile = res.get("profile", {})
|
|
is_logged_in = true
|
|
emit_signal.call_deferred("logged_in", LOGIN_STATUS.OK, profile)
|
|
|
|
|
|
## Attempts to resume a previous login using butlerd's stored credentials.
|
|
## Returns true on success (profile is set, logged_in signal emitted).
|
|
func try_saved_login() -> bool:
|
|
return await thread_group.exec(_try_saved_login)
|
|
|
|
|
|
func _try_saved_login() -> bool:
|
|
var list_res := await _rpc_call("Profile.List", {})
|
|
if "error" in list_res:
|
|
return false
|
|
var profiles: Array = list_res.get("profiles", [])
|
|
if profiles.is_empty():
|
|
return false
|
|
# Use the most recently connected profile.
|
|
var best: Dictionary = profiles[0]
|
|
var best_time: int = int(best.get("lastConnected", 0))
|
|
for p in profiles:
|
|
var t: int = int(p.get("lastConnected", 0))
|
|
if t > best_time:
|
|
best = p
|
|
best_time = t
|
|
var profile_id: int = int(best.get("id", 0))
|
|
if profile_id == 0:
|
|
return false
|
|
var use_res := await _rpc_call("Profile.UseSavedLogin", {"profileId": profile_id})
|
|
if "error" in use_res:
|
|
logger.warn("Profile.UseSavedLogin failed: " + str(use_res["error"]))
|
|
return false
|
|
profile = use_res.get("profile", {})
|
|
is_logged_in = true
|
|
emit_signal.call_deferred("logged_in", LOGIN_STATUS.OK, profile)
|
|
return true
|
|
|
|
|
|
## Returns every game the logged-in profile owns a download key for.
|
|
## Each item looks like: {"downloadKey": {...}, "game": {...}}
|
|
func get_owned_games() -> Array:
|
|
return await thread_group.exec(_get_owned_games)
|
|
|
|
|
|
func _get_owned_games() -> Array:
|
|
if not is_logged_in:
|
|
return []
|
|
var params := {
|
|
"profileId": profile.get("user", {}).get("id", profile.get("id", 0)),
|
|
}
|
|
var res := await _rpc_call("Fetch.ProfileOwnedKeys", params)
|
|
if "error" in res:
|
|
logger.warn("Fetch.ProfileOwnedKeys failed: " + str(res["error"]))
|
|
return []
|
|
# butlerd serves Fetch.* results from its local cache by default and only
|
|
# sets `stale` when the data hasn't been pulled from itch.io recently. On
|
|
# a fresh database that cache is empty, so without this refresh the
|
|
# library would stay empty until the daemon happens to refresh on its own.
|
|
if res.get("stale", false):
|
|
logger.info("Owned keys are stale. Refreshing from itch.io.")
|
|
params["fresh"] = true
|
|
res = await _rpc_call("Fetch.ProfileOwnedKeys", params)
|
|
if "error" in res:
|
|
logger.warn("Fetch.ProfileOwnedKeys (fresh) failed: " + str(res["error"]))
|
|
return []
|
|
return res.get("items", [])
|
|
|
|
|
|
## Returns every game present in any of the profile's collections, as full
|
|
## Game dicts (CollectionGame embeds the whole game object). These aren't
|
|
## necessarily owned: the user may have bookmarked free games in a collection.
|
|
func get_collection_games() -> Array:
|
|
return await thread_group.exec(_get_collection_games)
|
|
|
|
|
|
func _get_collection_games() -> Array:
|
|
if not is_logged_in:
|
|
return []
|
|
var params := {"profileId": profile.get("user", {}).get("id", profile.get("id", 0))}
|
|
var res := await _rpc_call("Fetch.ProfileCollections", params)
|
|
if "error" in res:
|
|
logger.warn("Fetch.ProfileCollections failed: " + str(res["error"]))
|
|
return []
|
|
if res.get("stale", false):
|
|
logger.info("Collections are stale. Refreshing from itch.io.")
|
|
params["fresh"] = true
|
|
res = await _rpc_call("Fetch.ProfileCollections", params)
|
|
if "error" in res:
|
|
logger.warn("Fetch.ProfileCollections (fresh) failed: " + str(res["error"]))
|
|
return []
|
|
return await _fetch_collection_games(
|
|
profile.get("user", {}).get("id", profile.get("id", 0)),
|
|
res.get("items", [])
|
|
)
|
|
|
|
|
|
## Like get_collection_games, but returns {id, title, games} per collection
|
|
## so the library can filter by collection visibility settings.
|
|
func get_collection_groups() -> Array:
|
|
return await thread_group.exec(_get_collection_groups)
|
|
|
|
|
|
func _get_collection_groups() -> Array:
|
|
if not is_logged_in:
|
|
return []
|
|
var params := {"profileId": profile.get("user", {}).get("id", profile.get("id", 0))}
|
|
var res := await _rpc_call("Fetch.ProfileCollections", params)
|
|
if "error" in res:
|
|
return []
|
|
if res.get("stale", false):
|
|
params["fresh"] = true
|
|
res = await _rpc_call("Fetch.ProfileCollections", params)
|
|
if "error" in res:
|
|
return []
|
|
var collections: Array = res.get("items", [])
|
|
var groups := []
|
|
for c in collections:
|
|
var collection: Dictionary = c
|
|
var col_id: int = collection.get("id", 0)
|
|
var col_title: String = collection.get("title", "")
|
|
var games := await _fetch_collection_games(
|
|
profile.get("user", {}).get("id", profile.get("id", 0)),
|
|
[collection]
|
|
)
|
|
groups.append({"id": col_id, "title": col_title, "games": games})
|
|
return groups
|
|
|
|
|
|
## Paginates through Fetch.Collection.Games for every collection, collecting the
|
|
## embedded game objects. Each collection can span multiple pages (cursor), and
|
|
## a page served from butlerd's local cache is re-issued fresh.
|
|
func _fetch_collection_games(profile_id: int, collections: Array) -> Array:
|
|
var games := []
|
|
for c in collections:
|
|
var collection: Dictionary = c
|
|
var collection_id: int = collection.get("id", 0)
|
|
var cursor: Variant = null
|
|
while true:
|
|
var params := {"profileId": profile_id, "collectionId": collection_id}
|
|
if cursor != null:
|
|
params["cursor"] = cursor
|
|
var res := await _rpc_call("Fetch.Collection.Games", params)
|
|
if "error" in res:
|
|
logger.warn("Fetch.Collection.Games failed for collection " + str(collection_id) + ": " + str(res["error"]))
|
|
break
|
|
if res.get("stale", false):
|
|
params["fresh"] = true
|
|
res = await _rpc_call("Fetch.Collection.Games", params)
|
|
if "error" in res:
|
|
logger.warn("Fetch.Collection.Games (fresh) failed for collection " + str(collection_id) + ": " + str(res["error"]))
|
|
break
|
|
for cg in res.get("items", []):
|
|
var collection_game: Dictionary = cg
|
|
var game: Dictionary = collection_game.get("game", {})
|
|
if not game.is_empty():
|
|
games.append(game)
|
|
cursor = res.get("nextCursor", null)
|
|
if cursor == null:
|
|
break
|
|
return games
|
|
|
|
|
|
## Returns every installed game (a "cave" in butlerd terminology).
|
|
## Returns `null` when butlerd is unreachable or the call errored, so callers
|
|
## can tell a genuine "no games installed" (`[]`) apart from a transient
|
|
## failure.
|
|
func get_caves() -> Variant:
|
|
return await thread_group.exec(_get_caves)
|
|
|
|
|
|
func _get_caves() -> Variant:
|
|
var params := {}
|
|
var res := await _rpc_call("Fetch.Caves", params)
|
|
if "error" in res:
|
|
logger.warn("Fetch.Caves failed: " + str(res["error"]))
|
|
return null
|
|
if res.get("stale", false):
|
|
logger.info("Caves are stale. Refreshing from itch.io.")
|
|
params["fresh"] = true
|
|
res = await _rpc_call("Fetch.Caves", params)
|
|
if "error" in res:
|
|
logger.warn("Fetch.Caves (fresh) failed: " + str(res["error"]))
|
|
return null
|
|
return res.get("items", [])
|
|
|
|
|
|
## Ensures at least one install location exists and returns its ID, creating
|
|
## one at the persistent user://butler/games path on first run. Older builds
|
|
## created the location under user://plugins/itch/games, which OGPU trashes on
|
|
## every plugin update; existing locations pointing there are left alone (their
|
|
## orphaned caves are cleaned up by [method _resolve_cave]) but never reused.
|
|
func _ensure_install_location() -> String:
|
|
if _cached_install_location_id != "":
|
|
return _cached_install_location_id
|
|
|
|
var target_path := ProjectSettings.globalize_path(GAMES_DIR)
|
|
var res := await _rpc_call("Install.Locations.List", {})
|
|
var locations: Array = res.get("installLocations", [])
|
|
for loc in locations:
|
|
var location: Dictionary = loc
|
|
if location.get("path", "") == target_path:
|
|
_cached_install_location_id = location.get("id", "")
|
|
return _cached_install_location_id
|
|
|
|
DirAccess.make_dir_recursive_absolute(target_path)
|
|
# Let butlerd generate the id so we never collide with a stale location
|
|
# that happens to carry the same id under a different path.
|
|
var add_res := await _rpc_call("Install.Locations.Add", {"path": target_path})
|
|
if "error" in add_res:
|
|
logger.error("Install.Locations.Add failed: " + str(add_res["error"]))
|
|
return ""
|
|
_cached_install_location_id = add_res.get("installLocation", {}).get("id", "")
|
|
return _cached_install_location_id
|
|
|
|
|
|
## Returns every configured install location, each as:
|
|
## {"id": "...", "path": "...", "sizeInfo": {"installedSize", "freeSize", "totalSize"}}
|
|
## The preferred location (user://butler/games) is always listed first, so it
|
|
## becomes the default choice in OGPU's install-location picker (the picker
|
|
## focuses the first card). Locations pointing inside the plugin's own
|
|
## directory are dropped: games installed there get wiped on every plugin
|
|
## update (the plugin dir is moved to trash by OGPU when a new zip is loaded).
|
|
func get_install_locations() -> Array:
|
|
return await thread_group.exec(_get_install_locations)
|
|
|
|
|
|
func _get_install_locations() -> Array:
|
|
var res := await _rpc_call("Install.Locations.List", {})
|
|
if "error" in res:
|
|
logger.warn("Install.Locations.List failed: " + str(res["error"]))
|
|
return []
|
|
var locations: Array = res.get("installLocations", [])
|
|
var preferred_path := ProjectSettings.globalize_path(GAMES_DIR)
|
|
var plugin_dir := ProjectSettings.globalize_path("user://plugins/itch")
|
|
var preferred: Array = []
|
|
var others: Array = []
|
|
for raw in locations:
|
|
var location: Dictionary = raw
|
|
var path: String = location.get("path", "")
|
|
if path.begins_with(plugin_dir):
|
|
logger.info("Ignoring install location inside the plugin directory: " + path)
|
|
continue
|
|
if path == preferred_path:
|
|
preferred.append(location)
|
|
else:
|
|
others.append(location)
|
|
return preferred + others
|
|
|
|
|
|
## Returns the uploads butlerd considers compatible with this machine for the
|
|
## given game. Each entry is a full Upload dict (id, filename, displayName,
|
|
## size, type, platforms, ...).
|
|
func get_compatible_uploads(game_id: int) -> Array:
|
|
return await thread_group.exec(_get_compatible_uploads.bind(game_id))
|
|
|
|
|
|
func _get_compatible_uploads(game_id: int) -> Array:
|
|
var params := {"gameId": game_id, "compatible": true}
|
|
var res := await _rpc_call("Fetch.GameUploads", params)
|
|
if "error" in res:
|
|
logger.warn("Fetch.GameUploads failed for game " + str(game_id) + ": " + str(res["error"]))
|
|
return []
|
|
# Uploads are cached by butlerd just like owned keys, so retry fresh when
|
|
# the cached result is stale, otherwise a cold database would report every
|
|
# game as having no compatible upload.
|
|
if res.get("stale", false):
|
|
logger.info("Uploads are stale. Refreshing from itch.io.")
|
|
params["fresh"] = true
|
|
res = await _rpc_call("Fetch.GameUploads", params)
|
|
if "error" in res:
|
|
logger.warn("Fetch.GameUploads (fresh) failed for game " + str(game_id) + ": " + str(res["error"]))
|
|
return []
|
|
return res.get("uploads", [])
|
|
|
|
|
|
## Looks for updates to installed caves. When cave_ids is empty, butlerd checks
|
|
## every cave and respects per-cave snooze (matching the official itch.io app).
|
|
## Returns the list of GameUpdate dicts (each with a `caveId`).
|
|
func check_updates(cave_ids: Array = []) -> Array:
|
|
return await thread_group.exec(_check_updates.bind(cave_ids))
|
|
|
|
|
|
func _check_updates(cave_ids: Array) -> Array:
|
|
var params := {}
|
|
if not cave_ids.is_empty():
|
|
params["caveIds"] = cave_ids
|
|
var res := await _rpc_call("CheckUpdate", params)
|
|
if "error" in res:
|
|
logger.warn("CheckUpdate failed: " + str(res["error"]))
|
|
return []
|
|
return res.get("updates", [])
|
|
|
|
|
|
## Installs (or updates, if a caveId is given) the given game.
|
|
## `options` may carry:
|
|
## - "install_location_id": the InstallLocation id to install into (fresh installs)
|
|
## - "upload": the upload filename/displayName chosen by the user (defaults to
|
|
## butlerd's first compatible upload otherwise)
|
|
## Returns true when the operation finished without errors.
|
|
func install(game: Dictionary, cave_id: String = "", options: Dictionary = {}) -> bool:
|
|
return await thread_group.exec(_install.bind(game, cave_id, options))
|
|
|
|
|
|
## Reconciles an install/update request against what butlerd actually has
|
|
## installed. OGPU moves the entire extracted plugin directory (including any
|
|
## games previously installed under user://plugins/itch/games) to the trash
|
|
## whenever the plugin is updated, leaving "orphan" caves in butler.db that
|
|
## point at folders which no longer exist. Those caves make a fresh install
|
|
## fail with butlerd's "That upload is already installed!" error while the
|
|
## game can't actually be launched, so we detect them here and uninstall them
|
|
## first (the folder is already gone, so nothing is lost).
|
|
##
|
|
## Returns the cave id to pass to Install.Queue: a valid existing cave when
|
|
## one is found (so the request becomes an update), or an empty string when
|
|
## the game needs a fresh install.
|
|
func _resolve_cave(game: Dictionary, cave_id: String) -> String:
|
|
var game_id: int = game.get("id", 0)
|
|
var res := await _rpc_call("Fetch.Caves", {"filters": {"gameId": game_id}})
|
|
if "error" in res:
|
|
logger.warn("Fetch.Caves failed while reconciling game " + str(game_id) + ": " + str(res["error"]))
|
|
return cave_id
|
|
|
|
var matched: Array = []
|
|
for c in res.get("items", []):
|
|
var cave: Dictionary = c
|
|
if cave_id == "" or cave.get("id", "") == cave_id:
|
|
matched.append(cave)
|
|
if matched.is_empty():
|
|
return cave_id
|
|
|
|
for cave in matched:
|
|
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
|
|
if DirAccess.dir_exists_absolute(install_folder):
|
|
return cave.get("id", cave_id)
|
|
|
|
for cave in matched:
|
|
var orphan_id: String = cave.get("id", "")
|
|
logger.info("Uninstalling orphaned cave " + orphan_id + " for game " + str(game_id) + " (install folder is missing)")
|
|
await _rpc_call("Uninstall.Perform", {"caveId": orphan_id})
|
|
return ""
|
|
|
|
|
|
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)
|
|
var reason := "update" if cave_id != "" else "install"
|
|
|
|
var uploads := await _get_compatible_uploads(game_id)
|
|
if uploads.is_empty():
|
|
logger.error("No compatible uploads found for game id " + str(game_id))
|
|
emit_signal.call_deferred("app_installed", cave_id, false)
|
|
emit_signal.call_deferred(
|
|
"install_failed",
|
|
cave_id if cave_id != "" else str(game_id),
|
|
game.get("title", "Unknown itch.io game"),
|
|
"No compatible upload found for this game."
|
|
)
|
|
return false
|
|
var upload: Dictionary = uploads[0]
|
|
# Respect the upload the user picked in the install-options dialog, when
|
|
# one was offered (the game has more than one compatible upload).
|
|
var chosen_upload: Variant = options.get("upload", null)
|
|
if chosen_upload != null:
|
|
var picked := _pick_upload(uploads, str(chosen_upload))
|
|
if picked.is_empty():
|
|
logger.warn("Selected upload '" + str(chosen_upload) + "' not found. Using default upload.")
|
|
else:
|
|
upload = picked
|
|
|
|
var location_id: String = options.get("install_location_id", "") as String
|
|
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)
|
|
if "error" in queue_res:
|
|
var queue_error: String = _rpc_error_message(queue_res)
|
|
logger.error("Install.Queue failed: " + str(queue_res["error"]))
|
|
logger.error("Install.Queue failed (message): " + queue_error)
|
|
emit_signal.call_deferred("app_installed", cave_id, false)
|
|
emit_signal.call_deferred(
|
|
"install_failed",
|
|
cave_id if cave_id != "" else str(game_id),
|
|
game.get("title", "Unknown itch.io game"),
|
|
queue_error
|
|
)
|
|
return false
|
|
|
|
var task_id: String = queue_res.get("id", "")
|
|
var staging_folder: String = queue_res.get("stagingFolder", "")
|
|
|
|
var on_notification := func(method: String, params: Variant) -> void:
|
|
if method != "Progress":
|
|
return
|
|
var p: Dictionary = params
|
|
var progress: float = p.get("progress", 0.0)
|
|
install_progressed.emit.call_deferred(
|
|
cave_id if cave_id != "" else str(game_id),
|
|
int(progress * PERCENTAGE_SCALE),
|
|
PERCENTAGE_SCALE
|
|
)
|
|
rpc_notification.connect(on_notification)
|
|
|
|
var perform_res := await _rpc_call("Install.Perform", {"id": task_id, "stagingFolder": staging_folder})
|
|
|
|
# butler can panic ("runtime error: slice bounds out of range") when
|
|
# resuming a download whose checkpoint recorded an invalid offset (a
|
|
# stale/corrupt partial download in the staging folder). Clear just the
|
|
# download checkpoint and partial copy and retry once so the download
|
|
# starts from scratch. The rest of the staging folder (the queue metadata
|
|
# that holds the game/upload) must be left intact, or butler fails the
|
|
# retry with "Corrupted download info (missing game)".
|
|
if "error" in perform_res and staging_folder != "" and _is_download_resume_panic(perform_res.get("error")):
|
|
logger.warn("butler panicked resuming download; clearing download state and retrying once")
|
|
_clear_download_state(staging_folder)
|
|
perform_res = await _rpc_call("Install.Perform", {"id": task_id, "stagingFolder": staging_folder})
|
|
if "error" in perform_res:
|
|
logger.error("Install.Perform retry after clearing download state also failed: " + str(perform_res.get("error")))
|
|
else:
|
|
logger.info("Install.Perform recovered after clearing corrupt download state")
|
|
|
|
rpc_notification.disconnect(on_notification)
|
|
|
|
var success := not ("error" in perform_res)
|
|
if not success:
|
|
var error_msg: String = _rpc_error_message(perform_res)
|
|
logger.error("Install.Perform failed: " + str(perform_res.get("error")))
|
|
logger.error("Install.Perform failed (message): " + error_msg)
|
|
emit_signal.call_deferred(
|
|
"install_failed",
|
|
cave_id if cave_id != "" else str(game_id),
|
|
game.get("title", "Unknown itch.io game"),
|
|
error_msg
|
|
)
|
|
|
|
if reason == "update":
|
|
emit_signal.call_deferred("app_updated", cave_id, success)
|
|
else:
|
|
emit_signal.call_deferred("app_installed", cave_id, success)
|
|
return success
|
|
|
|
|
|
## Extracts a concise, human-readable message from a butlerd RPC error dict
|
|
## (the "message" field, falling back to the whole error when absent).
|
|
func _rpc_error_message(res: Dictionary) -> String:
|
|
var error: Variant = res.get("error", null)
|
|
if error == null:
|
|
return "Unknown error"
|
|
if error is Dictionary:
|
|
var msg: String = error.get("message", "")
|
|
if msg != "":
|
|
return msg
|
|
return str(error)
|
|
|
|
|
|
## True when butler panicked resuming a partially-downloaded game. The panic
|
|
## message ("runtime error: slice bounds out of range") comes from savior's
|
|
## seekSource when a corrupt checkpoint leaves the read offset beyond the
|
|
## section size, so the only reliable way forward is a fresh download.
|
|
func _is_download_resume_panic(error: Variant) -> bool:
|
|
return str(error).contains("slice bounds out of range")
|
|
|
|
|
|
## Removes butler's download checkpoint file(s) and the partially-downloaded
|
|
## install source from the staging folder, so the next Install.Perform attempt
|
|
## downloads from scratch. Only the *download* state is cleared: the queue
|
|
## metadata butler persisted alongside it (which holds the game/upload the
|
|
## install was queued with) must survive, or the retry fails with "Corrupted
|
|
## download info (missing game)".
|
|
func _clear_download_state(staging_folder: String) -> void:
|
|
if staging_folder == "" or not DirAccess.dir_exists_absolute(staging_folder):
|
|
return
|
|
var dir := DirAccess.open(staging_folder)
|
|
if dir == null:
|
|
logger.warn("Could not open staging folder to clear download state: " + staging_folder)
|
|
return
|
|
dir.list_dir_begin()
|
|
var fname := dir.get_next()
|
|
while fname != "":
|
|
if fname.begins_with("downsource-") and fname.ends_with("-state.dat"):
|
|
DirAccess.remove_absolute(staging_folder + "/" + fname)
|
|
logger.info("Removed corrupt download checkpoint: " + fname)
|
|
fname = dir.get_next()
|
|
dir.list_dir_end()
|
|
# The partially-downloaded copy butler was resuming from also has to go.
|
|
if DirAccess.dir_exists_absolute(staging_folder + "/install-source"):
|
|
_wipe_dir(staging_folder + "/install-source")
|
|
logger.info("Cleared partial download copy in staging install-source")
|
|
|
|
|
|
## Recursively removes the contents of a directory, leaving the directory
|
|
## itself in place.
|
|
func _wipe_dir(path: String) -> void:
|
|
if path == "" or not DirAccess.dir_exists_absolute(path):
|
|
return
|
|
var dir := DirAccess.open(path)
|
|
if dir == null:
|
|
logger.warn("Could not open staging folder to clear: " + path)
|
|
return
|
|
dir.list_dir_begin()
|
|
var fname := dir.get_next()
|
|
while fname != "":
|
|
if fname != "." and fname != "..":
|
|
var full: String = path + "/" + fname
|
|
if dir.current_is_dir():
|
|
_wipe_dir(full)
|
|
DirAccess.remove_absolute(full)
|
|
else:
|
|
DirAccess.remove_absolute(full)
|
|
fname = dir.get_next()
|
|
dir.list_dir_end()
|
|
|
|
|
|
## 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:
|
|
for u in uploads:
|
|
var upload: Dictionary = u
|
|
if upload.get("displayName", "") == chosen or upload.get("filename", "") == chosen:
|
|
return upload
|
|
return {}
|
|
|
|
|
|
## Uninstalls the given cave. Returns true on success.
|
|
func uninstall(cave_id: String) -> bool:
|
|
return await thread_group.exec(_uninstall.bind(cave_id))
|
|
|
|
|
|
func _uninstall(cave_id: String) -> bool:
|
|
var res := await _rpc_call("Uninstall.Perform", {"caveId": cave_id})
|
|
var success := not ("error" in res)
|
|
if not success:
|
|
logger.error("Uninstall.Perform failed: " + str(res.get("error")))
|
|
emit_signal.call_deferred("app_uninstalled", cave_id, success)
|
|
return success
|
|
|
|
|
|
## Launches the given cave. This call blocks (on the background thread) for
|
|
## the entire lifetime of the game, so callers should not await it directly
|
|
## from a UI-facing context; connect to `launch_exited` instead if needed.
|
|
func launch(cave_id: String) -> void:
|
|
await thread_group.exec(_launch.bind(cave_id))
|
|
|
|
|
|
func _launch(cave_id: String) -> void:
|
|
var prereqs_dir := ProjectSettings.globalize_path("user://butler/prereqs")
|
|
DirAccess.make_dir_recursive_absolute(prereqs_dir)
|
|
|
|
# `Launch` may send interactive requests mid-call (PickManifestAction,
|
|
# AcceptLicense, ShellLaunch, HTMLLaunch). This client answers the common
|
|
# ones with sane non-interactive defaults; anything fancier (HTML5
|
|
# games, EULAs) is a good next step but out of scope for a first pass.
|
|
var on_notification := func(method: String, params: Variant) -> void:
|
|
match method:
|
|
"PrereqsStarted":
|
|
logger.info("Installing prerequisites for cave " + cave_id)
|
|
"LaunchRunning":
|
|
logger.info("Game running: " + cave_id)
|
|
rpc_notification.connect(on_notification)
|
|
|
|
var res := await _rpc_call("Launch", {"caveId": cave_id, "prereqsDir": prereqs_dir})
|
|
|
|
rpc_notification.disconnect(on_notification)
|
|
|
|
if "error" in res:
|
|
logger.error("Launch failed: " + str(res["error"]))
|
|
launch_exited.emit.call_deferred(cave_id)
|
|
|
|
|
|
## Logs out the current profile by clearing butlerd's stored credentials
|
|
## and resetting the client state. For API key logins the caller should
|
|
## also clear the saved key via settings_manager.
|
|
func logout() -> void:
|
|
await thread_group.exec(_logout)
|
|
|
|
|
|
func _logout() -> 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:
|
|
# butlerd doesn't have an explicit logout RPC; forgetting the profile
|
|
# is achieved by clearing the DB. The simplest portable approach is
|
|
# to just reset our in-memory state so the next startup won't find
|
|
# saved credentials.
|
|
logger.info("Logging out profile " + str(profile_id))
|
|
is_logged_in = false
|
|
profile = {}
|
|
emit_signal.call_deferred("logged_in", LOGIN_STATUS.FAILED, {})
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
if rpc_socket:
|
|
rpc_socket.disconnect_from_host()
|
|
if not butler_process:
|
|
return
|
|
butler_process.stop()
|