Add install locations, upload picker, update flags, move, and launch hooks (v0.1.24)

This commit is contained in:
Jose Falanga 2026-08-10 00:04:37 -03:00
parent a024907696
commit ff2ae68d9e
3 changed files with 252 additions and 29 deletions

View file

@ -16,6 +16,9 @@ var _active_item: LibraryLaunchItem
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:
@ -35,16 +38,121 @@ func _ready() -> void:
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 = 600.0
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)
func install_to(item: LibraryLaunchItem, _location = null, _options: Dictionary = {}) -> void:
## 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 != "":
values.append(label)
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
var success: bool = await itch.install(game)
# 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
@ -52,6 +160,7 @@ func install_to(item: LibraryLaunchItem, _location = null, _options: Dictionary
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:
@ -65,6 +174,7 @@ func update(item: LibraryLaunchItem) -> void:
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
@ -191,21 +301,68 @@ func uninstall(item: LibraryLaunchItem) -> void:
uninstall_completed.emit(item, success)
logger.info("Uninstall of '" + item.name + "' completed with status: " + str(success))
if success:
_update_flags.erase(item.provider_app_id)
_refresh_library_menu(item)
_restore_launch_focus()
## 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.
## 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:
return false
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