extends Library const ItchClient := preload("res://plugins/itch/core/itch_client.gd") const _apps_cache_file: String = "apps.json" @onready var itch: ItchClient = get_tree().get_first_node_in_group("itch_client") 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) itch.install(game) func update(item: LibraryLaunchItem) -> void: var game := (item.metadata.get("game", {}) as Dictionary) itch.install(game, item.provider_app_id) func uninstall(item: LibraryLaunchItem) -> void: itch.uninstall(item.provider_app_id) ## 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 ## Re-emits itch.io's raw (id, current, total) install progress under the ## base [Library] signal contract (item, percent_completed) so the UI's ## generic install-progress widgets pick it up regardless of provider. ## TODO: verify the exact LibraryManager lookup method/key for resolving a ## provider_app_id back to its LibraryLaunchItem (this plugin was written ## against the public Library/LibraryLaunchItem API, but LibraryManager's ## internals weren't available while writing this) and wire the emit below ## through it instead of dropping the notification. func _on_install_progressed(_id: String, _current: int, _total: int) -> void: pass ## 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: items.append(LibraryLaunchItem.from_dict(i)) 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 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] for o in owned: var owned_entry: Dictionary = o var game: Dictionary = owned_entry.get("game", {}) if game.is_empty(): continue # Only surface actual games (itch.io also hosts tools, assets, # soundtracks, comics, etc. under the same ownership API). if game.get("classification", "game") != "game": continue 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 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") return items ## 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()