diff --git a/core/itch_client.gd b/core/itch_client.gd index 4ec7e0f..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 @@ -376,13 +377,98 @@ func login_with_password(username: String, password: String) -> void: func _login_with_password(username: String, password: String) -> void: - var res := await _rpc_call("Profile.LoginWithPassword", {"username": username, "password": password}) + 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 + # 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) + + +## 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 _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) diff --git a/core/itch_settings.gd b/core/itch_settings.gd index e48f457..0bd1a3f 100644 --- a/core/itch_settings.gd +++ b/core/itch_settings.gd @@ -100,6 +100,8 @@ 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) if api_key == "": @@ -107,12 +109,13 @@ func _on_save_button() -> void: itch.login_with_api_key(api_key) else: # Username/password login - var username: String = username_box.text.strip_edges() + var uname: String = username_box.text.strip_edges() var password: String = password_box.text - settings_manager.set_value("plugin.itch", "username", username) - if username == "" or password == "": + settings_manager.set_value("plugin.itch", "username", uname) + settings_manager.set_value("plugin.itch", "cookie", "") + if uname == "" or password == "": return - itch.login_with_password(username, password) + itch.login_with_password(uname, password) ## Fetches the user's collections and builds a checkbox for each one. diff --git a/plugin.gd b/plugin.gd index 4027f07..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 @@ -58,19 +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 - # Password is not persisted; prompt via settings if needed. - # For now, just log the message — the user must save from settings. - var notify := Notification.new("itch.io: open plugin settings to log in with username/password") - 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 @@ -79,17 +82,22 @@ func _on_client_logged_in(status: ItchClient.LOGIN_STATUS, profile: Dictionary) notify.icon = icon if status == ItchClient.LOGIN_STATUS.OK: - var username: String = profile.get("user", {}).get("username", "") - notify.text = "Successfully logged in to itch.io as " + username + var uname: String = profile.get("user", {}).get("username", "") + notify.text = "Successfully logged in to itch.io as " + uname logger.info(notify.text) notification_manager.show(notify) return - notify.text = "Failed to log in to itch.io. Check your API key in plugin settings." + notify.text = "Failed to log in to itch.io. Check your credentials in plugin settings." logger.warn(notify.text) 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()