diff --git a/core/itch_client.gd b/core/itch_client.gd index 5af9df4..ee83933 100644 --- a/core/itch_client.gd +++ b/core/itch_client.gd @@ -371,56 +371,6 @@ 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 params := {"username": username, "password": password} - var res := await _rpc_call("Profile.LoginWithPassword", params) - if "error" in res: - is_logged_in = false - emit_signal.call_deferred("logged_in", LOGIN_STATUS.INVALID_KEY, {}) - return - profile = res.get("profile", {}) - is_logged_in = true - emit_signal.call_deferred("logged_in", LOGIN_STATUS.OK, profile) - - -## Attempts to resume a previous login using butlerd's stored credentials. -## Returns true on success (profile is set, logged_in signal emitted). -func try_saved_login() -> bool: - return await thread_group.exec(_try_saved_login) - - -func _try_saved_login() -> bool: - var list_res := await _rpc_call("Profile.List", {}) - if "error" in list_res: - return false - var profiles: Array = list_res.get("profiles", []) - if profiles.is_empty(): - return false - # Use the most recently connected profile. - var best: Dictionary = profiles[0] - var best_time: int = int(best.get("lastConnected", 0)) - for p in profiles: - var t: int = int(p.get("lastConnected", 0)) - if t > best_time: - best = p - best_time = t - var profile_id: int = int(best.get("id", 0)) - if profile_id == 0: - return false - var use_res := await _rpc_call("Profile.UseSavedLogin", {"profileId": profile_id}) - if "error" in use_res: - logger.warn("Profile.UseSavedLogin failed: " + str(use_res["error"])) - return false - profile = use_res.get("profile", {}) - is_logged_in = true - emit_signal.call_deferred("logged_in", LOGIN_STATUS.OK, profile) - return true - - ## Returns every game the logged-in profile owns a download key for. ## Each item looks like: {"downloadKey": {...}, "game": {...}} func get_owned_games() -> Array: @@ -479,38 +429,6 @@ func _get_collection_games() -> Array: ) -## Like get_collection_games, but returns {id, title, games} per collection -## so the library can filter by collection visibility settings. -func get_collection_groups() -> Array: - return await thread_group.exec(_get_collection_groups) - - -func _get_collection_groups() -> Array: - if not is_logged_in: - return [] - var params := {"profileId": profile.get("user", {}).get("id", profile.get("id", 0))} - var res := await _rpc_call("Fetch.ProfileCollections", params) - if "error" in res: - return [] - if res.get("stale", false): - params["fresh"] = true - res = await _rpc_call("Fetch.ProfileCollections", params) - if "error" in res: - return [] - var collections: Array = res.get("items", []) - var groups := [] - for c in collections: - var collection: Dictionary = c - var col_id: int = collection.get("id", 0) - var col_title: String = collection.get("title", "") - var games := await _fetch_collection_games( - profile.get("user", {}).get("id", profile.get("id", 0)), - [collection] - ) - groups.append({"id": col_id, "title": col_title, "games": games}) - return groups - - ## Paginates through Fetch.Collection.Games for every collection, collecting the ## embedded game objects. Each collection can span multiple pages (cursor), and ## a page served from butlerd's local cache is re-issued fresh. @@ -961,28 +879,6 @@ func _launch(cave_id: String) -> void: launch_exited.emit.call_deferred(cave_id) -## 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..bacffbd 100644 --- a/core/itch_settings.gd +++ b/core/itch_settings.gd @@ -1,199 +1,77 @@ -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 collections_container: VBoxContainer = $%CollectionsContainer +@onready var status_label: Label = $%StatusLabel +@onready var api_key_box: LineEdit = $%ApiKeyInput +@onready var save_button: Button = $%SaveButton +@onready var help_label: Label = $%HelpLabel +@onready var filter_check: CheckBox = $%FilterUnsupported @onready var itch: ItchClient = get_tree().get_first_node_in_group("itch_client") -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) - _on_login_method_changed(saved_method) - - var username := settings_manager.get_value("plugin.itch", "username", "") as String - username_box.text = username - filter_check.button_pressed = settings_manager.get_value("plugin.itch", "filter_unsupported", true) as bool - show_purchases_check.button_pressed = settings_manager.get_value("plugin.itch", "show_purchases", true) as bool - # Connect signals + help_label.text = "Get an API key from https://itch.io/user/settings/api-keys" + + _update_status() + itch.client_ready.connect(_update_status) + itch.logged_in.connect(_on_login) + save_button.pressed.connect(_on_save_button) - 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 _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." + notify.text = "itch.io login failed. Double check your API key." notification_manager.show(notify) -func _on_login_method_changed(idx: int) -> void: - # API key mode: show API key input, hide user/pass. - # Password mode: show user/pass, hide API key. - var is_api := idx == 0 - api_key_box.visible = is_api - username_box.visible = not is_api - password_box.visible = not is_api - - func _on_save_button() -> void: - var method: int = login_method.selected - settings_manager.set_value("plugin.itch", "login_method", method) - - if method == 0: - # API key login - settings_manager.set_value("plugin.itch", "username", "") - var api_key: String = api_key_box.text.strip_edges() - 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 uname: String = username_box.text.strip_edges() - var password: String = password_box.text - settings_manager.set_value("plugin.itch", "username", uname) - if uname == "" or password == "": - return - itch.login_with_password(uname, password) - - -func _on_logout_button() -> void: - var method: int = login_method.selected - - # Clear saved credentials - if method == 0: - settings_manager.set_value("plugin.itch", "api_key", "") - api_key_box.text = "" - else: - settings_manager.set_value("plugin.itch", "username", "") - username_box.text = "" - password_box.text = "" - - # Logout from butlerd - itch.logout() - - var notify := Notification.new("Logged out of itch.io") - notify.icon = icon - notification_manager.show(notify) - - -## Fetches the user's collections and builds a checkbox for each one. -func _populate_collections(_status: ItchClient.LOGIN_STATUS, _profile: Dictionary) -> void: - if not itch.is_logged_in: + var api_key: String = api_key_box.text.strip_edges() + settings_manager.set_value("plugin.itch", "api_key", api_key) + if api_key == "": return - var profile_id: int = itch.profile.get("user", {}).get("id", itch.profile.get("id", 0)) - var res := await itch._rpc_call("Fetch.ProfileCollections", {"profileId": profile_id}) - if "error" in res: - return - var collections: Array = res.get("items", []) - - # Load saved visibility state. - var hidden: Dictionary = settings_manager.get_value("plugin.itch", "hidden_collections", {}) as Dictionary - - # Clear old checkboxes. - for child in collections_container.get_children(): - child.queue_free() - _collection_checks.clear() - - for c in collections: - var collection: Dictionary = c - var col_name: String = collection.get("title", "Collection " + str(collection.get("id", ""))) - var col_id: String = str(collection.get("id", 0)) - var check := CheckBox.new() - check.text = col_name - check.button_pressed = not hidden.has(col_id) - check.toggled.connect(_on_collection_toggled.bind(col_id)) - collections_container.add_child(check) - _collection_checks[col_id] = check - - -func _on_collection_toggled(pressed: bool, col_id: String) -> void: - var hidden: Dictionary = settings_manager.get_value("plugin.itch", "hidden_collections", {}) as Dictionary - if pressed: - hidden.erase(col_id) - else: - hidden[col_id] = true - settings_manager.set_value("plugin.itch", "hidden_collections", hidden) - - -func _on_show_purchases_toggled(pressed: bool) -> void: - settings_manager.set_value("plugin.itch", "show_purchases", pressed) + itch.login_with_api_key(api_key) ## 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 d2f313f..9c201b7 100644 --- a/core/itch_settings.tscn +++ b/core/itch_settings.tscn @@ -1,111 +1,53 @@ -[gd_scene load_steps=7 format=3 uid="uid://itch_settings_scene"] +[gd_scene load_steps=2 format=3 uid="uid://itch_settings_scene"] [ext_resource type="Script" path="res://plugins/itch/core/itch_settings.gd" id="1"] -[ext_resource type="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 = -60.0 +offset_right = 200.0 +offset_bottom = 60.0 -[node name="Status" parent="ContentContainer" instance=ExtResource("2")] +[node name="TitleLabel" type="Label" parent="VBoxContainer"] +layout_mode = 2 +text = "itch.io" +horizontal_alignment = 1 + +[node name="StatusLabel" type="Label" parent="VBoxContainer"] unique_name_in_owner = true layout_mode = 2 -title = "Status" -description = "" -status = 2 -color = "red" +text = "Status: starting butlerd..." -[node name="ConnectedStatus" parent="ContentContainer" instance=ExtResource("2")] +[node name="ApiKeyInput" type="LineEdit" parent="VBoxContainer"] unique_name_in_owner = true layout_mode = 2 -title = "Connected" -description = "" -color = "gray" +placeholder_text = "itch.io API key" -[node name="LoggedInStatus" parent="ContentContainer" instance=ExtResource("2")] +[node name="HelpLabel" type="Label" parent="VBoxContainer"] unique_name_in_owner = true layout_mode = 2 -title = "Logged In" -description = "" -color = "gray" +autowrap_mode = 2 +text = "Get an API key from https://itch.io/user/settings/api-keys" -[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 -title = "Login Method" -description = "" - -[node name="ApiKeyInput" parent="ContentContainer" instance=ExtResource("3")] -unique_name_in_owner = true -layout_mode = 2 -title = "API Key" -description = "Get an API key from https://itch.io/user/settings/api-keys" -secret = true - -[node name="UsernameInput" parent="ContentContainer" instance=ExtResource("3")] -unique_name_in_owner = true -layout_mode = 2 -title = "Username" -description = "" -visible = false - -[node name="PasswordInput" parent="ContentContainer" instance=ExtResource("3")] -unique_name_in_owner = true -layout_mode = 2 -title = "Password" -description = "" -secret = true -visible = false - -[node name="HSeparatorLogin" type="HSeparator" parent="ContentContainer"] -layout_mode = 2 - -[node name="SaveButton" parent="ContentContainer" instance=ExtResource("5")] -unique_name_in_owner = true -layout_mode = 2 -text = "Save and Log In" - -[node name="LogoutButton" parent="ContentContainer" instance=ExtResource("5")] -unique_name_in_owner = true -layout_mode = 2 -text = "Logout" - -[node name="HSeparator2" type="HSeparator" parent="ContentContainer"] -layout_mode = 2 - -[node name="FilterUnsupported" parent="ContentContainer" instance=ExtResource("4")] +[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")] -unique_name_in_owner = true -layout_mode = 2 -text = "Show main purchases" -button_pressed = true - -[node name="CollectionsLabel" type="Label" parent="ContentContainer"] -layout_mode = 2 -text = "Show collections:" - -[node name="CollectionsContainer" type="VBoxContainer" parent="ContentContainer"] +[node name="SaveButton" type="Button" parent="VBoxContainer"] unique_name_in_owner = true layout_mode = 2 +text = "Save and Log In" diff --git a/core/library_itch.gd b/core/library_itch.gd index 6813c92..8100989 100644 --- a/core/library_itch.gd +++ b/core/library_itch.gd @@ -19,6 +19,13 @@ var _diagnosed_pids := {} ## caveId -> true when butlerd reports an update for that cave. Refreshed in the ## background by CheckUpdate so has_update() stays cheap and synchronous. var _update_flags := {} +## gameId -> {genre, inputs, screenshots} parsed from each game's itch.io page. +## butlerd's Game struct has none of these fields, so they're cached here and +## merged into the game dict on every library load, powering per-layout boxart +## (and, potentially, gamepad/genre filters). +var _enriched: Dictionary = {} + +const _meta_cache_file: String = "itch_meta.json" func _ready() -> void: @@ -449,6 +456,7 @@ func _cave_has_files(cave: Dictionary) -> bool: func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant: if game.is_empty(): return null + game = _apply_enriched(game) if game.get("classification", "game") != "game": return null var game_id: int = int(game.get("id", 0)) @@ -479,6 +487,7 @@ func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant: ## from Fetch.Caves. Uses the standard Cache system so we don't hammer ## butlerd (and, transitively, the itch.io API) on every library refresh. func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> Array[LibraryLaunchItem]: + _enriched = _load_enriched_meta() if caching_flags & Cache.FLAGS.LOAD and Cache.is_cached(_cache_dir, _apps_cache_file): var json_items = Cache.get_json(_cache_dir, _apps_cache_file) if json_items != null: @@ -517,6 +526,10 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> var items := [] as Array[LibraryLaunchItem] for i in json_items: var item := LibraryLaunchItem.from_dict(i) + # Re-apply enriched page metadata (genre/inputs/screenshots) from + # the itch_meta cache so a cached library still writes per-layout + # boxart (and could filter by gamepad/genre) on cold loads. + item.metadata["game"] = _apply_enriched(item.metadata.get("game", {})) var game: Dictionary = item.metadata.get("game", {}) var cave: Dictionary = caves_by_game_id.get(int(game.get("id", 0)), {}) if not cave.is_empty() and _cave_has_files(cave): @@ -544,6 +557,7 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> if not _game_available_on_current_platform(game, item.installed): continue items.append(item) + _queue_boxart(items) if caching_flags & Cache.FLAGS.SAVE: var json_out := [] for it in items: @@ -558,14 +572,9 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> return [] logger.info("Fetching itch.io library...") - 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 owned: Array = await itch.get_owned_games() var caves: Variant = await itch.get_caves() - var collection_groups: Array = await itch.get_collection_groups() + var collection_games: Array = await itch.get_collection_games() # Clean up "orphan" caves (butler.db entries whose install folder was wiped # by a plugin update) in the background so a later Install.Queue doesn't @@ -600,21 +609,16 @@ 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 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): + for g in collection_games: + var game: Dictionary = g + var game_id: int = game.get("id", 0) + if game_id in seen_game_ids: continue - 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) + 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.") @@ -625,6 +629,8 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> if Cache.save_json(_cache_dir, _apps_cache_file, json_items) != OK: logger.warn("Unable to save itch.io apps cache") + _queue_boxart(items) + return items @@ -636,6 +642,271 @@ func _cleanup_orphan_caves(cave_ids: Array) -> void: await itch.uninstall(cave_id) +## Queues background downloads of each game's art into OGPU's local boxart +## directory (user://boxart/local/). The built-in "local" BoxArtProvider picks +## those files up by -.png, so games show their real cover +## instead of the placeholder. Each task enriches the game from its itch.io page +## first (genre/inputs/screenshots, cached in itch_meta.json) and then writes +## per-layout art: the cover for portrait/logo, full-res screenshots for +## landscape/banner. For games with an animated GIF cover, itch.io provides a +## stillCoverUrl (a static frame, served as PNG) which we prefer, since Godot +## has no GIF decoder. +func _queue_boxart(items: Array) -> void: + for i in items: + var item: LibraryLaunchItem = i + var game: Dictionary = item.metadata.get("game", {}) + if int(game.get("id", 0)) == 0: + continue + itch.thread_group.scheduled_exec(_enrich_and_boxart.bind(game), 0) + + +## Downloads the given game's art (blocking, runs on the shared thread) and +## writes it to the boxart layouts. The cover (or a full-res screenshot when +## enriched metadata is available) fills every slot itch.io can serve: OGPU's +## boxart slots all keep-aspect (scale + crop), so the 315x250 cover works for +## portrait/logo while screenshots give landscape/banner real resolution. +## When force is set (right after a fresh page fetch), landscape/banner get +## overwritten so installs that only ever had the cover upgraded with art. +func _ensure_boxart(game: Dictionary, meta: Dictionary = {}, force := false) -> void: + var title: String = game.get("title", "") + var cover_url := _cover_url(game) + if title.is_empty() or cover_url.is_empty(): + return + var dir := _boxart_dir() + + # Screenshots are full-res originals from the game page; the cover is only + # reused for landscape/banner when a game has no screenshots at all. + var screenshots: Array = meta.get("screenshots", []) + var sources := { + "portrait": cover_url, + "landscape": screenshots[0] if not screenshots.is_empty() else cover_url, + "banner": screenshots[1] if screenshots.size() > 1 else (screenshots[0] if not screenshots.is_empty() else cover_url), + "logo": cover_url, + } + # portrait/logo keep the cover forever; landscape/banner are replaced by + # screenshot art on the first enrichment after an upgrade. + var fixed := {"portrait": true, "logo": true} + var bodies := {} + DirAccess.make_dir_recursive_absolute(dir) + for layout in ["portrait", "landscape", "banner", "logo"]: + var path := "/".join([dir, title + "-" + layout + ".png"]) + if FileAccess.file_exists(path): + if fixed.has(layout) or not force: + continue + var url: String = sources[layout] + if url.is_empty(): + continue + var body: PackedByteArray + if bodies.has(url): + body = bodies[url] + else: + body = _http_get(url) + bodies[url] = body + if body.is_empty() or not _is_raster_image(body): + # OGPU's local provider only loads png/jpg. If itch.io didn't give us a + # still for an animated cover, skip rather than write an unloadable file. + logger.warn("Skipping unsupported art for '" + title + "' layout " + layout) + continue + var file := FileAccess.open(path, FileAccess.WRITE) + if file: + file.store_buffer(body) + file.close() + logger.info("Downloaded " + layout + " boxart for '" + title + "'") + + +## Runs on the shared thread: enrich the game from its itch.io page (once per +## game, cached in itch_meta.json) and write per-layout boxart. A fresh fetch +## force-refreshes landscape/banner so installs that only ever had the cover +## copied into every slot get upgraded to full-res screenshots. +func _enrich_and_boxart(game: Dictionary) -> void: + var game_id := int(game.get("id", 0)) + if game_id == 0: + return + var meta := {} + var force := false + if _enriched.has(game_id): + meta = _enriched[game_id] + else: + var fetched := _fetch_game_page_metadata(game) + if not fetched.is_empty(): + meta = fetched + force = true + _save_enriched_meta(game_id, meta) + logger.info("Enriched page metadata for '" + game.get("title", "") + "': genre='" + str(meta.get("genre", "")) + "' inputs=" + str(meta.get("inputs", []))) + _ensure_boxart(game, meta, force) + + +## Fetches a game's itch.io page and parses the Details table (genre, inputs) +## plus the screenshot gallery. Returns {} on failure, otherwise +## {genre: String, inputs: Array[String], screenshots: Array[String]}. None of +## these fields exist in butlerd's Game struct; data.json exposes only freeform +## tags and a price, so the page HTML is the only keyless source. +func _fetch_game_page_metadata(game: Dictionary) -> Dictionary: + var page_url: String = game.get("url", "") + if page_url.is_empty(): + return {} + var body := _http_get(page_url) + if body.is_empty(): + return {} + var html := body.get_string_from_utf8() + if html.is_empty(): + return {} + + # RegEx instances are created here (background thread) to avoid sharing + # RegEx objects across threads. + var genre_re := RegEx.new() + genre_re.compile('Genre]*>([^<]+)') + var inputs_row_re := RegEx.new() + inputs_row_re.compile('(?s)Inputs(.*?)') + var input_slug_re := RegEx.new() + input_slug_re.compile('input-([a-z0-9-]+)') + var shot_re := RegEx.new() + shot_re.compile('https://img\\.itch\\.zone/aW1hZ2Uv[^"]*/original/[^"]*') + + var genre := "" + var genre_match := genre_re.search(html) + if genre_match != null: + genre = _decode_html(genre_match.get_string(1).strip_edges()) + + var inputs: Array = [] + var inputs_match := inputs_row_re.search(html) + if inputs_match != null: + for m in input_slug_re.search_all(inputs_match.get_string(1)): + inputs.append(m.get_string(1)) + + var screenshots: Array = [] + for m in shot_re.search_all(html): + screenshots.append(m.get_string(0)) + + return {"genre": genre, "inputs": inputs, "screenshots": screenshots} + + +## Decodes the HTML entities itch.io's Details table can contain. +func _decode_html(value: String) -> String: + value = value.replace("&", "&") + value = value.replace("<", "<") + value = value.replace(">", ">") + value = value.replace(""", "\"") + value = value.replace("'", "'") + return value + + +## Loads the itch_meta.json cache: gameId -> {genre, inputs, screenshots}. +## JSON round-trips integer keys as strings, so they are normalized to int. +func _load_enriched_meta() -> Dictionary: + var raw: Variant = Cache.get_json(_cache_dir, _meta_cache_file) + if typeof(raw) != TYPE_DICTIONARY: + return {} + var out := {} + for key in raw: + out[int(key)] = raw[key] + return out + + +## Merges one game's enriched metadata into the shared itch_meta.json cache. +func _save_enriched_meta(game_id: int, meta: Dictionary) -> void: + var raw: Variant = Cache.get_json(_cache_dir, _meta_cache_file) + if typeof(raw) != TYPE_DICTIONARY: + raw = {} + raw[str(game_id)] = meta + Cache.save_json(_cache_dir, _meta_cache_file, raw) + + +## Merges the enriched metadata (genre/inputs/screenshots) into a game dict so +## it survives and is available during _make_item and boxart setup. +func _apply_enriched(game: Dictionary) -> Dictionary: + if game.is_empty(): + return game + var meta: Dictionary = _enriched.get(int(game.get("id", 0)), {}) + if meta.is_empty(): + return game + if not game.has("genre"): + game["genre"] = meta.get("genre", "") + if not game.has("inputs"): + game["inputs"] = meta.get("inputs", []) + if not game.has("screenshots"): + game["screenshots"] = meta.get("screenshots", []) + return game + + +func _boxart_dir() -> String: + return ProjectSettings.globalize_path("user://boxart/local") + + +## itch.io exposes stillCoverUrl for games whose cover is an animated GIF: a +## static frame that the CDN serves as PNG. Prefer it over coverUrl so those +## games still get art (Godot can't decode GIFs at runtime). +func _cover_url(game: Dictionary) -> String: + var still: String = game.get("stillCoverUrl", "") + if not still.is_empty(): + return still + return game.get("coverUrl", "") + + +## Blocking HTTPS GET of a single URL (cover image or game page HTML), for use +## on a background thread. +func _http_get(url: String) -> PackedByteArray: + var parts := url.split("/") + var use_tls := parts[0] == "https:" + var http := HTTPClient.new() + var err: int = http.connect_to_host(parts[2], 443 if use_tls else 80, TLSOptions.client() if use_tls else null) + if err != OK: + return PackedByteArray() + var deadline := Time.get_ticks_msec() + 15000 + while http.get_status() == HTTPClient.STATUS_CONNECTING or http.get_status() == HTTPClient.STATUS_RESOLVING: + http.poll() + if Time.get_ticks_msec() > deadline: + http.close() + return PackedByteArray() + OS.delay_msec(50) + if http.get_status() != HTTPClient.STATUS_CONNECTED: + http.close() + return PackedByteArray() + + http.request(HTTPClient.METHOD_GET, "/" + "/".join(parts.slice(3)), PackedStringArray()) + deadline = Time.get_ticks_msec() + 15000 + while http.get_status() == HTTPClient.STATUS_REQUESTING: + http.poll() + if Time.get_ticks_msec() > deadline: + http.close() + return PackedByteArray() + OS.delay_msec(10) + + var body := PackedByteArray() + while http.get_status() == HTTPClient.STATUS_BODY: + http.poll() + var chunk: PackedByteArray = http.read_response_body_chunk() + if chunk.is_empty(): + if Time.get_ticks_msec() > deadline: + http.close() + return PackedByteArray() + OS.delay_msec(10) + continue + body.append_array(chunk) + + var code: int = http.get_response_code() + http.close() + if code != 200: + return PackedByteArray() + return body + + +## Returns true if the bytes look like a PNG or JPEG (the only formats OGPU's +## local boxart provider can load). +func _is_raster_image(body: PackedByteArray) -> bool: + if body.size() < 12: + return false + var png_magic := [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + var is_png := true + for i in png_magic.size(): + if body[i] != png_magic[i]: + is_png = false + break + if is_png: + return true + return body[0] == 0xff and body[1] == 0xd8 and body[2] == 0xff + + ## Resolves the authoritative launch command for an installed game. ## ## butlerd writes .itch/receipt.json.gz into every install folder and resolves diff --git a/plugin.gd b/plugin.gd index ba649b4..9d366aa 100644 --- a/plugin.gd +++ b/plugin.gd @@ -16,8 +16,6 @@ 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: @@ -48,31 +46,15 @@ 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 == "": - 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 - var notify := Notification.new("itch.io: open plugin settings to log in") + if api_key == "": + var notify := Notification.new("itch.io API key required") 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 @@ -81,13 +63,13 @@ func _on_client_logged_in(status: ItchClient.LOGIN_STATUS, profile: Dictionary) notify.icon = icon if status == ItchClient.LOGIN_STATUS.OK: - var uname: String = profile.get("user", {}).get("username", "") - notify.text = "Successfully logged in to itch.io as " + uname + var username: String = profile.get("user", {}).get("username", "") + notify.text = "Successfully logged in to itch.io as " + username logger.info(notify.text) notification_manager.show(notify) return - notify.text = "Failed to log in to itch.io. Check your credentials in plugin settings." + notify.text = "Failed to log in to itch.io. Check your API key in plugin settings." logger.warn(notify.text) notification_manager.show(notify) diff --git a/plugin.json b/plugin.json index e6d4d11..c46628a 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.33", "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",