OGPU moves the whole extracted plugin directory (plugins/itch/) to trash on every plugin update, so keeping installed games under user://plugins/itch/games meant game files silently vanished on update while butler.db kept the (now orphaned) cave. That made fresh installs fail with butlerd's "That upload is already installed!" and broke launches for games whose files were gone. - New installs go to user://butler/games (persistent, next to butler.db) via a reworked _ensure_install_location; prereqs move there too - _install/_update reconcile the cave first: a cave whose install folder no longer exists is uninstalled before a fresh (re)install, and a valid existing cave turns the request into an update instead - _make_item treats missing-folder caves as not installed, and the cache path re-checks cwd so stale caches don't render a broken Play button - orphaned caves are cleaned out of butler.db in the background on library load - drop the plugin's global InstallLocation class stub: it collided with OGPU core's nested Library.InstallLocation and broke compilation on a cold .godot cache (the unused install_to arg is now untyped) - Makefile: compute preset number with shell arithmetic instead of bc
406 lines
15 KiB
GDScript
406 lines
15 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 = 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
|
|
|
|
|
|
## A cave is only usable when its install folder actually exists on disk.
|
|
## OGPU moves the whole extracted plugin directory (where older builds kept
|
|
## installed games) to the trash on every plugin update, so butler.db can hold
|
|
## "orphan" caves whose files are gone. Those must not show as installed: the
|
|
## user can't launch them, and Install.Queue would reject a fresh install with
|
|
## "That upload is already installed!".
|
|
func _cave_has_files(cave: Dictionary) -> bool:
|
|
if cave.is_empty():
|
|
return false
|
|
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
|
|
if install_folder == "":
|
|
return false
|
|
return DirAccess.dir_exists_absolute(install_folder)
|
|
|
|
|
|
## 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, {})
|
|
var cave_valid: bool = _cave_has_files(cave)
|
|
if not _game_available_on_current_platform(game, cave_valid):
|
|
return null
|
|
|
|
var item := LibraryLaunchItem.new()
|
|
item.provider_app_id = cave.get("id", "") if cave_valid else ""
|
|
item.name = game.get("title", "Unknown itch.io game")
|
|
item.tags = ["itch"]
|
|
item.categories = ["Game"]
|
|
item.installed = cave_valid
|
|
item.metadata = {"game": game}
|
|
|
|
if cave_valid:
|
|
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)
|
|
# A cached item can claim to be installed while its folder was
|
|
# wiped by a plugin update (see _cave_has_files). Re-check the
|
|
# folder so stale caches don't render a broken Play button.
|
|
if item.installed and item.cwd != "" and not DirAccess.dir_exists_absolute(item.cwd):
|
|
logger.info("Cached install '" + item.name + "' has no files on disk. Marking as not installed.")
|
|
item.installed = false
|
|
item.command = ""
|
|
item.provider_app_id = ""
|
|
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()
|
|
|
|
# Clean up "orphan" caves (butler.db entries whose install folder was wiped
|
|
# by a plugin update) in the background so a later Install.Queue doesn't
|
|
# trip over them. The folder is already gone, so nothing is lost.
|
|
var orphan_cave_ids := []
|
|
for c in caves:
|
|
var cave: Dictionary = c
|
|
if not _cave_has_files(cave):
|
|
orphan_cave_ids.append(cave.get("id", ""))
|
|
if not orphan_cave_ids.is_empty():
|
|
logger.info("Found " + str(orphan_cave_ids.size()) + " orphaned cave(s) with missing files. Cleaning up.")
|
|
_cleanup_orphan_caves(orphan_cave_ids)
|
|
|
|
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: Variant = _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: Variant = _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
|
|
|
|
|
|
## Fire-and-forget removal of butler.db caves whose install folders no longer
|
|
## exist. Runs sequentially (one butlerd uninstall at a time) so the shared
|
|
## thread's one-shot queue never holds concurrent RPCs.
|
|
func _cleanup_orphan_caves(cave_ids: Array) -> void:
|
|
for cave_id in cave_ids:
|
|
await itch.uninstall(cave_id)
|
|
|
|
|
|
## 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()
|