Initial commit
This commit is contained in:
commit
3132a5015a
14 changed files with 1730 additions and 0 deletions
472
core/itch_client.gd
Normal file
472
core/itch_client.gd
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
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"
|
||||
const butler_dir := "user://plugins/itch/assets/butler"
|
||||
const CACHE_DIR := "itch"
|
||||
|
||||
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 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 proc: InteractiveProcess
|
||||
var socket: StreamPeerTCP
|
||||
var 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 _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_bin := "/".join([ProjectSettings.globalize_path(butler_dir), "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([ProjectSettings.globalize_path("user://plugins/itch/assets"), "butler.db"])
|
||||
var args := [
|
||||
"daemon",
|
||||
"--json",
|
||||
"--transport", "tcp",
|
||||
"--keep-alive",
|
||||
"--dbpath", dbpath,
|
||||
"--address", "https://itch.io",
|
||||
"--user-agent", "OpenGamepadUI-itch/0.1.0",
|
||||
"--destiny-pid", str(OS.get_process_id()),
|
||||
]
|
||||
|
||||
proc = InteractiveProcess.new(butler_bin, args)
|
||||
if proc.start() != OK:
|
||||
logger.error("Unable to spawn butlerd")
|
||||
return
|
||||
client_started = true
|
||||
state = STATE.WAITING_HANDSHAKE
|
||||
bootstrap_finished.emit()
|
||||
|
||||
|
||||
## 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 goos := "linux"
|
||||
var goarch := "amd64"
|
||||
if OS.get_name() == "Windows":
|
||||
goos = "windows"
|
||||
if OS.get_name() == "macOS":
|
||||
goos = "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:
|
||||
goarch = "arm64"
|
||||
|
||||
var platform_slug := goos + "-" + goarch
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wire protocol: spawn -> read handshake off stdout -> connect TCP -> auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _thread_process(_delta: float) -> void:
|
||||
if state == STATE.WAITING_HANDSHAKE and proc:
|
||||
_proc_buffer += proc.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 = 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 state == STATE.CONNECTED and socket:
|
||||
socket.poll()
|
||||
var available := socket.get_available_bytes()
|
||||
if available <= 0:
|
||||
return
|
||||
var chunk := 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 = 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])
|
||||
|
||||
socket = StreamPeerTCP.new()
|
||||
if 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 := 50
|
||||
while socket.get_status() == StreamPeerTCP.STATUS_CONNECTING and timeout > 0:
|
||||
socket.poll()
|
||||
OS.delay_msec(20)
|
||||
timeout -= 1
|
||||
if socket.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||
logger.error("Timed out connecting to butlerd")
|
||||
return
|
||||
|
||||
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 socket:
|
||||
return
|
||||
socket.put_data((text + "\n").to_utf8_buffer())
|
||||
|
||||
|
||||
## 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}
|
||||
_send_line(JSON.stringify(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)
|
||||
|
||||
|
||||
## Returns every game the logged-in profile owns a download key for.
|
||||
## Each item looks like: {"downloadKey": {...}, "game": {...}}
|
||||
func get_owned_games(fresh: bool = false) -> Array:
|
||||
return await thread_group.exec(_get_owned_games.bind(fresh))
|
||||
|
||||
|
||||
func _get_owned_games(fresh: bool) -> Array:
|
||||
if not is_logged_in:
|
||||
return []
|
||||
var res := await _rpc_call("Fetch.ProfileOwnedKeys", {
|
||||
"profileId": profile.get("user", {}).get("id", profile.get("id", 0)),
|
||||
"fresh": fresh,
|
||||
})
|
||||
if "error" in res:
|
||||
logger.warn("Fetch.ProfileOwnedKeys failed: " + str(res["error"]))
|
||||
return []
|
||||
return res.get("items", [])
|
||||
|
||||
|
||||
## Returns every installed game (a "cave" in butlerd terminology).
|
||||
func get_caves() -> Array:
|
||||
return await thread_group.exec(_get_caves)
|
||||
|
||||
|
||||
func _get_caves() -> Array:
|
||||
var res := await _rpc_call("Fetch.Caves", {})
|
||||
if "error" in res:
|
||||
logger.warn("Fetch.Caves failed: " + str(res["error"]))
|
||||
return []
|
||||
return res.get("items", [])
|
||||
|
||||
|
||||
## Ensures at least one install location exists and returns its ID, creating
|
||||
## a default one under the plugin's data directory on first run.
|
||||
func _ensure_install_location() -> String:
|
||||
if _install_location_id != "":
|
||||
return _install_location_id
|
||||
|
||||
var res := await _rpc_call("Install.Locations.List", {})
|
||||
var locations: Array = res.get("installLocations", [])
|
||||
if locations.size() > 0:
|
||||
_install_location_id = locations[0].get("id", "")
|
||||
return _install_location_id
|
||||
|
||||
var path := ProjectSettings.globalize_path("user://plugins/itch/games")
|
||||
DirAccess.make_dir_recursive_absolute(path)
|
||||
var add_res := await _rpc_call("Install.Locations.Add", {"id": "default", "path": path})
|
||||
_install_location_id = add_res.get("installLocation", {}).get("id", "default")
|
||||
return _install_location_id
|
||||
|
||||
|
||||
## Installs (or updates, if a caveId is given) the given game.
|
||||
func install(game: Dictionary, cave_id: String = "") -> void:
|
||||
await thread_group.exec(_install.bind(game, cave_id))
|
||||
|
||||
|
||||
func _install(game: Dictionary, cave_id: String) -> void:
|
||||
var game_id: int = game.get("id", 0)
|
||||
var reason := "update" if cave_id != "" else "install"
|
||||
|
||||
var uploads_res := await _rpc_call("Fetch.GameUploads", {"gameId": game_id, "compatible": true})
|
||||
var uploads: Array = uploads_res.get("uploads", [])
|
||||
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)
|
||||
return
|
||||
var upload: Dictionary = uploads[0]
|
||||
|
||||
var 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:
|
||||
logger.error("Install.Queue failed: " + str(queue_res["error"]))
|
||||
emit_signal.call_deferred("app_installed", cave_id, false)
|
||||
return
|
||||
|
||||
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 * 100),
|
||||
100
|
||||
)
|
||||
rpc_notification.connect(on_notification)
|
||||
|
||||
var perform_res := await _rpc_call("Install.Perform", {"id": task_id, "stagingFolder": staging_folder})
|
||||
|
||||
rpc_notification.disconnect(on_notification)
|
||||
|
||||
var success := not ("error" in perform_res)
|
||||
if not success:
|
||||
logger.error("Install.Perform failed: " + str(perform_res.get("error")))
|
||||
|
||||
if reason == "update":
|
||||
emit_signal.call_deferred("app_updated", cave_id, success)
|
||||
else:
|
||||
emit_signal.call_deferred("app_installed", cave_id, success)
|
||||
|
||||
|
||||
## Uninstalls the given cave.
|
||||
func uninstall(cave_id: String) -> void:
|
||||
await thread_group.exec(_uninstall.bind(cave_id))
|
||||
|
||||
|
||||
func _uninstall(cave_id: String) -> void:
|
||||
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)
|
||||
|
||||
|
||||
## 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://plugins/itch/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)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if socket:
|
||||
socket.disconnect_from_host()
|
||||
if not proc:
|
||||
return
|
||||
proc.stop()
|
||||
6
core/itch_client.tscn
Normal file
6
core/itch_client.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3 uid="uid://itch_client_scene"]
|
||||
|
||||
[ext_resource type="Script" path="res://plugins/itch/core/itch_client.gd" id="1"]
|
||||
|
||||
[node name="ItchClient" type="Node"]
|
||||
script = ExtResource("1")
|
||||
70
core/itch_settings.gd
Normal file
70
core/itch_settings.gd
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
extends Control
|
||||
|
||||
## itch.io plugin settings screen
|
||||
##
|
||||
## NOTE: this uses plain Godot Controls rather than OpenGamepadUI's themed
|
||||
## widget set (the ones used by the built-in Steam plugin's settings scene,
|
||||
## e.g. its custom StatusIndicator/TextInput components), since this plugin
|
||||
## was written without editor access to those scene resources. Swap the
|
||||
## nodes below for the themed equivalents if you want it to match the rest
|
||||
## of the settings UI pixel-for-pixel.
|
||||
|
||||
const ItchClient := preload("res://plugins/itch/core/itch_client.gd")
|
||||
var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager
|
||||
var notification_manager := load("res://core/global/notification_manager.tres") as NotificationManager
|
||||
const icon := preload("res://plugins/itch/assets/itch.svg")
|
||||
|
||||
@onready var status_label: Label = $%StatusLabel
|
||||
@onready var api_key_box: LineEdit = $%ApiKeyInput
|
||||
@onready var save_button: Button = $%SaveButton
|
||||
@onready var help_label: Label = $%HelpLabel
|
||||
|
||||
@onready var itch: ItchClient = get_tree().get_first_node_in_group("itch_client")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var api_key := settings_manager.get_value("plugin.itch", "api_key", "") as String
|
||||
api_key_box.text = api_key
|
||||
api_key_box.secret = true
|
||||
|
||||
help_label.text = "Get an API key from https://itch.io/user/settings/api-keys"
|
||||
|
||||
_update_status()
|
||||
itch.client_ready.connect(_update_status)
|
||||
itch.logged_in.connect(_on_login)
|
||||
|
||||
save_button.pressed.connect(_on_save_button)
|
||||
|
||||
|
||||
func _update_status() -> void:
|
||||
if not itch:
|
||||
status_label.text = "Status: itch client not found"
|
||||
return
|
||||
if itch.state == itch.STATE.BOOT:
|
||||
status_label.text = "Status: starting butlerd..."
|
||||
return
|
||||
if not itch.is_logged_in:
|
||||
status_label.text = "Status: connected, not logged in"
|
||||
return
|
||||
var username: String = itch.profile.get("user", {}).get("username", "")
|
||||
status_label.text = "Status: logged in as " + username
|
||||
|
||||
|
||||
func _on_login(status: ItchClient.LOGIN_STATUS, _profile: Dictionary) -> void:
|
||||
_update_status()
|
||||
|
||||
var notify := Notification.new("")
|
||||
notify.icon = icon
|
||||
if status == ItchClient.LOGIN_STATUS.OK:
|
||||
notify.text = "Successfully logged in to itch.io"
|
||||
else:
|
||||
notify.text = "itch.io login failed. Double check your API key."
|
||||
notification_manager.show(notify)
|
||||
|
||||
|
||||
func _on_save_button() -> void:
|
||||
var api_key: String = api_key_box.text.strip_edges()
|
||||
settings_manager.set_value("plugin.itch", "api_key", api_key)
|
||||
if api_key == "":
|
||||
return
|
||||
itch.login_with_api_key(api_key)
|
||||
48
core/itch_settings.tscn
Normal file
48
core/itch_settings.tscn
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
[gd_scene load_steps=2 format=3 uid="uid://itch_settings_scene"]
|
||||
|
||||
[ext_resource type="Script" path="res://plugins/itch/core/itch_settings.gd" id="1"]
|
||||
|
||||
[node name="ItchSettings" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -200.0
|
||||
offset_top = -60.0
|
||||
offset_right = 200.0
|
||||
offset_bottom = 60.0
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "itch.io"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Status: starting butlerd..."
|
||||
|
||||
[node name="ApiKeyInput" type="LineEdit" parent="VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
placeholder_text = "itch.io API key"
|
||||
|
||||
[node name="HelpLabel" type="Label" parent="VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
autowrap_mode = 2
|
||||
text = "Get an API key from https://itch.io/user/settings/api-keys"
|
||||
|
||||
[node name="SaveButton" type="Button" parent="VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Save and Log In"
|
||||
154
core/library_itch.gd
Normal file
154
core/library_itch.gd
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
extends Library
|
||||
|
||||
const ItchClient := preload("res://plugins/itch/core/itch_client.gd")
|
||||
const _apps_cache_file: String = "apps.json"
|
||||
|
||||
@onready var itch: ItchClient = get_tree().get_first_node_in_group("itch_client")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
super()
|
||||
library_id = "itch"
|
||||
logger_name = "itch"
|
||||
logger = Log.get_logger(logger_name, log_level)
|
||||
logger.info("itch.io library loaded")
|
||||
itch.logged_in.connect(_on_logged_in)
|
||||
itch.install_progressed.connect(_on_install_progressed)
|
||||
|
||||
|
||||
func get_library_launch_items() -> Array[LibraryLaunchItem]:
|
||||
return await _load_library(Cache.FLAGS.LOAD | Cache.FLAGS.SAVE)
|
||||
|
||||
|
||||
func install_to(item: LibraryLaunchItem, _location: InstallLocation = null, _options: Dictionary = {}) -> void:
|
||||
var game := (item.metadata.get("game", {}) as Dictionary)
|
||||
itch.install(game)
|
||||
|
||||
|
||||
func update(item: LibraryLaunchItem) -> void:
|
||||
var game := (item.metadata.get("game", {}) as Dictionary)
|
||||
itch.install(game, item.provider_app_id)
|
||||
|
||||
|
||||
func uninstall(item: LibraryLaunchItem) -> void:
|
||||
itch.uninstall(item.provider_app_id)
|
||||
|
||||
|
||||
## itch.io's CheckUpdate call is async (and rate-limited), so we don't poll it
|
||||
## synchronously here. LibraryManager is expected to periodically call
|
||||
## get_library_launch_items() again, which re-fetches from butlerd's cache.
|
||||
func has_update(_item: LibraryLaunchItem) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func _on_logged_in(status: ItchClient.LOGIN_STATUS, _profile: Dictionary) -> void:
|
||||
if status != ItchClient.LOGIN_STATUS.OK:
|
||||
return
|
||||
logger.info("Logged in. Refreshing itch.io library.")
|
||||
var items: Array = await _load_library(Cache.FLAGS.SAVE)
|
||||
for i in items:
|
||||
var item: LibraryLaunchItem = i
|
||||
if not library_manager.has_app(item.name):
|
||||
logger.debug("App '" + item.name + "' was not loaded. Reloading library.")
|
||||
library_manager.reload_library()
|
||||
return
|
||||
|
||||
|
||||
## Re-emits itch.io's raw (id, current, total) install progress under the
|
||||
## base [Library] signal contract (item, percent_completed) so the UI's
|
||||
## generic install-progress widgets pick it up regardless of provider.
|
||||
## TODO: verify the exact LibraryManager lookup method/key for resolving a
|
||||
## provider_app_id back to its LibraryLaunchItem (this plugin was written
|
||||
## against the public Library/LibraryLaunchItem API, but LibraryManager's
|
||||
## internals weren't available while writing this) and wire the emit below
|
||||
## through it instead of dropping the notification.
|
||||
func _on_install_progressed(_id: String, _current: int, _total: int) -> void:
|
||||
pass
|
||||
|
||||
|
||||
## Builds the full itch.io library: owned games merged with install state
|
||||
## from Fetch.Caves. Uses the standard Cache system so we don't hammer
|
||||
## butlerd (and, transitively, the itch.io API) on every library refresh.
|
||||
func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> Array[LibraryLaunchItem]:
|
||||
if caching_flags & Cache.FLAGS.LOAD and Cache.is_cached(_cache_dir, _apps_cache_file):
|
||||
var json_items = Cache.get_json(_cache_dir, _apps_cache_file)
|
||||
if json_items != null:
|
||||
logger.info("itch.io apps found in cache. Using cache.")
|
||||
var items := [] as Array[LibraryLaunchItem]
|
||||
for i in json_items:
|
||||
items.append(LibraryLaunchItem.from_dict(i))
|
||||
return items
|
||||
|
||||
if not itch.is_logged_in:
|
||||
logger.info("itch.io client is not logged in yet.")
|
||||
return []
|
||||
|
||||
logger.info("Fetching itch.io library...")
|
||||
var owned: Array = await itch.get_owned_games(false)
|
||||
var caves: Array = await itch.get_caves()
|
||||
|
||||
var caves_by_game_id := {}
|
||||
for c in caves:
|
||||
var cave: Dictionary = c
|
||||
var game_id: int = cave.get("game", {}).get("id", 0)
|
||||
caves_by_game_id[game_id] = cave
|
||||
|
||||
var items := [] as Array[LibraryLaunchItem]
|
||||
for o in owned:
|
||||
var owned_entry: Dictionary = o
|
||||
var game: Dictionary = owned_entry.get("game", {})
|
||||
if game.is_empty():
|
||||
continue
|
||||
# Only surface actual games (itch.io also hosts tools, assets,
|
||||
# soundtracks, comics, etc. under the same ownership API).
|
||||
if game.get("classification", "game") != "game":
|
||||
continue
|
||||
|
||||
var game_id: int = game.get("id", 0)
|
||||
var cave: Dictionary = caves_by_game_id.get(game_id, {})
|
||||
|
||||
var item := LibraryLaunchItem.new()
|
||||
item.provider_app_id = cave.get("id", "")
|
||||
item.name = game.get("title", "Unknown itch.io game")
|
||||
item.tags = ["itch"]
|
||||
item.categories = ["Game"]
|
||||
item.installed = not cave.is_empty()
|
||||
item.metadata = {"game": game}
|
||||
|
||||
if not cave.is_empty():
|
||||
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
|
||||
var exe := _find_executable(install_folder)
|
||||
item.command = exe
|
||||
item.args = []
|
||||
item.cwd = install_folder
|
||||
|
||||
items.append(item)
|
||||
|
||||
if caching_flags & Cache.FLAGS.SAVE:
|
||||
logger.debug("Saving itch.io apps to cache.")
|
||||
var json_items := []
|
||||
for i in items:
|
||||
var item: LibraryLaunchItem = i
|
||||
json_items.append(item.to_dict())
|
||||
if Cache.save_json(_cache_dir, _apps_cache_file, json_items) != OK:
|
||||
logger.warn("Unable to save itch.io apps cache")
|
||||
|
||||
return items
|
||||
|
||||
|
||||
## Best-effort discovery of the game's launch executable inside its install
|
||||
## 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.
|
||||
## TODO: parse .itch/receipt.json.gz for the authoritative launch target
|
||||
## and any declared manifest Actions instead of guessing.
|
||||
func _find_executable(install_folder: String) -> String:
|
||||
if install_folder == "":
|
||||
return ""
|
||||
var out := []
|
||||
OS.execute("bash", [
|
||||
"-c",
|
||||
"find " + install_folder.c_escape() + " -maxdepth 2 -type f -executable | head -n 1"
|
||||
], out)
|
||||
if out.is_empty():
|
||||
return ""
|
||||
return (out[0] as String).strip_edges()
|
||||
6
core/library_itch.tscn
Normal file
6
core/library_itch.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3 uid="uid://library_itch_scene"]
|
||||
|
||||
[ext_resource type="Script" path="res://plugins/itch/core/library_itch.gd" id="1"]
|
||||
|
||||
[node name="LibraryItch" type="Node"]
|
||||
script = ExtResource("1")
|
||||
Loading…
Add table
Add a link
Reference in a new issue