diff --git a/core/itch_client.gd b/core/itch_client.gd index 5af9df4..44479cd 100644 --- a/core/itch_client.gd +++ b/core/itch_client.gd @@ -56,6 +56,7 @@ 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) +signal login_cookies_saved(username: String, cookie: String) var proc: InteractiveProcess var socket: StreamPeerTCP @@ -384,41 +385,91 @@ func _login_with_password(username: String, password: String) -> void: return profile = res.get("profile", {}) is_logged_in = true + # Persist the cookie so we can re-authenticate on restart without the + # password. Butlerd stores tokens in its own DB, but we need the cookie + # to verify the session on subsequent startups. + var cookie: String = res.get("cookie", "") + if cookie != "": + emit_signal.call_deferred("login_cookies_saved", username, cookie) 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) +## Re-authenticates using a previously saved cookie. The cookie is verified +## against itch.io's API directly (bypassing butlerd) because butlerd does +## not expose a "login with cookie" method. +func login_with_cookie(cookie: String) -> void: + await thread_group.exec(_login_with_cookie.bind(cookie)) -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", {}) +func _login_with_cookie(cookie: String) -> void: + var http := HTTPClient.new() + var err := http.connect_to_host("api.itch.io", 443, TLSOptions.client()) + if err != OK: + is_logged_in = false + emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {}) + return + 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() + is_logged_in = false + emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {}) + return + OS.delay_msec(50) + if http.get_status() != HTTPClient.STATUS_CONNECTED: + http.close() + is_logged_in = false + emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {}) + return + + var headers := PackedStringArray([ + "Authorization: Bearer " + cookie, + "User-Agent: opencode-itch-plugin/1.0", + ]) + http.request(HTTPClient.METHOD_GET, "/profile", headers) + deadline = Time.get_ticks_msec() + 15000 + while http.get_status() == HTTPClient.STATUS_REQUESTING: + http.poll() + if Time.get_ticks_msec() > deadline: + http.close() + is_logged_in = false + emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {}) + return + 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() + is_logged_in = false + emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {}) + return + OS.delay_msec(10) + continue + body.append_array(chunk) + http.close() + + var response_code := http.get_response_code() + var json_text := body.get_string_from_utf8() + if response_code != 200 or json_text.is_empty(): + is_logged_in = false + emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {}) + return + + var json := JSON.new() + if json.parse(json_text) != OK: + is_logged_in = false + emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {}) + return + + var data: Dictionary = json.data + profile = data.get("user", data) 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. @@ -961,28 +1012,6 @@ func _launch(cave_id: String) -> void: 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 socket: socket.disconnect_from_host() diff --git a/core/itch_settings.gd b/core/itch_settings.gd index a2bd26c..0bd1a3f 100644 --- a/core/itch_settings.gd +++ b/core/itch_settings.gd @@ -1,27 +1,28 @@ -extends MarginContainer +extends Control ## itch.io plugin settings screen ## -## Uses OpenGamepadUI's themed widget set (StatusPanel, ComponentTextInput, -## Toggle, CardButton, Dropdown) for consistent look with the rest of the -## settings UI. +## 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 := $%Status as StatusPanel -@onready var connected_status := $%ConnectedStatus as StatusPanel -@onready var logged_in_status := $%LoggedInStatus as StatusPanel -@onready var login_method := $%LoginMethod as Dropdown -@onready var api_key_box := $%ApiKeyInput as ComponentTextInput -@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 status_label: Label = $%StatusLabel +@onready var api_key_box: LineEdit = $%ApiKeyInput +@onready var username_box: LineEdit = $%UsernameInput +@onready var password_box: LineEdit = $%PasswordInput +@onready var login_method: OptionButton = $%LoginMethod +@onready var save_button: Button = $%SaveButton +@onready var help_label: Label = $%HelpLabel +@onready var filter_check: CheckBox = $%FilterUnsupported +@onready var show_purchases_check: CheckBox = $%ShowPurchases @onready var collections_container: VBoxContainer = $%CollectionsContainer @onready var itch: ItchClient = get_tree().get_first_node_in_group("itch_client") @@ -30,49 +31,12 @@ var _collection_checks: Dictionary = {} 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 api_key_box.text = api_key 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) + login_method.selected = saved_method _on_login_method_changed(saved_method) var username := settings_manager.get_value("plugin.itch", "username", "") as String @@ -81,21 +45,39 @@ func _ready() -> void: 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 + _update_status() + itch.client_ready.connect(_update_status) + itch.logged_in.connect(_on_login) + 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) + login_method.item_selected.connect(_on_login_method_changed) 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("") notify.icon = icon - if login_status == ItchClient.LOGIN_STATUS.OK: + if status == ItchClient.LOGIN_STATUS.OK: notify.text = "Successfully logged in to itch.io" else: notify.text = "itch.io login failed. Double check your credentials." @@ -109,6 +91,7 @@ func _on_login_method_changed(idx: int) -> void: api_key_box.visible = is_api username_box.visible = not is_api password_box.visible = not is_api + help_label.visible = is_api func _on_save_button() -> void: @@ -117,6 +100,7 @@ func _on_save_button() -> void: if method == 0: # API key login + settings_manager.set_value("plugin.itch", "cookie", "") settings_manager.set_value("plugin.itch", "username", "") var api_key: String = api_key_box.text.strip_edges() settings_manager.set_value("plugin.itch", "api_key", api_key) @@ -128,31 +112,12 @@ func _on_save_button() -> void: var uname: String = username_box.text.strip_edges() var password: String = password_box.text settings_manager.set_value("plugin.itch", "username", uname) + settings_manager.set_value("plugin.itch", "cookie", "") 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: diff --git a/core/itch_settings.tscn b/core/itch_settings.tscn index d2f313f..f7dca25 100644 --- a/core/itch_settings.tscn +++ b/core/itch_settings.tscn @@ -1,111 +1,88 @@ -[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="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 anchor_right = 1.0 anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 script = ExtResource("1") -[node name="ContentContainer" type="VBoxContainer" parent="."] -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 0 -theme_override_constants/separation = 10 +[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 = -120.0 +offset_right = 200.0 +offset_bottom = 120.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 layout_mode = 2 -title = "Status" -description = "" -status = 2 -color = "red" +text = "Status: starting butlerd..." -[node name="ConnectedStatus" parent="ContentContainer" instance=ExtResource("2")] +[node name="LoginMethod" type="OptionButton" parent="VBoxContainer"] unique_name_in_owner = true layout_mode = 2 -title = "Connected" -description = "" -color = "gray" +item_count = 2 +popup/item_0_text = "API Key" +popup/item_1_text = "Username & Password" -[node name="LoggedInStatus" parent="ContentContainer" instance=ExtResource("2")] +[node name="ApiKeyInput" type="LineEdit" parent="VBoxContainer"] unique_name_in_owner = true layout_mode = 2 -title = "Logged In" -description = "" -color = "gray" +placeholder_text = "itch.io API key" -[node name="HSeparator" type="HSeparator" parent="ContentContainer"] -layout_mode = 2 - -[node name="LoginMethod" parent="ContentContainer" instance=ExtResource("6")] +[node name="UsernameInput" type="LineEdit" parent="VBoxContainer"] 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 = "" +placeholder_text = "Username" visible = false -[node name="PasswordInput" parent="ContentContainer" instance=ExtResource("3")] +[node name="PasswordInput" type="LineEdit" parent="VBoxContainer"] unique_name_in_owner = true layout_mode = 2 -title = "Password" -description = "" +placeholder_text = "Password" secret = true visible = false -[node name="HSeparatorLogin" type="HSeparator" parent="ContentContainer"] -layout_mode = 2 - -[node name="SaveButton" parent="ContentContainer" instance=ExtResource("5")] +[node name="HelpLabel" type="Label" parent="VBoxContainer"] unique_name_in_owner = true layout_mode = 2 -text = "Save and Log In" +autowrap_mode = 2 +text = "Get an API key from https://itch.io/user/settings/api-keys" -[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")] +[node name="FilterUnsupported" type="CheckBox" parent="VBoxContainer"] unique_name_in_owner = true layout_mode = 2 text = "Only show games available on this platform" -button_pressed = true -[node name="ShowPurchases" parent="ContentContainer" instance=ExtResource("4")] +[node name="ShowPurchases" type="CheckBox" parent="VBoxContainer"] unique_name_in_owner = true layout_mode = 2 text = "Show main purchases" button_pressed = true -[node name="CollectionsLabel" type="Label" parent="ContentContainer"] +[node name="CollectionsLabel" type="Label" parent="VBoxContainer"] +unique_name_in_owner = true layout_mode = 2 text = "Show collections:" -[node name="CollectionsContainer" type="VBoxContainer" parent="ContentContainer"] +[node name="CollectionsContainer" type="VBoxContainer" parent="VBoxContainer"] unique_name_in_owner = true layout_mode = 2 + +[node name="SaveButton" type="Button" parent="VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Save and Log In" diff --git a/plugin.gd b/plugin.gd index ba649b4..a3b4850 100644 --- a/plugin.gd +++ b/plugin.gd @@ -18,6 +18,7 @@ var itch: ItchClient 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 +var saved_cookie := settings_manager.get_value("plugin.itch", "cookie", "") as String func _ready() -> void: @@ -29,6 +30,7 @@ func _ready() -> void: itch.bootstrap_finished.connect(_on_client_start) itch.client_ready.connect(_on_client_ready) itch.logged_in.connect(_on_client_logged_in) + itch.login_cookies_saved.connect(_on_cookies_saved) add_child(itch) # Load the Library implementation @@ -48,10 +50,6 @@ func _on_client_start() -> void: # Triggers when butlerd has completed its handshake and is ready for calls 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 == "": @@ -62,17 +60,20 @@ func _on_client_ready() -> void: return itch.login_with_api_key(api_key) else: - # Username/password login + # Username/password login — try saved cookie first. 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) + if saved_cookie != "": + itch.login_with_cookie(saved_cookie) + else: + 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 @@ -92,6 +93,11 @@ func _on_client_logged_in(status: ItchClient.LOGIN_STATUS, profile: Dictionary) notification_manager.show(notify) +func _on_cookies_saved(uname: String, cookie: String) -> void: + settings_manager.set_value("plugin.itch", "username", uname) + settings_manager.set_value("plugin.itch", "cookie", cookie) + + # Return the settings menu scene func get_settings_menu() -> Control: return settings_menu.instantiate() diff --git a/plugin.json b/plugin.json index e6d4d11..3cfd32b 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "plugin.id": "itch", "plugin.name": "itch.io", - "plugin.version": "0.1.39", + "plugin.version": "0.1.35", "plugin.min-api-version": "1.1.0", "plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin", "plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",