From e2a4464d0dabbded23152044fde9809d9c76f655 Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Mon, 17 Aug 2026 02:00:39 -0300 Subject: [PATCH] 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