- itch: UPPER_CASE constants, CACHE_NAMESPACE, butler_process/rpc_socket/connection_state, LOGIN_METHOD enum, SETTINGS_SECTION, signal handler renames - artprovider: UPPER_CASE constants, _enriched_meta, _find_game_dict, dead code removal, named poll intervals - session-switch: MAX_POLL_FRAMES, _poll_for_power_menu, BUTTON_LABEL const
969 lines
38 KiB
GDScript
969 lines
38 KiB
GDScript
extends Library
|
|
|
|
const ItchClient := preload("res://plugins/itch/core/itch_client.gd")
|
|
const _apps_cache_file: String = "apps.json"
|
|
const LAUNCH_WATCH_INTERVAL_SEC := 2.0
|
|
const UPDATE_CHECK_INTERVAL_SEC := 600.0
|
|
const BUTLERD_BOOT_MAX_ATTEMPTS := 10
|
|
const BUTLERD_BOOT_RETRY_DELAY_SEC := 0.5
|
|
const LAUNCH_WATCH_TIMEOUT_MS := 5000
|
|
## 0o111 — execute bit set for any of owner/group/others.
|
|
const EXECUTE_PERMISSION_BIT := 73
|
|
|
|
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
|
|
|
|
## pid -> launch time (msec) for itch.io games being watched for launch failure.
|
|
var _launch_watch := {}
|
|
## pids already diagnosed, so a failed game is only reported once.
|
|
var _diagnosed_pids := {}
|
|
## caveId -> true when butlerd reports an update for that cave. Refreshed in the
|
|
## background by CheckUpdate so has_update() stays cheap and synchronous.
|
|
var _update_flags := {}
|
|
|
|
|
|
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)
|
|
itch.install_failed.connect(_on_install_failed)
|
|
# Detect itch.io games that die before creating a window (a missing shared
|
|
# library, a broken install, ...) and surface a clear, actionable error
|
|
# instead of leaving the user stuck on a black in-game screen.
|
|
if load("res://core/global/launch_manager.tres") != null:
|
|
var watch_timer := Timer.new()
|
|
watch_timer.wait_time = LAUNCH_WATCH_INTERVAL_SEC
|
|
watch_timer.autostart = true
|
|
watch_timer.timeout.connect(_check_failed_launches)
|
|
add_child(watch_timer)
|
|
# Keep the has_update() flags fresh in the background (CheckUpdate hits the
|
|
# itch.io API, so it must never run on the UI thread or per-library-load).
|
|
var update_timer := Timer.new()
|
|
update_timer.wait_time = UPDATE_CHECK_INTERVAL_SEC
|
|
update_timer.autostart = true
|
|
update_timer.timeout.connect(_refresh_update_flags)
|
|
add_child(update_timer)
|
|
_refresh_update_flags.call_deferred()
|
|
|
|
|
|
func get_library_launch_items() -> Array[LibraryLaunchItem]:
|
|
return await _load_library(Cache.FLAGS.LOAD | Cache.FLAGS.SAVE)
|
|
|
|
|
|
## Every butlerd install location, surfaced as OGPU InstallLocation objects.
|
|
## OGPU opens the install-location picker (game_launch_menu.gd) whenever this
|
|
## returns at least one entry, so installs let the user pick where the game
|
|
## goes. Size info comes from butlerd's Install.Locations.List (bytes).
|
|
func get_available_install_locations(_item: LibraryLaunchItem = null) -> Array[Library.InstallLocation]:
|
|
var raw := await itch.get_install_locations()
|
|
var out: Array[Library.InstallLocation] = []
|
|
for loc in raw:
|
|
var entry: Dictionary = loc
|
|
var location := Library.InstallLocation.new()
|
|
location.id = entry.get("id", "")
|
|
location.name = entry.get("path", "")
|
|
location.description = "itch.io install folder"
|
|
var size_info: Dictionary = entry.get("sizeInfo", {})
|
|
var total: int = int(size_info.get("totalSize", 0))
|
|
var free: int = int(size_info.get("freeSize", 0))
|
|
if total > 0:
|
|
location.total_space_mb = total / (1024 * 1024)
|
|
if free > 0:
|
|
location.free_space_mb = free / (1024 * 1024)
|
|
out.append(location)
|
|
return out
|
|
|
|
|
|
## Surfaces an upload picker when the game has more than one compatible upload
|
|
## (different platform builds, demo vs full version, ...), so the user can
|
|
## choose which one to install. The picked upload's display name comes back
|
|
## through the options dict and is matched against Fetch.GameUploads in the
|
|
## client's install() call.
|
|
func get_install_options(item: LibraryLaunchItem) -> Array[Library.InstallOption]:
|
|
if item.installed:
|
|
return []
|
|
var game: Dictionary = item.metadata.get("game", {})
|
|
var game_id: int = int(game.get("id", 0))
|
|
if game_id == 0:
|
|
return []
|
|
var uploads: Array = await itch.get_compatible_uploads(game_id)
|
|
if uploads.size() <= 1:
|
|
return []
|
|
var option := Library.InstallOption.new()
|
|
option.id = "upload"
|
|
option.name = "Version"
|
|
option.description = "Which upload of this game to install"
|
|
option.value_type = TYPE_STRING
|
|
var values := []
|
|
for u in uploads:
|
|
var upload: Dictionary = u
|
|
var label: String = upload.get("displayName", "")
|
|
if label == "":
|
|
label = upload.get("filename", "")
|
|
if label != "":
|
|
var size: int = int(upload.get("size", 0))
|
|
values.append(label + " (" + _format_bytes(size) + ")")
|
|
if values.size() <= 1:
|
|
return []
|
|
option.values = values
|
|
return [option]
|
|
|
|
|
|
## Lifecycle hooks OGPU's LaunchManager invokes around a game's lifetime. This
|
|
## build of OGPU never calls get_app_lifecycle_hooks() (the AppLifecycleHook API
|
|
## is dead code in core), so the PRE_LAUNCH hook here is defensive: it re-applies
|
|
## the user-library path to cached items that predate it, in case launch time is
|
|
## ever wired up to the hook API.
|
|
func get_app_lifecycle_hooks() -> Array[AppLifecycleHook]:
|
|
var hooks: Array[AppLifecycleHook] = []
|
|
hooks.append(LaunchHook.new(AppLifecycleHook.TYPE.PRE_LAUNCH, _on_pre_launch))
|
|
return hooks
|
|
|
|
|
|
## Defensive pre-launch cleanup: cached LibraryLaunchItems built before the
|
|
## user-library path existed won't carry LD_LIBRARY_PATH. Re-apply it so games
|
|
## that need it (e.g. Friday Night Funkin' + libvlc) always launch, whatever
|
|
## path the item took into the launch manager.
|
|
func _on_pre_launch(item: LibraryLaunchItem) -> void:
|
|
_apply_user_library_path(item)
|
|
|
|
|
|
## Wraps a callback into OGPU's AppLifecycleHook contract.
|
|
class LaunchHook extends AppLifecycleHook:
|
|
var _callback: Callable
|
|
|
|
func _init(hook_type: AppLifecycleHook.TYPE, callback: Callable) -> void:
|
|
super(hook_type)
|
|
_callback = callback
|
|
|
|
func execute(item: LibraryLaunchItem) -> void:
|
|
_callback.call(item)
|
|
|
|
|
|
func install_to(item: LibraryLaunchItem, location = null, options: Dictionary = {}) -> void:
|
|
var game := (item.metadata.get("game", {}) as Dictionary)
|
|
_active_item = item
|
|
# Forward the user's install-location and install-option choices (upload,
|
|
# ...) to butlerd. Install.Location objects come from the picker OGPU opens
|
|
# when get_available_install_locations() returns more than one entry.
|
|
var opts := {}
|
|
if location != null and not (location.id as String).is_empty():
|
|
opts["install_location_id"] = location.id
|
|
for key in options:
|
|
opts[key] = options[key]
|
|
var success: bool = await itch.install(game, "", opts)
|
|
if success:
|
|
await _refresh_installed_item(item, game)
|
|
_active_item = null
|
|
install_completed.emit(item, success)
|
|
logger.info("Install of '" + item.name + "' completed with status: " + str(success))
|
|
if success:
|
|
_refresh_library_menu(item)
|
|
_refresh_update_flags.call_deferred()
|
|
|
|
|
|
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)
|
|
if success:
|
|
await _refresh_installed_item(item, game)
|
|
_active_item = null
|
|
update_completed.emit(item, success)
|
|
logger.info("Update of '" + item.name + "' completed with status: " + str(success))
|
|
if success:
|
|
_refresh_library_menu(item)
|
|
_refresh_update_flags.call_deferred()
|
|
|
|
|
|
## OpenGamepadUI's library menu doesn't re-render after an install or
|
|
## uninstall (the core install handler is a no-op upstream), so the tabs end
|
|
## up showing stale install state. Signal the menu to queue a refresh.
|
|
##
|
|
## The app must NOT be dropped and re-added here: remove_library_launch_item()
|
|
## erases the launch item from the existing LibraryItem and (when empty) the
|
|
## whole app from the manager, so the next add builds a brand new LibraryItem.
|
|
## The launch page and game settings hold a reference to the *old* item in
|
|
## their state data, and once its launch_items is emptied the provider dropdown
|
|
## in game_launch_settings.gd ends up with zero entries and segfaults on
|
|
## select(). Patching the same instance in place keeps those references valid.
|
|
func _refresh_library_menu(item: LibraryLaunchItem) -> void:
|
|
var library_manager := load("res://core/global/library_manager.tres") as LibraryManager
|
|
if library_manager == null:
|
|
return
|
|
if not library_manager.has_app(item.name):
|
|
library_manager.add_library_launch_item("itch", item)
|
|
return
|
|
var app := library_manager.get_app_by_name(item.name)
|
|
if app.get_launch_item("itch") == null:
|
|
item._provider_id = "itch"
|
|
app.launch_items.push_back(item)
|
|
# library_item_added/library_item_removed both just queue a menu refresh, so
|
|
# a single emit re-renders the grids with the item's current install state.
|
|
library_manager.library_item_added.emit(app)
|
|
|
|
|
|
## OpenGamepadUI hides the Uninstall button once the item is no longer
|
|
## installed. Godot releases focus entirely when the focused control becomes
|
|
## hidden, so the game's launch page ends up unnavigable (D-pad dead) until the
|
|
## user presses the guide button. Hand focus back to the Play/Install button.
|
|
func _restore_launch_focus() -> void:
|
|
logger.info("Restore focus: restoring launch page focus after uninstall.")
|
|
await get_tree().process_frame
|
|
await get_tree().process_frame
|
|
if not is_inside_tree():
|
|
logger.warn("Restore focus: library not in tree.")
|
|
return
|
|
var root := get_tree().root
|
|
if root == null:
|
|
logger.warn("Restore focus: no tree root.")
|
|
return
|
|
var grabbed := false
|
|
for c in root.find_children("LaunchButton", "", true, false):
|
|
if c is Control and c.is_visible_in_tree():
|
|
logger.info("Restore focus: grabbing focus on " + str(c.get_path()))
|
|
(c as Control).grab_focus.call_deferred()
|
|
grabbed = true
|
|
break
|
|
if not grabbed:
|
|
logger.warn("Restore focus: no visible LaunchButton found.")
|
|
return
|
|
await get_tree().process_frame
|
|
var owner := root.gui_get_focus_owner()
|
|
logger.info("Restore focus: focus owner after grab: " + (owner.get_path() if owner != null else "null"))
|
|
|
|
|
|
## User-level shared libraries for itch games, e.g. libvlc.so.5 for games that
|
|
## link against it. Lives under the persistent OGPU data dir so it survives
|
|
## plugin updates and OS re-images, and is injected into the launch environment
|
|
## via LD_LIBRARY_PATH.
|
|
func _user_lib_dir() -> String:
|
|
var dir := _lib_hint()
|
|
if DirAccess.dir_exists_absolute(dir):
|
|
return dir
|
|
return ""
|
|
|
|
|
|
## The path users drop missing shared libraries into (see _user_lib_dir).
|
|
func _lib_hint() -> String:
|
|
return OS.get_environment("HOME") + "/.local/share/opengamepadui/lib"
|
|
|
|
|
|
## User-level runtime binaries for itch games (the LOVE runtime, a JRE, ...),
|
|
## e.g. ~/.local/share/opengamepadui/libexec. Lives under the persistent OGPU
|
|
## data dir so it survives plugin updates and OS re-images. Drop `love` and/or
|
|
## `java` (or symlinks to them) here.
|
|
func _runtime_dir() -> String:
|
|
return OS.get_environment("HOME") + "/.local/share/opengamepadui/libexec"
|
|
|
|
|
|
func _apply_user_library_path(item: LibraryLaunchItem) -> void:
|
|
var lib_dir := _user_lib_dir()
|
|
if lib_dir == "":
|
|
return
|
|
var existing: Variant = item.env.get("LD_LIBRARY_PATH", "")
|
|
if existing is String and not (existing as String).is_empty():
|
|
for entry in (existing as String).split(":", false):
|
|
if entry == lib_dir:
|
|
return
|
|
item.env["LD_LIBRARY_PATH"] = lib_dir + ":" + (existing as String)
|
|
else:
|
|
item.env["LD_LIBRARY_PATH"] = lib_dir
|
|
|
|
|
|
## The LibraryLaunchItem the UI holds was built from a cached library listing
|
|
## that can be stale (old install folder, empty or wrong command). After a
|
|
## successful install or update, patch the SAME item instance in place so the
|
|
## Play button launches the freshly installed binary right away.
|
|
func _refresh_installed_item(item: LibraryLaunchItem, game: Dictionary) -> void:
|
|
var game_id: int = int(game.get("id", 0))
|
|
var caves: Variant = await itch.get_caves()
|
|
if caves == null:
|
|
logger.warn("Could not fetch caves to refresh freshly installed item '" + item.name + "'")
|
|
return
|
|
for c in caves:
|
|
var cave: Dictionary = c
|
|
if int(cave.get("game", {}).get("id", 0)) != game_id:
|
|
continue
|
|
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
|
|
if install_folder == "" or not DirAccess.dir_exists_absolute(install_folder):
|
|
continue
|
|
var launch := _resolve_launch(install_folder)
|
|
item.installed = true
|
|
item.provider_app_id = cave.get("id", "")
|
|
item.cwd = install_folder
|
|
item.command = launch.get("command", "")
|
|
item.args = launch.get("args", [])
|
|
_apply_user_library_path(item)
|
|
logger.info("Refreshed installed item '" + item.name + "' to run: " + item.command)
|
|
return
|
|
logger.warn("Could not locate cave for freshly installed game id " + str(game_id))
|
|
|
|
|
|
func uninstall(item: LibraryLaunchItem) -> void:
|
|
var cave_id := item.provider_app_id
|
|
_active_item = item
|
|
var success: bool = await itch.uninstall(item.provider_app_id)
|
|
if success:
|
|
item.installed = false
|
|
item.command = ""
|
|
item.args = []
|
|
item.cwd = ""
|
|
item.provider_app_id = ""
|
|
_active_item = null
|
|
uninstall_completed.emit(item, success)
|
|
logger.info("Uninstall of '" + item.name + "' completed with status: " + str(success))
|
|
if success:
|
|
_update_flags.erase(cave_id)
|
|
_refresh_library_menu(item)
|
|
_restore_launch_focus()
|
|
|
|
|
|
## itch.io's update availability comes from butlerd's CheckUpdate call, which
|
|
## is async and hits the itch.io API, so it runs in the background and results
|
|
## are cached per cave id. LibraryManager/OGPU's core doesn't poll this today,
|
|
## but implementations that do will get a cheap, synchronous answer.
|
|
func has_update(_item: LibraryLaunchItem) -> bool:
|
|
var cave_id: String = _item.provider_app_id
|
|
if cave_id == "":
|
|
return false
|
|
return _update_flags.get(cave_id, false) as bool
|
|
|
|
|
|
## Moves an install to another location. butlerd has no move RPC (the official
|
|
## itch app does exactly this too), so it's uninstall + reinstall into the
|
|
## target location. Save files live outside the install folder, so nothing is
|
|
## lost in the move.
|
|
func move(item: LibraryLaunchItem, to_location: Library.InstallLocation) -> void:
|
|
var game := (item.metadata.get("game", {}) as Dictionary)
|
|
_active_item = item
|
|
var uninstall_ok: bool = await itch.uninstall(item.provider_app_id)
|
|
if not uninstall_ok:
|
|
_active_item = null
|
|
move_completed.emit(item, false)
|
|
return
|
|
item.provider_app_id = ""
|
|
item.installed = false
|
|
var install_ok: bool = await itch.install(game, "", {"install_location_id": to_location.id})
|
|
if install_ok:
|
|
await _refresh_installed_item(item, game)
|
|
_active_item = null
|
|
move_completed.emit(item, install_ok)
|
|
if install_ok:
|
|
_refresh_library_menu(item)
|
|
_refresh_update_flags.call_deferred()
|
|
|
|
|
|
## Refresh the caveId -> update-available map from butlerd's CheckUpdate. Runs
|
|
## on the background thread (network + rate limits); pass no cave ids so it
|
|
## respects each cave's snooze setting, like the official itch.io app.
|
|
func _refresh_update_flags() -> void:
|
|
if not itch.is_logged_in:
|
|
return
|
|
var updates: Array = await itch.check_updates()
|
|
var flags := {}
|
|
for u in updates:
|
|
var update: Dictionary = u
|
|
var cave_id: String = update.get("caveId", "")
|
|
if cave_id != "":
|
|
flags[cave_id] = true
|
|
_update_flags = flags
|
|
logger.debug("Refreshed update flags for " + str(flags.size()) + " game(s)")
|
|
|
|
|
|
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.")
|
|
_refresh_update_flags.call_deferred()
|
|
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 = 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 launch := _resolve_launch(install_folder)
|
|
item.command = launch.get("command", "")
|
|
item.args = launch.get("args", [])
|
|
item.cwd = install_folder
|
|
_apply_user_library_path(item)
|
|
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.")
|
|
# The cached install folder (and the launch command resolved from it)
|
|
# can be stale: older builds kept games under user://plugins/itch/games,
|
|
# which OGPU trashes on every plugin update, and newer builds
|
|
# re-installed everything under the persistent butler/games location.
|
|
# Reconcile each cached entry against butlerd's live cave list so the
|
|
# install state, install folder and command all point at reality.
|
|
# `get_caves()` returns null on error, [] when nothing is installed.
|
|
var caves: Variant = await itch.get_caves()
|
|
# Butlerd is spawned asynchronously and the first library loads can
|
|
# beat it to the punch, getting an empty cave list and wrongly
|
|
# uninstalling every cached game. When the cache claims installs,
|
|
# give butlerd a moment to come up. Once butlerd is connected, an
|
|
# empty list is the truth (e.g. the user uninstalled everything).
|
|
var cached_installed := false
|
|
for i in json_items:
|
|
if (i as Dictionary).get("installed", false):
|
|
cached_installed = true
|
|
break
|
|
var attempts := 0
|
|
while cached_installed and attempts < BUTLERD_BOOT_MAX_ATTEMPTS and (caves == null or (caves.is_empty() and itch.connection_state != ItchClient.STATE.CONNECTED)):
|
|
await get_tree().create_timer(BUTLERD_BOOT_RETRY_DELAY_SEC).timeout
|
|
caves = await itch.get_caves()
|
|
attempts += 1
|
|
var caves_by_game_id := {}
|
|
if caves != null:
|
|
for c in caves:
|
|
var cave: Dictionary = c
|
|
# Live cave ids are ints, but ids that round-tripped through the
|
|
# JSON cache come back as floats (Godot parses every number as
|
|
# float), so normalize the lookup key to int on both sides.
|
|
caves_by_game_id[int(cave.get("game", {}).get("id", 0))] = cave
|
|
var items := [] as Array[LibraryLaunchItem]
|
|
for i in json_items:
|
|
var item := LibraryLaunchItem.from_dict(i)
|
|
var game: Dictionary = item.metadata.get("game", {})
|
|
var cave: Dictionary = caves_by_game_id.get(int(game.get("id", 0)), {})
|
|
if not cave.is_empty() and _cave_has_files(cave):
|
|
var install_folder: String = cave.get("installInfo", {}).get("installFolder", "")
|
|
if not item.installed or item.cwd != install_folder:
|
|
logger.info("Refreshing install of '" + item.name + "' to: " + install_folder)
|
|
item.installed = true
|
|
item.provider_app_id = cave.get("id", "")
|
|
item.cwd = install_folder
|
|
var launch := _resolve_launch(install_folder)
|
|
item.command = launch.get("command", "")
|
|
item.args = launch.get("args", [])
|
|
elif caves == null:
|
|
# Fetch.Caves failed (e.g. butlerd still booting). Keep the
|
|
# cached state rather than wrongly uninstalling.
|
|
pass
|
|
elif item.installed:
|
|
logger.info("Cached install '" + item.name + "' has no cave on disk. Marking as not installed.")
|
|
item.installed = false
|
|
item.command = ""
|
|
item.args = []
|
|
item.provider_app_id = ""
|
|
if item.installed:
|
|
_apply_user_library_path(item)
|
|
if not _game_available_on_current_platform(game, item.installed):
|
|
continue
|
|
items.append(item)
|
|
if caching_flags & Cache.FLAGS.SAVE:
|
|
var json_out := []
|
|
for it in items:
|
|
var entry: LibraryLaunchItem = it
|
|
json_out.append(entry.to_dict())
|
|
if Cache.save_json(_cache_dir, _apps_cache_file, json_out) != OK:
|
|
logger.warn("Unable to save reconciled itch.io apps cache")
|
|
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 show_purchases: bool = settings_manager.get_value("plugin.itch", "show_purchases", true) as bool
|
|
var hidden_collections: Dictionary = settings_manager.get_value("plugin.itch", "hidden_collections", {}) as Dictionary
|
|
|
|
var owned: Array = []
|
|
if show_purchases:
|
|
owned = await itch.get_owned_games()
|
|
var caves: Variant = await itch.get_caves()
|
|
var collection_groups: Array = await itch.get_collection_groups()
|
|
|
|
# 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.
|
|
if caves == null:
|
|
caves = []
|
|
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 group in collection_groups:
|
|
var group_dict: Dictionary = group
|
|
var col_id: String = str(group_dict.get("id", 0))
|
|
if hidden_collections.has(col_id):
|
|
continue
|
|
for g in group_dict.get("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")
|
|
|
|
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)
|
|
|
|
|
|
## Resolves the authoritative launch command for an installed game.
|
|
##
|
|
## butlerd writes .itch/receipt.json.gz into every install folder and resolves
|
|
## the actual launch target there (the manifest knows a Unity game launches
|
|
## via its .x64 launcher rather than the LinuxPlayer_s.debug stub, that a .love
|
|
## game needs the LOVE runtime, etc.). Guessing with `find | head -n 1` picks
|
|
## the wrong binary for many real games, so the receipt is trusted first and a
|
|
## best-effort scan only kicks in when it's missing or unusable.
|
|
func _resolve_launch(install_folder: String) -> Dictionary:
|
|
if install_folder == "":
|
|
return {"command": "", "args": []}
|
|
var launch := _receipt_launch(install_folder)
|
|
if not launch.is_empty():
|
|
var resolved := _resolve_receipt_launch(install_folder, launch)
|
|
if not (resolved.get("command", "") as String).is_empty():
|
|
return resolved
|
|
return _resolve_fallback_launch(install_folder)
|
|
|
|
|
|
## Best-effort launch resolution when the receipt is missing or doesn't name a
|
|
## usable target. Preference order: real native executables (largest first),
|
|
## then runtime-bundled games detected by file extension (.love / .jar), which
|
|
## butlerd itself doesn't stub into receipts. Each runtime is looked up via
|
|
## _find_on_path(); if missing, a warning tells the user where to drop it.
|
|
func _resolve_fallback_launch(install_folder: String) -> Dictionary:
|
|
if install_folder == "":
|
|
return {"command": "", "args": []}
|
|
var exe := _find_executable_fallback(install_folder)
|
|
if exe != "":
|
|
return {"command": exe, "args": []}
|
|
var out := []
|
|
OS.execute("bash", [
|
|
"-c",
|
|
"find " + install_folder.c_escape() +
|
|
" -maxdepth 4 -type f \\( -name '*.love' -o -name '*.jar' \\) -printf '%s %p\\n' | sort -rn"
|
|
], out)
|
|
if out.is_empty():
|
|
return {"command": "", "args": []}
|
|
var lines := (out[0] as String).split("\n")
|
|
for line in lines:
|
|
var path := line.strip_edges().get_slice(" ", 1)
|
|
if path == "" or path.contains("/.itch/"):
|
|
continue
|
|
if path.ends_with(".love"):
|
|
var love_bin := _find_on_path("love")
|
|
if love_bin == "":
|
|
logger.warn("This is a LÖVE game, but no 'love' runtime was found. Drop one into " + _runtime_dir())
|
|
return {"command": "", "args": []}
|
|
return {"command": love_bin, "args": [path]}
|
|
if path.ends_with(".jar"):
|
|
var java_bin := _find_on_path("java")
|
|
if java_bin == "":
|
|
logger.warn("This is a Java game, but no 'java' runtime was found. Drop one into " + _runtime_dir())
|
|
return {"command": "", "args": []}
|
|
return {"command": java_bin, "args": ["-jar", path]}
|
|
return {"command": "", "args": []}
|
|
|
|
|
|
## Reads the `launch` block of butlerd's install receipt, if any.
|
|
func _receipt_launch(install_folder: String) -> Dictionary:
|
|
var receipt_path := "/".join([install_folder, ".itch", "receipt.json.gz"])
|
|
if not FileAccess.file_exists(receipt_path):
|
|
return {}
|
|
var file := FileAccess.open(receipt_path, FileAccess.READ)
|
|
if file == null:
|
|
return {}
|
|
var bytes := file.get_buffer(file.get_length())
|
|
file.close()
|
|
# Receipts are plain gzip. FileAccess.open_compressed only reads Godot's
|
|
# own GCPF container, not standard gzip, so decompress the raw stream.
|
|
var text := bytes.decompress_dynamic(1024 * 1024, FileAccess.COMPRESSION_GZIP).get_string_from_utf8()
|
|
var parsed: Variant = JSON.parse_string(text)
|
|
if typeof(parsed) != TYPE_DICTIONARY:
|
|
return {}
|
|
var launch: Variant = (parsed as Dictionary).get("launch", {})
|
|
if typeof(launch) != TYPE_DICTIONARY:
|
|
return {}
|
|
return launch
|
|
|
|
|
|
## Turns the receipt's `launch` block into an actual command to run.
|
|
func _resolve_receipt_launch(install_folder: String, launch: Dictionary) -> Dictionary:
|
|
var launch_type: String = launch.get("type", "")
|
|
var rel_path: String = launch.get("path", "")
|
|
var args: Array = launch.get("withArgs", [])
|
|
var abs_path := install_folder
|
|
if not rel_path.is_empty():
|
|
abs_path = "/".join([install_folder, rel_path])
|
|
match launch_type:
|
|
"executable", "shell":
|
|
if not _is_runnable_file(abs_path):
|
|
return {}
|
|
return {"command": abs_path, "args": args}
|
|
"love":
|
|
if not FileAccess.file_exists(abs_path):
|
|
return {}
|
|
var love_bin := _find_on_path("love")
|
|
if love_bin == "":
|
|
logger.warn("This is a LÖVE game, but no 'love' runtime was found. Drop one into " + _runtime_dir())
|
|
return {}
|
|
return {"command": love_bin, "args": [abs_path] + args}
|
|
"jar":
|
|
if not FileAccess.file_exists(abs_path):
|
|
return {}
|
|
var java_bin := _find_on_path("java")
|
|
if java_bin == "":
|
|
logger.warn("This is a Java game, but no 'java' runtime was found. Drop one into " + _runtime_dir())
|
|
return {}
|
|
return {"command": java_bin, "args": ["-jar", abs_path] + args}
|
|
"web", "html":
|
|
logger.warn("HTML5 games aren't launchable in OpenGamepadUI yet: " + abs_path)
|
|
return {}
|
|
return {}
|
|
|
|
|
|
## Best-effort scan for the game's executable when the receipt is missing or
|
|
## doesn't name a usable one. Picks the largest real executable, skipping
|
|
## butler's own metadata dir, Unity debug stubs and shared libraries.
|
|
func _find_executable_fallback(install_folder: String) -> String:
|
|
if install_folder == "":
|
|
return ""
|
|
var out := []
|
|
OS.execute("bash", [
|
|
"-c",
|
|
"find " + install_folder.c_escape() +
|
|
" -maxdepth 4 -type f -executable -printf '%s %p\\n' | sort -rn"
|
|
], out)
|
|
if out.is_empty():
|
|
return ""
|
|
# OS.execute hands back the whole stdout as a single string, split by line.
|
|
var lines := (out[0] as String).split("\n")
|
|
for line in lines:
|
|
var path := line.strip_edges().get_slice(" ", 1)
|
|
if path != "" and _candidate_is_game_binary(path):
|
|
return path
|
|
return ""
|
|
|
|
|
|
## Filters out files that are clearly not the game itself (butler's metadata,
|
|
## Unity debug players, shared libraries, Windows binaries).
|
|
func _candidate_is_game_binary(path: String) -> bool:
|
|
if path.contains("/.itch/"):
|
|
return false
|
|
if path.ends_with("Player_s.debug"):
|
|
return false
|
|
if path.ends_with(".so") or path.ends_with(".debug"):
|
|
return false
|
|
if path.ends_with(".dll"):
|
|
return false
|
|
return true
|
|
|
|
|
|
## Returns true when the file exists and is marked executable.
|
|
func _is_runnable_file(path: String) -> bool:
|
|
if path == "" or not FileAccess.file_exists(path):
|
|
return false
|
|
return FileAccess.get_unix_permissions(path) & EXECUTE_PERMISSION_BIT != 0
|
|
|
|
|
|
## Looks up a runtime binary (love, java, ...). Checks the persistent runtime
|
|
## dir first (where users can drop a LOVE runtime or JRE), then PATH.
|
|
func _find_on_path(bin: String) -> String:
|
|
var candidate := "/".join([_runtime_dir(), bin])
|
|
if _is_runnable_file(candidate):
|
|
return candidate
|
|
for dir in OS.get_environment("PATH").split(":"):
|
|
if dir == "":
|
|
continue
|
|
candidate = "/".join([dir, bin])
|
|
if _is_runnable_file(candidate):
|
|
return candidate
|
|
return ""
|
|
|
|
|
|
## When a game dies before creating a window, OGPU's LaunchManager never marks
|
|
## it stopped (its app type stays UNKNOWN), so the user is left on a black
|
|
## in-game screen with no clue what happened. Poll running apps; for one of
|
|
## ours that is dead and windowless, run diagnostics (ldd for missing shared
|
|
## libraries, ...) and show a notification pointing at the fix. Forcing the app
|
|
## to STOPPED triggers LaunchManager's normal cleanup, which removes the stuck
|
|
## in-game state and returns the user to the library UI where the notification
|
|
## is visible.
|
|
func _check_failed_launches() -> void:
|
|
var launch_manager := load("res://core/global/launch_manager.tres") as LaunchManager
|
|
if launch_manager == null:
|
|
return
|
|
var now := Time.get_ticks_msec()
|
|
var running_pids := {}
|
|
for app in launch_manager.get_running():
|
|
var item: LibraryLaunchItem = app.launch_item
|
|
if item == null or item._provider_id != library_id:
|
|
continue
|
|
running_pids[app.pid] = true
|
|
if app.created_window:
|
|
# The game got a window, so it launched fine. Stop watching it.
|
|
_launch_watch.erase(app.pid)
|
|
continue
|
|
if not _launch_watch.has(app.pid):
|
|
_launch_watch[app.pid] = now
|
|
continue
|
|
if app.is_running():
|
|
continue
|
|
if now - int(_launch_watch[app.pid]) < LAUNCH_WATCH_TIMEOUT_MS:
|
|
continue
|
|
if app.pid in _diagnosed_pids:
|
|
continue
|
|
_diagnosed_pids[app.pid] = true
|
|
_launch_watch.erase(app.pid)
|
|
_handle_launch_failure(app)
|
|
# Forget apps that are no longer running at all (launched fine and exited,
|
|
# or were stopped some other way) so the watch list stays small.
|
|
var stale := []
|
|
for pid in _launch_watch:
|
|
if not running_pids.has(pid):
|
|
stale.append(pid)
|
|
for pid in stale:
|
|
_launch_watch.erase(pid)
|
|
|
|
|
|
func _handle_launch_failure(app: RunningApp) -> void:
|
|
var item: LibraryLaunchItem = app.launch_item
|
|
var message := _diagnose_launch_failure(item)
|
|
logger.warn("Game '" + item.name + "' failed to start: " + message)
|
|
# Return to the library UI first (LaunchManager cleans up a STOPPED app and
|
|
# pops the stuck black in-game state), then show the failure as a popup.
|
|
app.state = app.STATE.STOPPED
|
|
_show_failure_dialog.call_deferred(item.name, message)
|
|
|
|
|
|
## Shows the launch failure as a modal popup (OGPU's Dialog component) instead
|
|
## of a transient toast, so the user has time to read and act on the message.
|
|
func _show_failure_dialog(game_name: String, message: String) -> void:
|
|
_show_modal_dialog("Failed to start: " + game_name + "\n\n" + message)
|
|
|
|
|
|
## Surfaces an install/update failure (butlerd RPC error, corrupt download,
|
|
## ...) in the same modal Dialog component the launch failure uses. Before
|
|
## this, install errors were only written to the log, so a failed install
|
|
## looked like a silent no-op.
|
|
func _on_install_failed(_cave_or_game_id: String, game_title: String, message: String) -> void:
|
|
logger.warn("Install of '" + game_title + "' failed: " + message)
|
|
_show_modal_dialog("Failed to install: " + game_title + "\n\n" + message)
|
|
|
|
|
|
## Shows a message in OGPU's modal Dialog component. The Dialog scene is
|
|
## authored with zero-size anchors; make it (and its Spacer) fill the screen
|
|
## so the panel is centered over whatever UI is currently up.
|
|
func _show_modal_dialog(body: String) -> void:
|
|
var dialog_scene := load("res://core/ui/components/dialog.tscn") as PackedScene
|
|
if dialog_scene == null:
|
|
logger.warn("Dialog component unavailable; message not shown to user.")
|
|
return
|
|
var ui: Node = get_tree().get_first_node_in_group("main")
|
|
if ui == null:
|
|
ui = get_tree().root
|
|
var dialog: Control = dialog_scene.instantiate()
|
|
ui.add_child(dialog)
|
|
dialog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
dialog.size = (ui as Control).size if ui is Control else Vector2(1920, 1080)
|
|
(dialog.get_node("Spacer") as Control).set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
dialog.cancel_visible = false
|
|
dialog.closed.connect(func() -> void: dialog.queue_free())
|
|
# Hand focus back to whatever had it before the modal opened. Without a
|
|
# return node the Dialog leaves the UI unfocused on close, so gamepad
|
|
# navigation goes dead until the user presses the guide button.
|
|
var return_node: Control = get_tree().root.gui_get_focus_owner()
|
|
dialog.open(return_node, body, "OK")
|
|
|
|
|
|
## Figures out why a game failed to start and returns a human-readable,
|
|
## actionable explanation (missing shared library, missing binary, missing
|
|
## game file, ...).
|
|
func _diagnose_launch_failure(item: LibraryLaunchItem) -> String:
|
|
var command: String = item.command
|
|
if command.is_empty():
|
|
return "No launch command was set for this game. Reinstall it to repair the install."
|
|
if not FileAccess.file_exists(command):
|
|
return "Executable not found: " + command + ". Reinstall the game to repair the install."
|
|
if not _is_runnable_file(command):
|
|
return "Executable is missing the execute bit: " + command
|
|
var missing := _missing_shared_libs(command)
|
|
if not missing.is_empty():
|
|
return "Missing shared libraries: " + missing + ". Drop the .so files into " + _lib_hint() + " and launch again."
|
|
var args := item.args
|
|
if args.size() > 0 and not FileAccess.file_exists(args[0]):
|
|
return "Game file not found: " + args[0] + ". Reinstall the game."
|
|
return "The game exited before opening a window. Command was: " + command
|
|
|
|
|
|
## Runs ldd on an ELF executable and returns the shared libraries the loader
|
|
## can't find, e.g. "libvlc.so.5" (the exact cause of Friday Night Funkin'
|
|
## failing to launch until its libraries were made available).
|
|
func _missing_shared_libs(binary: String) -> String:
|
|
var out := []
|
|
OS.execute("ldd", [binary], out)
|
|
if out.is_empty():
|
|
return ""
|
|
var missing := []
|
|
for raw in (out[0] as String).split("\n"):
|
|
var line: String = raw.strip_edges()
|
|
# A missing dependency looks like "libfoo.so => not found".
|
|
if not line.contains("=>") or not line.contains("not found"):
|
|
continue
|
|
var name := line.get_slice("=>", 0).strip_edges()
|
|
if name != "":
|
|
missing.append(name)
|
|
return ", ".join(missing)
|
|
|
|
|
|
## Formats a byte count for display, e.g. 53712463 -> "51.2 MB". A zero-byte
|
|
## upload (like a "latest" external redirect placeholder) shows as "0 B" so
|
|
## the user can spot it before picking it.
|
|
func _format_bytes(bytes: int) -> String:
|
|
if bytes <= 0:
|
|
return "0 B"
|
|
var units := ["B", "KB", "MB", "GB", "TB"]
|
|
var value: float = float(bytes)
|
|
var i := 0
|
|
while value >= 1024.0 and i < units.size() - 1:
|
|
value /= 1024.0
|
|
i += 1
|
|
if i == 0:
|
|
return str(int(value)) + " B"
|
|
return str(snapped(value, 0.1)) + " " + units[i]
|