2026-08-05 17:27:43 -03:00
|
|
|
extends Library
|
|
|
|
|
|
|
|
|
|
const ItchClient := preload("res://plugins/itch/core/itch_client.gd")
|
|
|
|
|
const _apps_cache_file: String = "apps.json"
|
|
|
|
|
|
2026-08-07 18:41:39 -03:00
|
|
|
var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager
|
|
|
|
|
|
2026-08-05 17:27:43 -03:00
|
|
|
@onready var itch: ItchClient = get_tree().get_first_node_in_group("itch_client")
|
|
|
|
|
|
2026-08-06 19:13:34 -03:00
|
|
|
## 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
|
|
|
|
|
|
2026-08-05 17:27:43 -03:00
|
|
|
|
|
|
|
|
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)
|
2026-08-06 19:13:34 -03:00
|
|
|
_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))
|
2026-08-05 17:27:43 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func update(item: LibraryLaunchItem) -> void:
|
|
|
|
|
var game := (item.metadata.get("game", {}) as Dictionary)
|
2026-08-06 19:13:34 -03:00
|
|
|
_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))
|
2026-08-05 17:27:43 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func uninstall(item: LibraryLaunchItem) -> void:
|
2026-08-06 19:13:34 -03:00
|
|
|
_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))
|
2026-08-05 17:27:43 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
## 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
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 19:13:34 -03:00
|
|
|
## 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))
|
2026-08-05 17:27:43 -03:00
|
|
|
|
|
|
|
|
|
2026-08-07 18:41:39 -03:00
|
|
|
## Returns true unless the "only show games available on this platform"
|
|
|
|
|
## filter is enabled and the game's platforms don't include the OS
|
2026-08-07 22:19:51 -03:00
|
|
|
## 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:
|
2026-08-07 18:41:39 -03:00
|
|
|
if not _filter_unsupported():
|
|
|
|
|
return true
|
2026-08-07 22:19:51 -03:00
|
|
|
if installed:
|
|
|
|
|
return true
|
2026-08-07 18:41:39 -03:00
|
|
|
var platforms: Dictionary = game.get("platforms", {})
|
|
|
|
|
if platforms.is_empty():
|
2026-08-07 22:19:51 -03:00
|
|
|
return false
|
2026-08-07 18:41:39 -03:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 18:52:55 -03:00
|
|
|
## Builds a LibraryLaunchItem for a game, or null when the entry isn't an
|
2026-08-07 18:41:39 -03:00
|
|
|
## actual game (itch.io also hosts tools, assets, soundtracks, comics, ...)
|
|
|
|
|
## or is filtered out by the "available on this platform" setting.
|
2026-08-06 18:52:55 -03:00
|
|
|
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, {})
|
2026-08-07 22:19:51 -03:00
|
|
|
if not _game_available_on_current_platform(game, not cave.is_empty()):
|
|
|
|
|
return null
|
2026-08-06 18:52:55 -03:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 17:27:43 -03:00
|
|
|
## 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:
|
2026-08-07 18:41:39 -03:00
|
|
|
var item := LibraryLaunchItem.from_dict(i)
|
|
|
|
|
var game: Dictionary = item.metadata.get("game", {})
|
2026-08-07 22:19:51 -03:00
|
|
|
if not _game_available_on_current_platform(game, item.installed):
|
2026-08-07 18:41:39 -03:00
|
|
|
continue
|
|
|
|
|
items.append(item)
|
2026-08-06 18:29:47 -03:00
|
|
|
_queue_boxart(items)
|
2026-08-05 17:27:43 -03:00
|
|
|
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...")
|
2026-08-05 22:08:24 -03:00
|
|
|
var owned: Array = await itch.get_owned_games()
|
2026-08-05 17:27:43 -03:00
|
|
|
var caves: Array = await itch.get_caves()
|
2026-08-06 18:52:55 -03:00
|
|
|
var collection_games: Array = await itch.get_collection_games()
|
2026-08-05 17:27:43 -03:00
|
|
|
|
|
|
|
|
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]
|
2026-08-06 18:52:55 -03:00
|
|
|
var seen_game_ids := {}
|
2026-08-05 17:27:43 -03:00
|
|
|
for o in owned:
|
|
|
|
|
var owned_entry: Dictionary = o
|
|
|
|
|
var game: Dictionary = owned_entry.get("game", {})
|
2026-08-06 18:52:55 -03:00
|
|
|
var item := _make_item(game, caves_by_game_id)
|
|
|
|
|
if item == null:
|
2026-08-05 17:27:43 -03:00
|
|
|
continue
|
2026-08-06 18:52:55 -03:00
|
|
|
seen_game_ids[game.get("id", 0)] = true
|
|
|
|
|
items.append(item)
|
2026-08-05 17:27:43 -03:00
|
|
|
|
2026-08-06 18:52:55 -03:00
|
|
|
# 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
|
2026-08-05 17:27:43 -03:00
|
|
|
var game_id: int = game.get("id", 0)
|
2026-08-06 18:52:55 -03:00
|
|
|
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
|
2026-08-05 17:27:43 -03:00
|
|
|
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")
|
|
|
|
|
|
2026-08-06 18:29:47 -03:00
|
|
|
_queue_boxart(items)
|
|
|
|
|
|
2026-08-05 17:27:43 -03:00
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 18:29:47 -03:00
|
|
|
## 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
|
2026-08-06 18:46:02 -03:00
|
|
|
## 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.
|
2026-08-06 18:29:47 -03:00
|
|
|
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", "")
|
2026-08-06 18:46:02 -03:00
|
|
|
var cover_url := _cover_url(game)
|
2026-08-06 18:29:47 -03:00
|
|
|
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", "")
|
2026-08-06 18:46:02 -03:00
|
|
|
var cover_url := _cover_url(game)
|
2026-08-06 18:29:47 -03:00
|
|
|
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):
|
2026-08-06 18:46:02 -03:00
|
|
|
# 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.
|
2026-08-06 18:29:47 -03:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 18:46:02 -03:00
|
|
|
## 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", "")
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 18:29:47 -03:00
|
|
|
## 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
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 17:27:43 -03:00
|
|
|
## 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()
|