From e2a4464d0dabbded23152044fde9809d9c76f655 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Mon, 17 Aug 2026 02:00:39 -0300 Subject: [PATCH 1/6] Add password login and collection visibility settings --- core/itch_client.gd | 47 ++++++++++++++++++++ core/itch_settings.gd | 98 ++++++++++++++++++++++++++++++++++++++--- core/itch_settings.tscn | 39 +++++++++++++++- core/library_itch.gd | 32 +++++++++----- plugin.gd | 28 +++++++++--- 5 files changed, 218 insertions(+), 26 deletions(-) diff --git a/core/itch_client.gd b/core/itch_client.gd index ee83933..4ec7e0f 100644 --- a/core/itch_client.gd +++ b/core/itch_client.gd @@ -371,6 +371,21 @@ func _login_with_api_key(api_key: String) -> void: 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 res := await _rpc_call("Profile.LoginWithPassword", {"username": username, "password": password}) + 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() -> Array: @@ -429,6 +444,38 @@ 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 ## embedded game objects. Each collection can span multiple pages (cursor), and ## a page served from butlerd's local cache is re-issued fresh. diff --git a/core/itch_settings.gd b/core/itch_settings.gd index bacffbd..e48f457 100644 --- a/core/itch_settings.gd +++ b/core/itch_settings.gd @@ -16,21 +16,34 @@ const icon := preload("res://plugins/itch/assets/itch.svg") @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") +var _collection_checks: Dictionary = {} + 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 - filter_check.button_pressed = settings_manager.get_value("plugin.itch", "filter_unsupported", true) as bool + var saved_method: int = settings_manager.get_value("plugin.itch", "login_method", 0) as int + login_method.selected = saved_method + _on_login_method_changed(saved_method) - help_label.text = "Get an API key from https://itch.io/user/settings/api-keys" + 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 + show_purchases_check.button_pressed = settings_manager.get_value("plugin.itch", "show_purchases", true) as bool _update_status() itch.client_ready.connect(_update_status) @@ -38,6 +51,11 @@ func _ready() -> void: save_button.pressed.connect(_on_save_button) 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 _update_status() -> void: @@ -62,16 +80,82 @@ func _on_login(status: ItchClient.LOGIN_STATUS, _profile: Dictionary) -> void: 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." + notify.text = "itch.io login failed. Double check your credentials." 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 + help_label.visible = is_api + + 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 == "": + var method: int = login_method.selected + settings_manager.set_value("plugin.itch", "login_method", method) + + if method == 0: + # API key login + 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) + else: + # Username/password login + var username: String = username_box.text.strip_edges() + var password: String = password_box.text + settings_manager.set_value("plugin.itch", "username", username) + if username == "" or password == "": + return + itch.login_with_password(username, password) + + +## 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 - itch.login_with_api_key(api_key) + 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 diff --git a/core/itch_settings.tscn b/core/itch_settings.tscn index 9c201b7..f7dca25 100644 --- a/core/itch_settings.tscn +++ b/core/itch_settings.tscn @@ -17,9 +17,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -200.0 -offset_top = -60.0 +offset_top = -120.0 offset_right = 200.0 -offset_bottom = 60.0 +offset_bottom = 120.0 [node name="TitleLabel" type="Label" parent="VBoxContainer"] layout_mode = 2 @@ -31,11 +31,31 @@ unique_name_in_owner = true layout_mode = 2 text = "Status: starting butlerd..." +[node name="LoginMethod" type="OptionButton" parent="VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +item_count = 2 +popup/item_0_text = "API Key" +popup/item_1_text = "Username & Password" + [node name="ApiKeyInput" type="LineEdit" parent="VBoxContainer"] unique_name_in_owner = true layout_mode = 2 placeholder_text = "itch.io API key" +[node name="UsernameInput" type="LineEdit" parent="VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +placeholder_text = "Username" +visible = false + +[node name="PasswordInput" type="LineEdit" parent="VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +placeholder_text = "Password" +secret = true +visible = false + [node name="HelpLabel" type="Label" parent="VBoxContainer"] unique_name_in_owner = true layout_mode = 2 @@ -47,6 +67,21 @@ unique_name_in_owner = true layout_mode = 2 text = "Only show games available on this platform" +[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="VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Show collections:" + +[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 diff --git a/core/library_itch.gd b/core/library_itch.gd index 09d8530..6813c92 100644 --- a/core/library_itch.gd +++ b/core/library_itch.gd @@ -558,9 +558,14 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> return [] logger.info("Fetching itch.io library...") - var owned: Array = await itch.get_owned_games() + var show_purchases: bool = settings_manager.get_value("plugin.itch", "show_purchases", true) as bool + 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 collection_games: Array = await itch.get_collection_games() + var collection_groups: Array = await itch.get_collection_groups() # 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 @@ -595,16 +600,21 @@ 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 # bookmarked), so merge them in too, deduped against owned games. - for g in collection_games: - var game: Dictionary = g - var game_id: int = game.get("id", 0) - if game_id in seen_game_ids: + for group in collection_groups: + var group_dict: Dictionary = group + var col_id: String = str(group_dict.get("id", 0)) + if hidden_collections.has(col_id): continue - var item: Variant = _make_item(game, caves_by_game_id) - if item == null: - continue - seen_game_ids[game_id] = true - items.append(item) + for g in group_dict.get("games", []): + var game: Dictionary = g + var game_id: int = game.get("id", 0) + if game_id in seen_game_ids: + continue + var item: Variant = _make_item(game, caves_by_game_id) + if item == null: + continue + seen_game_ids[game_id] = true + items.append(item) if caching_flags & Cache.FLAGS.SAVE: logger.debug("Saving itch.io apps to cache.") diff --git a/plugin.gd b/plugin.gd index 9d366aa..4027f07 100644 --- a/plugin.gd +++ b/plugin.gd @@ -16,6 +16,8 @@ var icon := preload("res://plugins/itch/assets/itch.svg") 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 func _ready() -> void: @@ -46,15 +48,29 @@ func _on_client_start() -> void: # Triggers when butlerd has completed its handshake and is ready for calls func _on_client_ready() -> void: - if api_key == "": - var notify := Notification.new("itch.io API key required") + if login_method == 0: + # API key login + if api_key == "": + var notify := Notification.new("itch.io API key required") + notify.icon = icon + logger.info(notify.text) + notification_manager.show(notify) + return + 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 + # 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) - return - - # If we have a saved API key, try logging in with it automatically - itch.login_with_api_key(api_key) # Triggers when the itch client finishes a login attempt From 2b07195789ba1dc0eabf3be1bcffb00c8d20c870 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Mon, 17 Aug 2026 02:04:00 -0300 Subject: [PATCH 2/6] Persist login cookies for seamless password login restart --- core/itch_client.gd | 88 ++++++++++++++++++++++++++++++++++++++++++- core/itch_settings.gd | 11 ++++-- plugin.gd | 28 +++++++++----- 3 files changed, 112 insertions(+), 15 deletions(-) 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() From 1931ae143acfa04aa448b1939ac9b6dceb0f6638 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Mon, 17 Aug 2026 02:08:53 -0300 Subject: [PATCH 3/6] Fix cookie persistence: save dict as JSON, verify via HTTP on restart --- core/itch_client.gd | 54 ++++++++++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/core/itch_client.gd b/core/itch_client.gd index 44479cd..61a0815 100644 --- a/core/itch_client.gd +++ b/core/itch_client.gd @@ -385,23 +385,41 @@ 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) + # Persist the cookie so we can re-authenticate on restart. Butlerd + # stores tokens in its own DB, but we re-send the cookie on subsequent + # startups so butlerd can refresh the session if needed. + var cookie_dict: Dictionary = res.get("cookie", {}) + if not cookie_dict.is_empty(): + var cookie_json := JSON.stringify(cookie_dict) + emit_signal.call_deferred("login_cookies_saved", username, cookie_json) 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)) +## Re-authenticates using a previously saved cookie. The cookie is a JSON +## dict of name-value pairs from Profile.LoginWithPassword. We send it to +## itch.io's API directly to verify the session — butlerd doesn't expose a +## "login with cookie" method, but it stores the credentials internally so +## subsequent butlerd calls will work once we've verified the session. +func login_with_cookie(cookie_json: String) -> void: + await thread_group.exec(_login_with_cookie.bind(cookie_json)) -func _login_with_cookie(cookie: String) -> void: +func _login_with_cookie(cookie_json: String) -> void: + var cookie_dict: Dictionary = {} + var json := JSON.new() + if json.parse(cookie_json) == OK and typeof(json.data) == TYPE_DICTIONARY: + cookie_dict = json.data + if cookie_dict.is_empty(): + is_logged_in = false + emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {}) + return + + # Build Cookie header from the dict: "name1=value1; name2=value2" + var pairs: PackedStringArray = [] + for key in cookie_dict: + pairs.append(str(key) + "=" + str(cookie_dict[key])) + var cookie_header := "; ".join(pairs) + var http := HTTPClient.new() var err := http.connect_to_host("api.itch.io", 443, TLSOptions.client()) if err != OK: @@ -424,7 +442,7 @@ func _login_with_cookie(cookie: String) -> void: return var headers := PackedStringArray([ - "Authorization: Bearer " + cookie, + "Cookie: " + cookie_header, "User-Agent: opencode-itch-plugin/1.0", ]) http.request(HTTPClient.METHOD_GET, "/profile", headers) @@ -454,19 +472,19 @@ func _login_with_cookie(cookie: String) -> void: 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(): + var response_text := body.get_string_from_utf8() + if response_code != 200 or response_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: + var resp_json := JSON.new() + if resp_json.parse(response_text) != OK: is_logged_in = false emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {}) return - var data: Dictionary = json.data + var data: Dictionary = resp_json.data profile = data.get("user", data) is_logged_in = true emit_signal.call_deferred("logged_in", LOGIN_STATUS.OK, profile) From d09bd2ae79a0212de73f1ea7b4350e4a3740cdfe Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Mon, 17 Aug 2026 02:19:22 -0300 Subject: [PATCH 4/6] Replace cookie HTTP verification with butlerd's Profile.List + UseSavedLogin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Butlerd stores credentials in its SQLite DB after the first password login. On restart, Profile.List finds saved profiles and Profile.UseSavedLogin refreshes them — no cookie persistence needed on our side. --- core/itch_client.gd | 125 ++++++++++-------------------------------- core/itch_settings.gd | 2 - plugin.gd | 24 +++----- 3 files changed, 37 insertions(+), 114 deletions(-) diff --git a/core/itch_client.gd b/core/itch_client.gd index 61a0815..06b3c6e 100644 --- a/core/itch_client.gd +++ b/core/itch_client.gd @@ -56,7 +56,6 @@ 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 @@ -385,109 +384,41 @@ 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. Butlerd - # stores tokens in its own DB, but we re-send the cookie on subsequent - # startups so butlerd can refresh the session if needed. - var cookie_dict: Dictionary = res.get("cookie", {}) - if not cookie_dict.is_empty(): - var cookie_json := JSON.stringify(cookie_dict) - emit_signal.call_deferred("login_cookies_saved", username, cookie_json) emit_signal.call_deferred("logged_in", LOGIN_STATUS.OK, profile) -## Re-authenticates using a previously saved cookie. The cookie is a JSON -## dict of name-value pairs from Profile.LoginWithPassword. We send it to -## itch.io's API directly to verify the session — butlerd doesn't expose a -## "login with cookie" method, but it stores the credentials internally so -## subsequent butlerd calls will work once we've verified the session. -func login_with_cookie(cookie_json: String) -> void: - await thread_group.exec(_login_with_cookie.bind(cookie_json)) +## 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 _login_with_cookie(cookie_json: String) -> void: - var cookie_dict: Dictionary = {} - var json := JSON.new() - if json.parse(cookie_json) == OK and typeof(json.data) == TYPE_DICTIONARY: - cookie_dict = json.data - if cookie_dict.is_empty(): - is_logged_in = false - emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {}) - return - - # Build Cookie header from the dict: "name1=value1; name2=value2" - var pairs: PackedStringArray = [] - for key in cookie_dict: - pairs.append(str(key) + "=" + str(cookie_dict[key])) - var cookie_header := "; ".join(pairs) - - 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([ - "Cookie: " + cookie_header, - "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 response_text := body.get_string_from_utf8() - if response_code != 200 or response_text.is_empty(): - is_logged_in = false - emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {}) - return - - var resp_json := JSON.new() - if resp_json.parse(response_text) != OK: - is_logged_in = false - emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {}) - return - - var data: Dictionary = resp_json.data - profile = data.get("user", data) +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. diff --git a/core/itch_settings.gd b/core/itch_settings.gd index 0bd1a3f..82ee1d7 100644 --- a/core/itch_settings.gd +++ b/core/itch_settings.gd @@ -100,7 +100,6 @@ 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) @@ -112,7 +111,6 @@ 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) diff --git a/plugin.gd b/plugin.gd index a3b4850..ba649b4 100644 --- a/plugin.gd +++ b/plugin.gd @@ -18,7 +18,6 @@ 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: @@ -30,7 +29,6 @@ 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 @@ -50,6 +48,10 @@ 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 == "": @@ -60,20 +62,17 @@ func _on_client_ready() -> void: return itch.login_with_api_key(api_key) else: - # Username/password login — try saved cookie first. + # 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 - 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) + 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 @@ -93,11 +92,6 @@ 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() From b9effee104f45b11931d8d4d9c87cd4df9abe756 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Mon, 17 Aug 2026 02:20:05 -0300 Subject: [PATCH 5/6] Bump to v0.1.39 --- plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.json b/plugin.json index 3cfd32b..e6d4d11 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "plugin.id": "itch", "plugin.name": "itch.io", - "plugin.version": "0.1.35", + "plugin.version": "0.1.39", "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", From c15d49bfd1cce0d53feb209ea493d5e941cf8fa5 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Mon, 17 Aug 2026 12:27:14 -0300 Subject: [PATCH 6/6] Migrate settings UI to OGPU themed widgets and add logout - Replace plain Godot Controls with StatusPanel, ComponentTextInput, Toggle, CardButton, and Dropdown components - Add logout functionality that clears API key or closes session - Match Steam plugin's settings UI style --- core/itch_client.gd | 22 ++++++++ core/itch_settings.gd | 119 ++++++++++++++++++++++++++-------------- core/itch_settings.tscn | 109 +++++++++++++++++++++--------------- 3 files changed, 166 insertions(+), 84 deletions(-) diff --git a/core/itch_client.gd b/core/itch_client.gd index 06b3c6e..5af9df4 100644 --- a/core/itch_client.gd +++ b/core/itch_client.gd @@ -961,6 +961,28 @@ 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 82ee1d7..a2bd26c 100644 --- a/core/itch_settings.gd +++ b/core/itch_settings.gd @@ -1,28 +1,27 @@ -extends Control +extends MarginContainer ## 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. +## Uses OpenGamepadUI's themed widget set (StatusPanel, ComponentTextInput, +## Toggle, CardButton, Dropdown) for consistent look with the rest of the +## settings UI. 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 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 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 collections_container: VBoxContainer = $%CollectionsContainer @onready var itch: ItchClient = get_tree().get_first_node_in_group("itch_client") @@ -31,12 +30,49 @@ 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.selected = saved_method + login_method.select(saved_method) _on_login_method_changed(saved_method) var username := settings_manager.get_value("plugin.itch", "username", "") as String @@ -45,39 +81,21 @@ 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 - _update_status() - itch.client_ready.connect(_update_status) - itch.logged_in.connect(_on_login) - + # Connect signals save_button.pressed.connect(_on_save_button) - filter_check.toggled.connect(_on_filter_toggled) + logout_button.pressed.connect(_on_logout_button) login_method.item_selected.connect(_on_login_method_changed) + 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 _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() - +func _on_login(login_status: ItchClient.LOGIN_STATUS, _profile: Dictionary) -> void: var notify := Notification.new("") notify.icon = icon - if status == ItchClient.LOGIN_STATUS.OK: + if login_status == ItchClient.LOGIN_STATUS.OK: notify.text = "Successfully logged in to itch.io" else: notify.text = "itch.io login failed. Double check your credentials." @@ -91,7 +109,6 @@ 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: @@ -116,6 +133,26 @@ func _on_save_button() -> void: 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 f7dca25..d2f313f 100644 --- a/core/itch_settings.tscn +++ b/core/itch_settings.tscn @@ -1,88 +1,111 @@ -[gd_scene load_steps=2 format=3 uid="uid://itch_settings_scene"] +[gd_scene load_steps=7 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="Control"] -layout_mode = 3 +[node name="ItchSettings" type="MarginContainer"] anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 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 = -120.0 -offset_right = 200.0 -offset_bottom = 120.0 - -[node name="TitleLabel" type="Label" parent="VBoxContainer"] +[node name="ContentContainer" type="VBoxContainer" parent="."] layout_mode = 2 -text = "itch.io" -horizontal_alignment = 1 +size_flags_horizontal = 3 +size_flags_vertical = 0 +theme_override_constants/separation = 10 -[node name="StatusLabel" type="Label" parent="VBoxContainer"] +[node name="Status" parent="ContentContainer" instance=ExtResource("2")] unique_name_in_owner = true layout_mode = 2 -text = "Status: starting butlerd..." +title = "Status" +description = "" +status = 2 +color = "red" -[node name="LoginMethod" type="OptionButton" parent="VBoxContainer"] +[node name="ConnectedStatus" parent="ContentContainer" instance=ExtResource("2")] unique_name_in_owner = true layout_mode = 2 -item_count = 2 -popup/item_0_text = "API Key" -popup/item_1_text = "Username & Password" +title = "Connected" +description = "" +color = "gray" -[node name="ApiKeyInput" type="LineEdit" parent="VBoxContainer"] +[node name="LoggedInStatus" parent="ContentContainer" instance=ExtResource("2")] unique_name_in_owner = true layout_mode = 2 -placeholder_text = "itch.io API key" +title = "Logged In" +description = "" +color = "gray" -[node name="UsernameInput" type="LineEdit" parent="VBoxContainer"] +[node name="HSeparator" type="HSeparator" parent="ContentContainer"] +layout_mode = 2 + +[node name="LoginMethod" parent="ContentContainer" instance=ExtResource("6")] unique_name_in_owner = true layout_mode = 2 -placeholder_text = "Username" +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" type="LineEdit" parent="VBoxContainer"] +[node name="PasswordInput" parent="ContentContainer" instance=ExtResource("3")] unique_name_in_owner = true layout_mode = 2 -placeholder_text = "Password" +title = "Password" +description = "" secret = true visible = false -[node name="HelpLabel" type="Label" parent="VBoxContainer"] +[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 -autowrap_mode = 2 -text = "Get an API key from https://itch.io/user/settings/api-keys" +text = "Save and Log In" -[node name="FilterUnsupported" type="CheckBox" parent="VBoxContainer"] +[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 layout_mode = 2 text = "Only show games available on this platform" +button_pressed = true -[node name="ShowPurchases" type="CheckBox" parent="VBoxContainer"] +[node name="ShowPurchases" parent="ContentContainer" instance=ExtResource("4")] unique_name_in_owner = true layout_mode = 2 text = "Show main purchases" button_pressed = true -[node name="CollectionsLabel" type="Label" parent="VBoxContainer"] -unique_name_in_owner = true +[node name="CollectionsLabel" type="Label" parent="ContentContainer"] layout_mode = 2 text = "Show collections:" -[node name="CollectionsContainer" type="VBoxContainer" parent="VBoxContainer"] +[node name="CollectionsContainer" type="VBoxContainer" parent="ContentContainer"] 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"