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.
357 lines
13 KiB
GDScript
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()
|