itchio-opengamepadui-plugin/core/library_itch.gd
Jose Falanga b0183b7c20 Add option to filter games not available on the current platform
The new 'Only show games available on this platform' checkbox in the
plugin settings drops games whose platforms don't include the OS
OpenGamepadUI is running on (e.g. Windows-only games on Linux). Games
with no platform info at all (HTML5) are kept since there's nothing to
filter on. The setting is read on every library load, so toggling it
reloads the itch.io library without a restart.
2026-08-07 18:41:39 -03:00

357 lines
13 KiB
GDScript

extends Library
const ItchClient := preload("res://plugins/itch/core/itch_client.gd")
const _apps_cache_file: String = "apps.json"
var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager
@onready var itch: ItchClient = get_tree().get_first_node_in_group("itch_client")
## The item currently being installed/updated/uninstalled. OGPU's InstallManager
## only runs one install at a time, so a single slot is enough to map butlerd's
## install_progressed/app_* signals back to the LibraryLaunchItem.
var _active_item: LibraryLaunchItem
func _ready() -> void:
super()
library_id = "itch"
logger_name = "itch"
logger = Log.get_logger(logger_name, log_level)
logger.info("itch.io library loaded")
itch.logged_in.connect(_on_logged_in)
itch.install_progressed.connect(_on_install_progressed)
func get_library_launch_items() -> Array[LibraryLaunchItem]:
return await _load_library(Cache.FLAGS.LOAD | Cache.FLAGS.SAVE)
func install_to(item: LibraryLaunchItem, _location: InstallLocation = null, _options: Dictionary = {}) -> void:
var game := (item.metadata.get("game", {}) as Dictionary)
_active_item = item
var success: bool = await itch.install(game)
_active_item = null
install_completed.emit(item, success)
logger.info("Install of '" + item.name + "' completed with status: " + str(success))
func update(item: LibraryLaunchItem) -> void:
var game := (item.metadata.get("game", {}) as Dictionary)
_active_item = item
var success: bool = await itch.install(game, item.provider_app_id)
_active_item = null
update_completed.emit(item, success)
logger.info("Update of '" + item.name + "' completed with status: " + str(success))
func uninstall(item: LibraryLaunchItem) -> void:
_active_item = item
var success: bool = await itch.uninstall(item.provider_app_id)
_active_item = null
uninstall_completed.emit(item, success)
logger.info("Uninstall of '" + item.name + "' completed with status: " + str(success))
## itch.io's CheckUpdate call is async (and rate-limited), so we don't poll it
## synchronously here. LibraryManager is expected to periodically call
## get_library_launch_items() again, which re-fetches from butlerd's cache.
func has_update(_item: LibraryLaunchItem) -> bool:
return false
func _on_logged_in(status: ItchClient.LOGIN_STATUS, _profile: Dictionary) -> void:
if status != ItchClient.LOGIN_STATUS.OK:
return
logger.info("Logged in. Refreshing itch.io library.")
var items: Array = await _load_library(Cache.FLAGS.SAVE)
for i in items:
var item: LibraryLaunchItem = i
if not library_manager.has_app(item.name):
logger.debug("App '" + item.name + "' was not loaded. Reloading library.")
library_manager.reload_library()
return
## Forwards butlerd's install progress (current/total are percentage points,
## 0-100) to the base [Library] install_progressed contract (a fraction,
## 0.0-1.0) that OGPU's InstallManager and launch menu render as a progress
## bar.
func _on_install_progressed(_id: String, current: int, total: int) -> void:
if _active_item == null:
return
if total <= 0:
return
logger.info("Install progressing: " + str(current) + "/" + str(total))
install_progressed.emit(_active_item, float(current) / float(total))
## Returns true unless the "only show games available on this platform"
## filter is enabled and the game's platforms don't include the OS
## OpenGamepadUI is running on. Games with no platform info at all (e.g.
## HTML5) are kept, since there's nothing to filter on.
func _game_available_on_current_platform(game: Dictionary) -> bool:
if not _filter_unsupported():
return true
var platforms: Dictionary = game.get("platforms", {})
if platforms.is_empty():
return true
var current := "windows"
if OS.get_name() == "Linux":
current = "linux"
elif OS.get_name() == "macOS":
current = "osx"
return not (platforms.get(current, "") as String).is_empty()
func _filter_unsupported() -> bool:
return settings_manager.get_value("plugin.itch", "filter_unsupported", true) as bool
## Builds a LibraryLaunchItem for a game, or null when the entry isn't an
## actual game (itch.io also hosts tools, assets, soundtracks, comics, ...)
## or is filtered out by the "available on this platform" setting.
func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant:
if game.is_empty():
return null
if game.get("classification", "game") != "game":
return null
if not _game_available_on_current_platform(game):
return null
var game_id: int = game.get("id", 0)
var cave: Dictionary = caves_by_game_id.get(game_id, {})
var item := LibraryLaunchItem.new()
item.provider_app_id = cave.get("id", "")
item.name = game.get("title", "Unknown itch.io game")
item.tags = ["itch"]
item.categories = ["Game"]
item.installed = not cave.is_empty()
item.metadata = {"game": game}
if not cave.is_empty():
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
var exe := _find_executable(install_folder)
item.command = exe
item.args = []
item.cwd = install_folder
return item
## Builds the full itch.io library: owned games merged with install state
## 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]:
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:
logger.info("itch.io apps found in cache. Using cache.")
var items := [] as Array[LibraryLaunchItem]
for i in json_items:
var item := LibraryLaunchItem.from_dict(i)
var game: Dictionary = item.metadata.get("game", {})
if not _game_available_on_current_platform(game):
continue
items.append(item)
_queue_boxart(items)
return items
if not itch.is_logged_in:
logger.info("itch.io client is not logged in yet.")
return []
logger.info("Fetching itch.io library...")
var owned: Array = await itch.get_owned_games()
var caves: Array = await itch.get_caves()
var collection_games: Array = await itch.get_collection_games()
var caves_by_game_id := {}
for c in caves:
var cave: Dictionary = c
var game_id: int = cave.get("game", {}).get("id", 0)
caves_by_game_id[game_id] = cave
var items := [] as Array[LibraryLaunchItem]
var seen_game_ids := {}
for o in owned:
var owned_entry: Dictionary = o
var game: Dictionary = owned_entry.get("game", {})
var item := _make_item(game, caves_by_game_id)
if item == null:
continue
seen_game_ids[game.get("id", 0)] = true
items.append(item)
# 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:
continue
var item := _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.")
var json_items := []
for i in items:
var item: LibraryLaunchItem = i
json_items.append(item.to_dict())
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
## Queues background downloads of each game's cover art into OGPU's local
## boxart directory (user://boxart/local/). The built-in "local" BoxArtProvider
## picks those files up by <game name>-<layout>.png, so games show their real
## cover instead of the placeholder. Covers are only downloaded once (the task
## bails out if a layout file already exists). 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", {})
var title: String = game.get("title", "")
var cover_url := _cover_url(game)
if title.is_empty() or cover_url.is_empty():
continue
if FileAccess.file_exists("/".join([_boxart_dir(), title + "-portrait.png"])):
continue
itch.thread_group.scheduled_exec(_ensure_boxart.bind(game), 0)
## Downloads the given game's cover (blocking, runs on the shared thread) and
## writes it to every boxart layout. itch.zone only serves one fixed landscape
## image per game, and OGPU's boxart slots all keep-aspect (scale + crop), so a
## single image is reused for portrait, landscape, banner and logo.
func _ensure_boxart(game: Dictionary) -> 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()
if FileAccess.file_exists("/".join([dir, title + "-portrait.png"])):
return
var body := _download_cover(cover_url)
if body.is_empty():
logger.warn("Unable to download cover art for '" + title + "'")
return
if 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 cover format for '" + title + "'")
return
DirAccess.make_dir_recursive_absolute(dir)
for layout in ["portrait", "landscape", "banner", "logo"]:
var path := "/".join([dir, title + "-" + layout + ".png"])
var file := FileAccess.open(path, FileAccess.WRITE)
if file:
file.store_buffer(body)
file.close()
logger.info("Downloaded boxart for '" + title + "'")
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 cover image, for use on a background thread.
func _download_cover(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
## Best-effort discovery of the game's launch executable inside its install
## folder. butlerd doesn't hand back a ready-to-exec command the way a
## "gog://" or "steam://" URI does, so we look at what's actually on disk.
## TODO: parse .itch/receipt.json.gz for the authoritative launch target
## and any declared manifest Actions instead of guessing.
func _find_executable(install_folder: String) -> String:
if install_folder == "":
return ""
var out := []
OS.execute("bash", [
"-c",
"find " + install_folder.c_escape() + " -maxdepth 2 -type f -executable | head -n 1"
], out)
if out.is_empty():
return ""
return (out[0] as String).strip_edges()