Compare commits
No commits in common. "trunk" and "v0.1.33" have entirely different histories.
6 changed files with 372 additions and 403 deletions
|
|
@ -371,56 +371,6 @@ func _login_with_api_key(api_key: String) -> void:
|
||||||
emit_signal.call_deferred("logged_in", LOGIN_STATUS.OK, profile)
|
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.
|
## Returns every game the logged-in profile owns a download key for.
|
||||||
## Each item looks like: {"downloadKey": {...}, "game": {...}}
|
## Each item looks like: {"downloadKey": {...}, "game": {...}}
|
||||||
func get_owned_games() -> Array:
|
func get_owned_games() -> Array:
|
||||||
|
|
@ -479,38 +429,6 @@ func _get_collection_games() -> Array:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
## 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
|
## Paginates through Fetch.Collection.Games for every collection, collecting the
|
||||||
## embedded game objects. Each collection can span multiple pages (cursor), and
|
## embedded game objects. Each collection can span multiple pages (cursor), and
|
||||||
## a page served from butlerd's local cache is re-issued fresh.
|
## a page served from butlerd's local cache is re-issued fresh.
|
||||||
|
|
@ -961,28 +879,6 @@ func _launch(cave_id: String) -> void:
|
||||||
launch_exited.emit.call_deferred(cave_id)
|
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:
|
func _exit_tree() -> void:
|
||||||
if socket:
|
if socket:
|
||||||
socket.disconnect_from_host()
|
socket.disconnect_from_host()
|
||||||
|
|
|
||||||
|
|
@ -1,199 +1,77 @@
|
||||||
extends MarginContainer
|
extends Control
|
||||||
|
|
||||||
## itch.io plugin settings screen
|
## itch.io plugin settings screen
|
||||||
##
|
##
|
||||||
## Uses OpenGamepadUI's themed widget set (StatusPanel, ComponentTextInput,
|
## NOTE: this uses plain Godot Controls rather than OpenGamepadUI's themed
|
||||||
## Toggle, CardButton, Dropdown) for consistent look with the rest of the
|
## widget set (the ones used by the built-in Steam plugin's settings scene,
|
||||||
## settings UI.
|
## 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")
|
const ItchClient := preload("res://plugins/itch/core/itch_client.gd")
|
||||||
var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager
|
var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager
|
||||||
var notification_manager := load("res://core/global/notification_manager.tres") as NotificationManager
|
var notification_manager := load("res://core/global/notification_manager.tres") as NotificationManager
|
||||||
const icon := preload("res://plugins/itch/assets/itch.svg")
|
const icon := preload("res://plugins/itch/assets/itch.svg")
|
||||||
|
|
||||||
@onready var status := $%Status as StatusPanel
|
@onready var status_label: Label = $%StatusLabel
|
||||||
@onready var connected_status := $%ConnectedStatus as StatusPanel
|
@onready var api_key_box: LineEdit = $%ApiKeyInput
|
||||||
@onready var logged_in_status := $%LoggedInStatus as StatusPanel
|
@onready var save_button: Button = $%SaveButton
|
||||||
@onready var login_method := $%LoginMethod as Dropdown
|
@onready var help_label: Label = $%HelpLabel
|
||||||
@onready var api_key_box := $%ApiKeyInput as ComponentTextInput
|
@onready var filter_check: CheckBox = $%FilterUnsupported
|
||||||
@onready var username_box := $%UsernameInput as ComponentTextInput
|
|
||||||
@onready var password_box := $%PasswordInput as ComponentTextInput
|
|
||||||
@onready var save_button := $%SaveButton as CardButton
|
|
||||||
@onready var logout_button := $%LogoutButton as CardButton
|
|
||||||
@onready var filter_check := $%FilterUnsupported as Toggle
|
|
||||||
@onready var show_purchases_check := $%ShowPurchases as Toggle
|
|
||||||
@onready var collections_container: VBoxContainer = $%CollectionsContainer
|
|
||||||
|
|
||||||
@onready var itch: ItchClient = get_tree().get_first_node_in_group("itch_client")
|
@onready var itch: ItchClient = get_tree().get_first_node_in_group("itch_client")
|
||||||
|
|
||||||
var _collection_checks: Dictionary = {}
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# Configure status indicators
|
|
||||||
status.status = status.STATUS.CANCELLED
|
|
||||||
status.color = "red"
|
|
||||||
var set_running := func():
|
|
||||||
if not itch.client_started:
|
|
||||||
return
|
|
||||||
status.status = status.STATUS.ACTIVE
|
|
||||||
status.color = "green"
|
|
||||||
if itch.client_started:
|
|
||||||
set_running.call()
|
|
||||||
itch.bootstrap_finished.connect(set_running)
|
|
||||||
|
|
||||||
# Configure connected status
|
|
||||||
connected_status.status = connected_status.STATUS.ACTIVE
|
|
||||||
if itch.state != itch.STATE.BOOT:
|
|
||||||
connected_status.color = "green"
|
|
||||||
itch.client_ready.connect(func():
|
|
||||||
connected_status.color = "green"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Configure login status
|
|
||||||
var update_login_status := func(login_status: ItchClient.LOGIN_STATUS):
|
|
||||||
if login_status != ItchClient.LOGIN_STATUS.OK:
|
|
||||||
logged_in_status.status = logged_in_status.STATUS.ACTIVE
|
|
||||||
logged_in_status.color = "gray"
|
|
||||||
return
|
|
||||||
logged_in_status.status = logged_in_status.STATUS.CLOSED
|
|
||||||
logged_in_status.color = "green"
|
|
||||||
itch.logged_in.connect(update_login_status)
|
|
||||||
itch.logged_in.connect(_on_login)
|
|
||||||
|
|
||||||
# Configure login method dropdown
|
|
||||||
login_method.add_item("API Key", 0)
|
|
||||||
login_method.add_item("Username & Password", 1)
|
|
||||||
login_method.selected = 0
|
|
||||||
|
|
||||||
# Load saved settings
|
|
||||||
var api_key := settings_manager.get_value("plugin.itch", "api_key", "") as String
|
var api_key := settings_manager.get_value("plugin.itch", "api_key", "") as String
|
||||||
api_key_box.text = api_key
|
api_key_box.text = api_key
|
||||||
api_key_box.secret = true
|
api_key_box.secret = true
|
||||||
|
|
||||||
var saved_method: int = settings_manager.get_value("plugin.itch", "login_method", 0) as int
|
|
||||||
login_method.select(saved_method)
|
|
||||||
_on_login_method_changed(saved_method)
|
|
||||||
|
|
||||||
var username := settings_manager.get_value("plugin.itch", "username", "") as String
|
|
||||||
username_box.text = username
|
|
||||||
|
|
||||||
filter_check.button_pressed = settings_manager.get_value("plugin.itch", "filter_unsupported", true) as bool
|
filter_check.button_pressed = settings_manager.get_value("plugin.itch", "filter_unsupported", true) as bool
|
||||||
show_purchases_check.button_pressed = settings_manager.get_value("plugin.itch", "show_purchases", true) as bool
|
|
||||||
|
|
||||||
# Connect signals
|
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)
|
save_button.pressed.connect(_on_save_button)
|
||||||
logout_button.pressed.connect(_on_logout_button)
|
|
||||||
login_method.item_selected.connect(_on_login_method_changed)
|
|
||||||
filter_check.toggled.connect(_on_filter_toggled)
|
filter_check.toggled.connect(_on_filter_toggled)
|
||||||
show_purchases_check.toggled.connect(_on_show_purchases_toggled)
|
|
||||||
|
|
||||||
# Build collection checkboxes once we're logged in.
|
|
||||||
itch.logged_in.connect(_populate_collections.bind(), CONNECT_ONE_SHOT)
|
|
||||||
|
|
||||||
|
|
||||||
func _on_login(login_status: ItchClient.LOGIN_STATUS, _profile: Dictionary) -> void:
|
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("")
|
var notify := Notification.new("")
|
||||||
notify.icon = icon
|
notify.icon = icon
|
||||||
if login_status == ItchClient.LOGIN_STATUS.OK:
|
if status == ItchClient.LOGIN_STATUS.OK:
|
||||||
notify.text = "Successfully logged in to itch.io"
|
notify.text = "Successfully logged in to itch.io"
|
||||||
else:
|
else:
|
||||||
notify.text = "itch.io login failed. Double check your credentials."
|
notify.text = "itch.io login failed. Double check your API key."
|
||||||
notification_manager.show(notify)
|
notification_manager.show(notify)
|
||||||
|
|
||||||
|
|
||||||
func _on_login_method_changed(idx: int) -> void:
|
|
||||||
# API key mode: show API key input, hide user/pass.
|
|
||||||
# Password mode: show user/pass, hide API key.
|
|
||||||
var is_api := idx == 0
|
|
||||||
api_key_box.visible = is_api
|
|
||||||
username_box.visible = not is_api
|
|
||||||
password_box.visible = not is_api
|
|
||||||
|
|
||||||
|
|
||||||
func _on_save_button() -> void:
|
func _on_save_button() -> void:
|
||||||
var method: int = login_method.selected
|
|
||||||
settings_manager.set_value("plugin.itch", "login_method", method)
|
|
||||||
|
|
||||||
if method == 0:
|
|
||||||
# API key login
|
|
||||||
settings_manager.set_value("plugin.itch", "username", "")
|
|
||||||
var api_key: String = api_key_box.text.strip_edges()
|
var api_key: String = api_key_box.text.strip_edges()
|
||||||
settings_manager.set_value("plugin.itch", "api_key", api_key)
|
settings_manager.set_value("plugin.itch", "api_key", api_key)
|
||||||
if api_key == "":
|
if api_key == "":
|
||||||
return
|
return
|
||||||
itch.login_with_api_key(api_key)
|
itch.login_with_api_key(api_key)
|
||||||
else:
|
|
||||||
# Username/password login
|
|
||||||
var uname: String = username_box.text.strip_edges()
|
|
||||||
var password: String = password_box.text
|
|
||||||
settings_manager.set_value("plugin.itch", "username", uname)
|
|
||||||
if uname == "" or password == "":
|
|
||||||
return
|
|
||||||
itch.login_with_password(uname, password)
|
|
||||||
|
|
||||||
|
|
||||||
func _on_logout_button() -> void:
|
|
||||||
var method: int = login_method.selected
|
|
||||||
|
|
||||||
# Clear saved credentials
|
|
||||||
if method == 0:
|
|
||||||
settings_manager.set_value("plugin.itch", "api_key", "")
|
|
||||||
api_key_box.text = ""
|
|
||||||
else:
|
|
||||||
settings_manager.set_value("plugin.itch", "username", "")
|
|
||||||
username_box.text = ""
|
|
||||||
password_box.text = ""
|
|
||||||
|
|
||||||
# Logout from butlerd
|
|
||||||
itch.logout()
|
|
||||||
|
|
||||||
var notify := Notification.new("Logged out of itch.io")
|
|
||||||
notify.icon = icon
|
|
||||||
notification_manager.show(notify)
|
|
||||||
|
|
||||||
|
|
||||||
## Fetches the user's collections and builds a checkbox for each one.
|
|
||||||
func _populate_collections(_status: ItchClient.LOGIN_STATUS, _profile: Dictionary) -> void:
|
|
||||||
if not itch.is_logged_in:
|
|
||||||
return
|
|
||||||
var profile_id: int = itch.profile.get("user", {}).get("id", itch.profile.get("id", 0))
|
|
||||||
var res := await itch._rpc_call("Fetch.ProfileCollections", {"profileId": profile_id})
|
|
||||||
if "error" in res:
|
|
||||||
return
|
|
||||||
var collections: Array = res.get("items", [])
|
|
||||||
|
|
||||||
# Load saved visibility state.
|
|
||||||
var hidden: Dictionary = settings_manager.get_value("plugin.itch", "hidden_collections", {}) as Dictionary
|
|
||||||
|
|
||||||
# Clear old checkboxes.
|
|
||||||
for child in collections_container.get_children():
|
|
||||||
child.queue_free()
|
|
||||||
_collection_checks.clear()
|
|
||||||
|
|
||||||
for c in collections:
|
|
||||||
var collection: Dictionary = c
|
|
||||||
var col_name: String = collection.get("title", "Collection " + str(collection.get("id", "")))
|
|
||||||
var col_id: String = str(collection.get("id", 0))
|
|
||||||
var check := CheckBox.new()
|
|
||||||
check.text = col_name
|
|
||||||
check.button_pressed = not hidden.has(col_id)
|
|
||||||
check.toggled.connect(_on_collection_toggled.bind(col_id))
|
|
||||||
collections_container.add_child(check)
|
|
||||||
_collection_checks[col_id] = check
|
|
||||||
|
|
||||||
|
|
||||||
func _on_collection_toggled(pressed: bool, col_id: String) -> void:
|
|
||||||
var hidden: Dictionary = settings_manager.get_value("plugin.itch", "hidden_collections", {}) as Dictionary
|
|
||||||
if pressed:
|
|
||||||
hidden.erase(col_id)
|
|
||||||
else:
|
|
||||||
hidden[col_id] = true
|
|
||||||
settings_manager.set_value("plugin.itch", "hidden_collections", hidden)
|
|
||||||
|
|
||||||
|
|
||||||
func _on_show_purchases_toggled(pressed: bool) -> void:
|
|
||||||
settings_manager.set_value("plugin.itch", "show_purchases", pressed)
|
|
||||||
|
|
||||||
|
|
||||||
## Persists the platform filter and reloads the itch.io library so the
|
## Persists the platform filter and reloads the itch.io library so the
|
||||||
|
|
|
||||||
|
|
@ -1,111 +1,53 @@
|
||||||
[gd_scene load_steps=7 format=3 uid="uid://itch_settings_scene"]
|
[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"]
|
[ext_resource type="Script" path="res://plugins/itch/core/itch_settings.gd" id="1"]
|
||||||
[ext_resource type="PackedScene" uid="uid://d1hlp6c8wrqgv" path="res://core/ui/components/status.tscn" id="2"]
|
|
||||||
[ext_resource type="PackedScene" uid="uid://d1rjdfxxrdccf" path="res://core/ui/components/text_input.tscn" id="3"]
|
|
||||||
[ext_resource type="PackedScene" uid="uid://d1qb7euwlu7bh" path="res://core/ui/components/toggle.tscn" id="4"]
|
|
||||||
[ext_resource type="PackedScene" uid="uid://c71ayw7pcw6u6" path="res://core/ui/components/card_button.tscn" id="5"]
|
|
||||||
[ext_resource type="PackedScene" uid="uid://xei5afwefxud" path="res://core/ui/components/dropdown.tscn" id="6"]
|
|
||||||
|
|
||||||
[node name="ItchSettings" type="MarginContainer"]
|
[node name="ItchSettings" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
anchor_bottom = 1.0
|
anchor_bottom = 1.0
|
||||||
grow_horizontal = 2
|
|
||||||
grow_vertical = 2
|
|
||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
|
|
||||||
[node name="ContentContainer" type="VBoxContainer" parent="."]
|
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||||
layout_mode = 2
|
layout_mode = 1
|
||||||
size_flags_horizontal = 3
|
anchors_preset = 8
|
||||||
size_flags_vertical = 0
|
anchor_left = 0.5
|
||||||
theme_override_constants/separation = 10
|
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="Status" parent="ContentContainer" instance=ExtResource("2")]
|
[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
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
title = "Status"
|
text = "Status: starting butlerd..."
|
||||||
description = ""
|
|
||||||
status = 2
|
|
||||||
color = "red"
|
|
||||||
|
|
||||||
[node name="ConnectedStatus" parent="ContentContainer" instance=ExtResource("2")]
|
[node name="ApiKeyInput" type="LineEdit" parent="VBoxContainer"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
title = "Connected"
|
placeholder_text = "itch.io API key"
|
||||||
description = ""
|
|
||||||
color = "gray"
|
|
||||||
|
|
||||||
[node name="LoggedInStatus" parent="ContentContainer" instance=ExtResource("2")]
|
[node name="HelpLabel" type="Label" parent="VBoxContainer"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
title = "Logged In"
|
autowrap_mode = 2
|
||||||
description = ""
|
text = "Get an API key from https://itch.io/user/settings/api-keys"
|
||||||
color = "gray"
|
|
||||||
|
|
||||||
[node name="HSeparator" type="HSeparator" parent="ContentContainer"]
|
[node name="FilterUnsupported" type="CheckBox" parent="VBoxContainer"]
|
||||||
layout_mode = 2
|
|
||||||
|
|
||||||
[node name="LoginMethod" parent="ContentContainer" instance=ExtResource("6")]
|
|
||||||
unique_name_in_owner = true
|
|
||||||
layout_mode = 2
|
|
||||||
title = "Login Method"
|
|
||||||
description = ""
|
|
||||||
|
|
||||||
[node name="ApiKeyInput" parent="ContentContainer" instance=ExtResource("3")]
|
|
||||||
unique_name_in_owner = true
|
|
||||||
layout_mode = 2
|
|
||||||
title = "API Key"
|
|
||||||
description = "Get an API key from https://itch.io/user/settings/api-keys"
|
|
||||||
secret = true
|
|
||||||
|
|
||||||
[node name="UsernameInput" parent="ContentContainer" instance=ExtResource("3")]
|
|
||||||
unique_name_in_owner = true
|
|
||||||
layout_mode = 2
|
|
||||||
title = "Username"
|
|
||||||
description = ""
|
|
||||||
visible = false
|
|
||||||
|
|
||||||
[node name="PasswordInput" parent="ContentContainer" instance=ExtResource("3")]
|
|
||||||
unique_name_in_owner = true
|
|
||||||
layout_mode = 2
|
|
||||||
title = "Password"
|
|
||||||
description = ""
|
|
||||||
secret = true
|
|
||||||
visible = false
|
|
||||||
|
|
||||||
[node name="HSeparatorLogin" type="HSeparator" parent="ContentContainer"]
|
|
||||||
layout_mode = 2
|
|
||||||
|
|
||||||
[node name="SaveButton" parent="ContentContainer" instance=ExtResource("5")]
|
|
||||||
unique_name_in_owner = true
|
|
||||||
layout_mode = 2
|
|
||||||
text = "Save and Log In"
|
|
||||||
|
|
||||||
[node name="LogoutButton" parent="ContentContainer" instance=ExtResource("5")]
|
|
||||||
unique_name_in_owner = true
|
|
||||||
layout_mode = 2
|
|
||||||
text = "Logout"
|
|
||||||
|
|
||||||
[node name="HSeparator2" type="HSeparator" parent="ContentContainer"]
|
|
||||||
layout_mode = 2
|
|
||||||
|
|
||||||
[node name="FilterUnsupported" parent="ContentContainer" instance=ExtResource("4")]
|
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "Only show games available on this platform"
|
text = "Only show games available on this platform"
|
||||||
button_pressed = true
|
|
||||||
|
|
||||||
[node name="ShowPurchases" parent="ContentContainer" instance=ExtResource("4")]
|
[node name="SaveButton" type="Button" parent="VBoxContainer"]
|
||||||
unique_name_in_owner = true
|
|
||||||
layout_mode = 2
|
|
||||||
text = "Show main purchases"
|
|
||||||
button_pressed = true
|
|
||||||
|
|
||||||
[node name="CollectionsLabel" type="Label" parent="ContentContainer"]
|
|
||||||
layout_mode = 2
|
|
||||||
text = "Show collections:"
|
|
||||||
|
|
||||||
[node name="CollectionsContainer" type="VBoxContainer" parent="ContentContainer"]
|
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
text = "Save and Log In"
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@ var _diagnosed_pids := {}
|
||||||
## caveId -> true when butlerd reports an update for that cave. Refreshed in the
|
## caveId -> true when butlerd reports an update for that cave. Refreshed in the
|
||||||
## background by CheckUpdate so has_update() stays cheap and synchronous.
|
## background by CheckUpdate so has_update() stays cheap and synchronous.
|
||||||
var _update_flags := {}
|
var _update_flags := {}
|
||||||
|
## gameId -> {genre, inputs, screenshots} parsed from each game's itch.io page.
|
||||||
|
## butlerd's Game struct has none of these fields, so they're cached here and
|
||||||
|
## merged into the game dict on every library load, powering per-layout boxart
|
||||||
|
## (and, potentially, gamepad/genre filters).
|
||||||
|
var _enriched: Dictionary = {}
|
||||||
|
|
||||||
|
const _meta_cache_file: String = "itch_meta.json"
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
|
@ -449,6 +456,7 @@ func _cave_has_files(cave: Dictionary) -> bool:
|
||||||
func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant:
|
func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant:
|
||||||
if game.is_empty():
|
if game.is_empty():
|
||||||
return null
|
return null
|
||||||
|
game = _apply_enriched(game)
|
||||||
if game.get("classification", "game") != "game":
|
if game.get("classification", "game") != "game":
|
||||||
return null
|
return null
|
||||||
var game_id: int = int(game.get("id", 0))
|
var game_id: int = int(game.get("id", 0))
|
||||||
|
|
@ -479,6 +487,7 @@ func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant:
|
||||||
## from Fetch.Caves. Uses the standard Cache system so we don't hammer
|
## from Fetch.Caves. Uses the standard Cache system so we don't hammer
|
||||||
## butlerd (and, transitively, the itch.io API) on every library refresh.
|
## 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]:
|
func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> Array[LibraryLaunchItem]:
|
||||||
|
_enriched = _load_enriched_meta()
|
||||||
if caching_flags & Cache.FLAGS.LOAD and Cache.is_cached(_cache_dir, _apps_cache_file):
|
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)
|
var json_items = Cache.get_json(_cache_dir, _apps_cache_file)
|
||||||
if json_items != null:
|
if json_items != null:
|
||||||
|
|
@ -517,6 +526,10 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) ->
|
||||||
var items := [] as Array[LibraryLaunchItem]
|
var items := [] as Array[LibraryLaunchItem]
|
||||||
for i in json_items:
|
for i in json_items:
|
||||||
var item := LibraryLaunchItem.from_dict(i)
|
var item := LibraryLaunchItem.from_dict(i)
|
||||||
|
# Re-apply enriched page metadata (genre/inputs/screenshots) from
|
||||||
|
# the itch_meta cache so a cached library still writes per-layout
|
||||||
|
# boxart (and could filter by gamepad/genre) on cold loads.
|
||||||
|
item.metadata["game"] = _apply_enriched(item.metadata.get("game", {}))
|
||||||
var game: Dictionary = item.metadata.get("game", {})
|
var game: Dictionary = item.metadata.get("game", {})
|
||||||
var cave: Dictionary = caves_by_game_id.get(int(game.get("id", 0)), {})
|
var cave: Dictionary = caves_by_game_id.get(int(game.get("id", 0)), {})
|
||||||
if not cave.is_empty() and _cave_has_files(cave):
|
if not cave.is_empty() and _cave_has_files(cave):
|
||||||
|
|
@ -544,6 +557,7 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) ->
|
||||||
if not _game_available_on_current_platform(game, item.installed):
|
if not _game_available_on_current_platform(game, item.installed):
|
||||||
continue
|
continue
|
||||||
items.append(item)
|
items.append(item)
|
||||||
|
_queue_boxart(items)
|
||||||
if caching_flags & Cache.FLAGS.SAVE:
|
if caching_flags & Cache.FLAGS.SAVE:
|
||||||
var json_out := []
|
var json_out := []
|
||||||
for it in items:
|
for it in items:
|
||||||
|
|
@ -558,14 +572,9 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) ->
|
||||||
return []
|
return []
|
||||||
|
|
||||||
logger.info("Fetching itch.io library...")
|
logger.info("Fetching itch.io library...")
|
||||||
var show_purchases: bool = settings_manager.get_value("plugin.itch", "show_purchases", true) as bool
|
var owned: Array = await itch.get_owned_games()
|
||||||
var hidden_collections: Dictionary = settings_manager.get_value("plugin.itch", "hidden_collections", {}) as Dictionary
|
|
||||||
|
|
||||||
var owned: Array = []
|
|
||||||
if show_purchases:
|
|
||||||
owned = await itch.get_owned_games()
|
|
||||||
var caves: Variant = await itch.get_caves()
|
var caves: Variant = await itch.get_caves()
|
||||||
var collection_groups: Array = await itch.get_collection_groups()
|
var collection_games: Array = await itch.get_collection_games()
|
||||||
|
|
||||||
# Clean up "orphan" caves (butler.db entries whose install folder was wiped
|
# Clean up "orphan" caves (butler.db entries whose install folder was wiped
|
||||||
# by a plugin update) in the background so a later Install.Queue doesn't
|
# by a plugin update) in the background so a later Install.Queue doesn't
|
||||||
|
|
@ -600,12 +609,7 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) ->
|
||||||
|
|
||||||
# Collection games aren't necessarily owned (e.g. a free game the user
|
# Collection games aren't necessarily owned (e.g. a free game the user
|
||||||
# bookmarked), so merge them in too, deduped against owned games.
|
# bookmarked), so merge them in too, deduped against owned games.
|
||||||
for group in collection_groups:
|
for g in collection_games:
|
||||||
var group_dict: Dictionary = group
|
|
||||||
var col_id: String = str(group_dict.get("id", 0))
|
|
||||||
if hidden_collections.has(col_id):
|
|
||||||
continue
|
|
||||||
for g in group_dict.get("games", []):
|
|
||||||
var game: Dictionary = g
|
var game: Dictionary = g
|
||||||
var game_id: int = game.get("id", 0)
|
var game_id: int = game.get("id", 0)
|
||||||
if game_id in seen_game_ids:
|
if game_id in seen_game_ids:
|
||||||
|
|
@ -625,6 +629,8 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) ->
|
||||||
if Cache.save_json(_cache_dir, _apps_cache_file, json_items) != OK:
|
if Cache.save_json(_cache_dir, _apps_cache_file, json_items) != OK:
|
||||||
logger.warn("Unable to save itch.io apps cache")
|
logger.warn("Unable to save itch.io apps cache")
|
||||||
|
|
||||||
|
_queue_boxart(items)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -636,6 +642,271 @@ func _cleanup_orphan_caves(cave_ids: Array) -> void:
|
||||||
await itch.uninstall(cave_id)
|
await itch.uninstall(cave_id)
|
||||||
|
|
||||||
|
|
||||||
|
## Queues background downloads of each game's art into OGPU's local boxart
|
||||||
|
## directory (user://boxart/local/). The built-in "local" BoxArtProvider picks
|
||||||
|
## those files up by <game name>-<layout>.png, so games show their real cover
|
||||||
|
## instead of the placeholder. Each task enriches the game from its itch.io page
|
||||||
|
## first (genre/inputs/screenshots, cached in itch_meta.json) and then writes
|
||||||
|
## per-layout art: the cover for portrait/logo, full-res screenshots for
|
||||||
|
## landscape/banner. For games with an animated GIF cover, itch.io provides a
|
||||||
|
## stillCoverUrl (a static frame, served as PNG) which we prefer, since Godot
|
||||||
|
## has no GIF decoder.
|
||||||
|
func _queue_boxart(items: Array) -> void:
|
||||||
|
for i in items:
|
||||||
|
var item: LibraryLaunchItem = i
|
||||||
|
var game: Dictionary = item.metadata.get("game", {})
|
||||||
|
if int(game.get("id", 0)) == 0:
|
||||||
|
continue
|
||||||
|
itch.thread_group.scheduled_exec(_enrich_and_boxart.bind(game), 0)
|
||||||
|
|
||||||
|
|
||||||
|
## Downloads the given game's art (blocking, runs on the shared thread) and
|
||||||
|
## writes it to the boxart layouts. The cover (or a full-res screenshot when
|
||||||
|
## enriched metadata is available) fills every slot itch.io can serve: OGPU's
|
||||||
|
## boxart slots all keep-aspect (scale + crop), so the 315x250 cover works for
|
||||||
|
## portrait/logo while screenshots give landscape/banner real resolution.
|
||||||
|
## When force is set (right after a fresh page fetch), landscape/banner get
|
||||||
|
## overwritten so installs that only ever had the cover upgraded with art.
|
||||||
|
func _ensure_boxart(game: Dictionary, meta: Dictionary = {}, force := false) -> void:
|
||||||
|
var title: String = game.get("title", "")
|
||||||
|
var cover_url := _cover_url(game)
|
||||||
|
if title.is_empty() or cover_url.is_empty():
|
||||||
|
return
|
||||||
|
var dir := _boxart_dir()
|
||||||
|
|
||||||
|
# Screenshots are full-res originals from the game page; the cover is only
|
||||||
|
# reused for landscape/banner when a game has no screenshots at all.
|
||||||
|
var screenshots: Array = meta.get("screenshots", [])
|
||||||
|
var sources := {
|
||||||
|
"portrait": cover_url,
|
||||||
|
"landscape": screenshots[0] if not screenshots.is_empty() else cover_url,
|
||||||
|
"banner": screenshots[1] if screenshots.size() > 1 else (screenshots[0] if not screenshots.is_empty() else cover_url),
|
||||||
|
"logo": cover_url,
|
||||||
|
}
|
||||||
|
# portrait/logo keep the cover forever; landscape/banner are replaced by
|
||||||
|
# screenshot art on the first enrichment after an upgrade.
|
||||||
|
var fixed := {"portrait": true, "logo": true}
|
||||||
|
var bodies := {}
|
||||||
|
DirAccess.make_dir_recursive_absolute(dir)
|
||||||
|
for layout in ["portrait", "landscape", "banner", "logo"]:
|
||||||
|
var path := "/".join([dir, title + "-" + layout + ".png"])
|
||||||
|
if FileAccess.file_exists(path):
|
||||||
|
if fixed.has(layout) or not force:
|
||||||
|
continue
|
||||||
|
var url: String = sources[layout]
|
||||||
|
if url.is_empty():
|
||||||
|
continue
|
||||||
|
var body: PackedByteArray
|
||||||
|
if bodies.has(url):
|
||||||
|
body = bodies[url]
|
||||||
|
else:
|
||||||
|
body = _http_get(url)
|
||||||
|
bodies[url] = body
|
||||||
|
if body.is_empty() or not _is_raster_image(body):
|
||||||
|
# OGPU's local provider only loads png/jpg. If itch.io didn't give us a
|
||||||
|
# still for an animated cover, skip rather than write an unloadable file.
|
||||||
|
logger.warn("Skipping unsupported art for '" + title + "' layout " + layout)
|
||||||
|
continue
|
||||||
|
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||||
|
if file:
|
||||||
|
file.store_buffer(body)
|
||||||
|
file.close()
|
||||||
|
logger.info("Downloaded " + layout + " boxart for '" + title + "'")
|
||||||
|
|
||||||
|
|
||||||
|
## Runs on the shared thread: enrich the game from its itch.io page (once per
|
||||||
|
## game, cached in itch_meta.json) and write per-layout boxart. A fresh fetch
|
||||||
|
## force-refreshes landscape/banner so installs that only ever had the cover
|
||||||
|
## copied into every slot get upgraded to full-res screenshots.
|
||||||
|
func _enrich_and_boxart(game: Dictionary) -> void:
|
||||||
|
var game_id := int(game.get("id", 0))
|
||||||
|
if game_id == 0:
|
||||||
|
return
|
||||||
|
var meta := {}
|
||||||
|
var force := false
|
||||||
|
if _enriched.has(game_id):
|
||||||
|
meta = _enriched[game_id]
|
||||||
|
else:
|
||||||
|
var fetched := _fetch_game_page_metadata(game)
|
||||||
|
if not fetched.is_empty():
|
||||||
|
meta = fetched
|
||||||
|
force = true
|
||||||
|
_save_enriched_meta(game_id, meta)
|
||||||
|
logger.info("Enriched page metadata for '" + game.get("title", "") + "': genre='" + str(meta.get("genre", "")) + "' inputs=" + str(meta.get("inputs", [])))
|
||||||
|
_ensure_boxart(game, meta, force)
|
||||||
|
|
||||||
|
|
||||||
|
## Fetches a game's itch.io page and parses the Details table (genre, inputs)
|
||||||
|
## plus the screenshot gallery. Returns {} on failure, otherwise
|
||||||
|
## {genre: String, inputs: Array[String], screenshots: Array[String]}. None of
|
||||||
|
## these fields exist in butlerd's Game struct; data.json exposes only freeform
|
||||||
|
## tags and a price, so the page HTML is the only keyless source.
|
||||||
|
func _fetch_game_page_metadata(game: Dictionary) -> Dictionary:
|
||||||
|
var page_url: String = game.get("url", "")
|
||||||
|
if page_url.is_empty():
|
||||||
|
return {}
|
||||||
|
var body := _http_get(page_url)
|
||||||
|
if body.is_empty():
|
||||||
|
return {}
|
||||||
|
var html := body.get_string_from_utf8()
|
||||||
|
if html.is_empty():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# RegEx instances are created here (background thread) to avoid sharing
|
||||||
|
# RegEx objects across threads.
|
||||||
|
var genre_re := RegEx.new()
|
||||||
|
genre_re.compile('<tr><td>Genre</td><td><a[^>]*>([^<]+)</a>')
|
||||||
|
var inputs_row_re := RegEx.new()
|
||||||
|
inputs_row_re.compile('(?s)<tr><td>Inputs</td><td>(.*?)</td></tr>')
|
||||||
|
var input_slug_re := RegEx.new()
|
||||||
|
input_slug_re.compile('input-([a-z0-9-]+)')
|
||||||
|
var shot_re := RegEx.new()
|
||||||
|
shot_re.compile('https://img\\.itch\\.zone/aW1hZ2Uv[^"]*/original/[^"]*')
|
||||||
|
|
||||||
|
var genre := ""
|
||||||
|
var genre_match := genre_re.search(html)
|
||||||
|
if genre_match != null:
|
||||||
|
genre = _decode_html(genre_match.get_string(1).strip_edges())
|
||||||
|
|
||||||
|
var inputs: Array = []
|
||||||
|
var inputs_match := inputs_row_re.search(html)
|
||||||
|
if inputs_match != null:
|
||||||
|
for m in input_slug_re.search_all(inputs_match.get_string(1)):
|
||||||
|
inputs.append(m.get_string(1))
|
||||||
|
|
||||||
|
var screenshots: Array = []
|
||||||
|
for m in shot_re.search_all(html):
|
||||||
|
screenshots.append(m.get_string(0))
|
||||||
|
|
||||||
|
return {"genre": genre, "inputs": inputs, "screenshots": screenshots}
|
||||||
|
|
||||||
|
|
||||||
|
## Decodes the HTML entities itch.io's Details table can contain.
|
||||||
|
func _decode_html(value: String) -> String:
|
||||||
|
value = value.replace("&", "&")
|
||||||
|
value = value.replace("<", "<")
|
||||||
|
value = value.replace(">", ">")
|
||||||
|
value = value.replace(""", "\"")
|
||||||
|
value = value.replace("'", "'")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
## Loads the itch_meta.json cache: gameId -> {genre, inputs, screenshots}.
|
||||||
|
## JSON round-trips integer keys as strings, so they are normalized to int.
|
||||||
|
func _load_enriched_meta() -> Dictionary:
|
||||||
|
var raw: Variant = Cache.get_json(_cache_dir, _meta_cache_file)
|
||||||
|
if typeof(raw) != TYPE_DICTIONARY:
|
||||||
|
return {}
|
||||||
|
var out := {}
|
||||||
|
for key in raw:
|
||||||
|
out[int(key)] = raw[key]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## Merges one game's enriched metadata into the shared itch_meta.json cache.
|
||||||
|
func _save_enriched_meta(game_id: int, meta: Dictionary) -> void:
|
||||||
|
var raw: Variant = Cache.get_json(_cache_dir, _meta_cache_file)
|
||||||
|
if typeof(raw) != TYPE_DICTIONARY:
|
||||||
|
raw = {}
|
||||||
|
raw[str(game_id)] = meta
|
||||||
|
Cache.save_json(_cache_dir, _meta_cache_file, raw)
|
||||||
|
|
||||||
|
|
||||||
|
## Merges the enriched metadata (genre/inputs/screenshots) into a game dict so
|
||||||
|
## it survives and is available during _make_item and boxart setup.
|
||||||
|
func _apply_enriched(game: Dictionary) -> Dictionary:
|
||||||
|
if game.is_empty():
|
||||||
|
return game
|
||||||
|
var meta: Dictionary = _enriched.get(int(game.get("id", 0)), {})
|
||||||
|
if meta.is_empty():
|
||||||
|
return game
|
||||||
|
if not game.has("genre"):
|
||||||
|
game["genre"] = meta.get("genre", "")
|
||||||
|
if not game.has("inputs"):
|
||||||
|
game["inputs"] = meta.get("inputs", [])
|
||||||
|
if not game.has("screenshots"):
|
||||||
|
game["screenshots"] = meta.get("screenshots", [])
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
func _boxart_dir() -> String:
|
||||||
|
return ProjectSettings.globalize_path("user://boxart/local")
|
||||||
|
|
||||||
|
|
||||||
|
## itch.io exposes stillCoverUrl for games whose cover is an animated GIF: a
|
||||||
|
## static frame that the CDN serves as PNG. Prefer it over coverUrl so those
|
||||||
|
## games still get art (Godot can't decode GIFs at runtime).
|
||||||
|
func _cover_url(game: Dictionary) -> String:
|
||||||
|
var still: String = game.get("stillCoverUrl", "")
|
||||||
|
if not still.is_empty():
|
||||||
|
return still
|
||||||
|
return game.get("coverUrl", "")
|
||||||
|
|
||||||
|
|
||||||
|
## Blocking HTTPS GET of a single URL (cover image or game page HTML), for use
|
||||||
|
## on a background thread.
|
||||||
|
func _http_get(url: String) -> PackedByteArray:
|
||||||
|
var parts := url.split("/")
|
||||||
|
var use_tls := parts[0] == "https:"
|
||||||
|
var http := HTTPClient.new()
|
||||||
|
var err: int = http.connect_to_host(parts[2], 443 if use_tls else 80, TLSOptions.client() if use_tls else null)
|
||||||
|
if err != OK:
|
||||||
|
return PackedByteArray()
|
||||||
|
var deadline := Time.get_ticks_msec() + 15000
|
||||||
|
while http.get_status() == HTTPClient.STATUS_CONNECTING or http.get_status() == HTTPClient.STATUS_RESOLVING:
|
||||||
|
http.poll()
|
||||||
|
if Time.get_ticks_msec() > deadline:
|
||||||
|
http.close()
|
||||||
|
return PackedByteArray()
|
||||||
|
OS.delay_msec(50)
|
||||||
|
if http.get_status() != HTTPClient.STATUS_CONNECTED:
|
||||||
|
http.close()
|
||||||
|
return PackedByteArray()
|
||||||
|
|
||||||
|
http.request(HTTPClient.METHOD_GET, "/" + "/".join(parts.slice(3)), PackedStringArray())
|
||||||
|
deadline = Time.get_ticks_msec() + 15000
|
||||||
|
while http.get_status() == HTTPClient.STATUS_REQUESTING:
|
||||||
|
http.poll()
|
||||||
|
if Time.get_ticks_msec() > deadline:
|
||||||
|
http.close()
|
||||||
|
return PackedByteArray()
|
||||||
|
OS.delay_msec(10)
|
||||||
|
|
||||||
|
var body := PackedByteArray()
|
||||||
|
while http.get_status() == HTTPClient.STATUS_BODY:
|
||||||
|
http.poll()
|
||||||
|
var chunk: PackedByteArray = http.read_response_body_chunk()
|
||||||
|
if chunk.is_empty():
|
||||||
|
if Time.get_ticks_msec() > deadline:
|
||||||
|
http.close()
|
||||||
|
return PackedByteArray()
|
||||||
|
OS.delay_msec(10)
|
||||||
|
continue
|
||||||
|
body.append_array(chunk)
|
||||||
|
|
||||||
|
var code: int = http.get_response_code()
|
||||||
|
http.close()
|
||||||
|
if code != 200:
|
||||||
|
return PackedByteArray()
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
## Returns true if the bytes look like a PNG or JPEG (the only formats OGPU's
|
||||||
|
## local boxart provider can load).
|
||||||
|
func _is_raster_image(body: PackedByteArray) -> bool:
|
||||||
|
if body.size() < 12:
|
||||||
|
return false
|
||||||
|
var png_magic := [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||||||
|
var is_png := true
|
||||||
|
for i in png_magic.size():
|
||||||
|
if body[i] != png_magic[i]:
|
||||||
|
is_png = false
|
||||||
|
break
|
||||||
|
if is_png:
|
||||||
|
return true
|
||||||
|
return body[0] == 0xff and body[1] == 0xd8 and body[2] == 0xff
|
||||||
|
|
||||||
|
|
||||||
## Resolves the authoritative launch command for an installed game.
|
## Resolves the authoritative launch command for an installed game.
|
||||||
##
|
##
|
||||||
## butlerd writes .itch/receipt.json.gz into every install folder and resolves
|
## butlerd writes .itch/receipt.json.gz into every install folder and resolves
|
||||||
|
|
|
||||||
28
plugin.gd
28
plugin.gd
|
|
@ -16,8 +16,6 @@ var icon := preload("res://plugins/itch/assets/itch.svg")
|
||||||
|
|
||||||
var itch: ItchClient
|
var itch: ItchClient
|
||||||
var api_key := settings_manager.get_value("plugin.itch", "api_key", "") as String
|
var api_key := settings_manager.get_value("plugin.itch", "api_key", "") as String
|
||||||
var login_method := settings_manager.get_value("plugin.itch", "login_method", 0) as int
|
|
||||||
var username := settings_manager.get_value("plugin.itch", "username", "") as String
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
|
@ -48,31 +46,15 @@ func _on_client_start() -> void:
|
||||||
|
|
||||||
# Triggers when butlerd has completed its handshake and is ready for calls
|
# Triggers when butlerd has completed its handshake and is ready for calls
|
||||||
func _on_client_ready() -> void:
|
func _on_client_ready() -> void:
|
||||||
# Always try butlerd's stored credentials first — this handles password
|
|
||||||
# logins from previous runs without any stored tokens on our side.
|
|
||||||
if await itch.try_saved_login():
|
|
||||||
return
|
|
||||||
if login_method == 0:
|
|
||||||
# API key login
|
|
||||||
if api_key == "":
|
if api_key == "":
|
||||||
var notify := Notification.new("itch.io API key required")
|
var notify := Notification.new("itch.io API key required")
|
||||||
notify.icon = icon
|
notify.icon = icon
|
||||||
logger.info(notify.text)
|
logger.info(notify.text)
|
||||||
notification_manager.show(notify)
|
notification_manager.show(notify)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# If we have a saved API key, try logging in with it automatically
|
||||||
itch.login_with_api_key(api_key)
|
itch.login_with_api_key(api_key)
|
||||||
else:
|
|
||||||
# Username/password login
|
|
||||||
if username == "":
|
|
||||||
var notify := Notification.new("itch.io username required")
|
|
||||||
notify.icon = icon
|
|
||||||
logger.info(notify.text)
|
|
||||||
notification_manager.show(notify)
|
|
||||||
return
|
|
||||||
var notify := Notification.new("itch.io: open plugin settings to log in")
|
|
||||||
notify.icon = icon
|
|
||||||
logger.info(notify.text)
|
|
||||||
notification_manager.show(notify)
|
|
||||||
|
|
||||||
|
|
||||||
# Triggers when the itch client finishes a login attempt
|
# Triggers when the itch client finishes a login attempt
|
||||||
|
|
@ -81,13 +63,13 @@ func _on_client_logged_in(status: ItchClient.LOGIN_STATUS, profile: Dictionary)
|
||||||
notify.icon = icon
|
notify.icon = icon
|
||||||
|
|
||||||
if status == ItchClient.LOGIN_STATUS.OK:
|
if status == ItchClient.LOGIN_STATUS.OK:
|
||||||
var uname: String = profile.get("user", {}).get("username", "")
|
var username: String = profile.get("user", {}).get("username", "")
|
||||||
notify.text = "Successfully logged in to itch.io as " + uname
|
notify.text = "Successfully logged in to itch.io as " + username
|
||||||
logger.info(notify.text)
|
logger.info(notify.text)
|
||||||
notification_manager.show(notify)
|
notification_manager.show(notify)
|
||||||
return
|
return
|
||||||
|
|
||||||
notify.text = "Failed to log in to itch.io. Check your credentials in plugin settings."
|
notify.text = "Failed to log in to itch.io. Check your API key in plugin settings."
|
||||||
logger.warn(notify.text)
|
logger.warn(notify.text)
|
||||||
notification_manager.show(notify)
|
notification_manager.show(notify)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"plugin.id": "itch",
|
"plugin.id": "itch",
|
||||||
"plugin.name": "itch.io",
|
"plugin.name": "itch.io",
|
||||||
"plugin.version": "0.1.39",
|
"plugin.version": "0.1.33",
|
||||||
"plugin.min-api-version": "1.1.0",
|
"plugin.min-api-version": "1.1.0",
|
||||||
"plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
"plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
||||||
"plugin.source": "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