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. Installed games always pass: they were ## installed on this machine, so they're runnable regardless of what ## platform info butlerd reported. Everything else needs an explicit ## platform match, so games with no platform info at all (e.g. HTML5) are ## hidden while the filter is on. func _game_available_on_current_platform(game: Dictionary, installed := false) -> bool: if not _filter_unsupported(): return true if installed: return true var platforms: Dictionary = game.get("platforms", {}) if platforms.is_empty(): return false 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 var game_id: int = game.get("id", 0) var cave: Dictionary = caves_by_game_id.get(game_id, {}) if not _game_available_on_current_platform(game, not cave.is_empty()): return null 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, item.installed): 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 -.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()