Add install locations, upload picker, update flags, move, and launch hooks (v0.1.24)
This commit is contained in:
parent
a024907696
commit
ff2ae68d9e
3 changed files with 252 additions and 29 deletions
|
|
@ -515,10 +515,72 @@ func _ensure_install_location() -> String:
|
||||||
return _install_location_id
|
return _install_location_id
|
||||||
|
|
||||||
|
|
||||||
|
## Returns every configured install location, each as:
|
||||||
|
## {"id": "...", "path": "...", "sizeInfo": {"installedSize", "freeSize", "totalSize"}}
|
||||||
|
func get_install_locations() -> Array:
|
||||||
|
return await thread_group.exec(_get_install_locations)
|
||||||
|
|
||||||
|
|
||||||
|
func _get_install_locations() -> Array:
|
||||||
|
var res := await _rpc_call("Install.Locations.List", {})
|
||||||
|
if "error" in res:
|
||||||
|
logger.warn("Install.Locations.List failed: " + str(res["error"]))
|
||||||
|
return []
|
||||||
|
return res.get("installLocations", [])
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the uploads butlerd considers compatible with this machine for the
|
||||||
|
## given game. Each entry is a full Upload dict (id, filename, displayName,
|
||||||
|
## size, type, platforms, ...).
|
||||||
|
func get_compatible_uploads(game_id: int) -> Array:
|
||||||
|
return await thread_group.exec(_get_compatible_uploads.bind(game_id))
|
||||||
|
|
||||||
|
|
||||||
|
func _get_compatible_uploads(game_id: int) -> Array:
|
||||||
|
var params := {"gameId": game_id, "compatible": true}
|
||||||
|
var res := await _rpc_call("Fetch.GameUploads", params)
|
||||||
|
if "error" in res:
|
||||||
|
logger.warn("Fetch.GameUploads failed for game " + str(game_id) + ": " + str(res["error"]))
|
||||||
|
return []
|
||||||
|
# Uploads are cached by butlerd just like owned keys, so retry fresh when
|
||||||
|
# the cached result is stale, otherwise a cold database would report every
|
||||||
|
# game as having no compatible upload.
|
||||||
|
if res.get("stale", false):
|
||||||
|
logger.info("Uploads are stale. Refreshing from itch.io.")
|
||||||
|
params["fresh"] = true
|
||||||
|
res = await _rpc_call("Fetch.GameUploads", params)
|
||||||
|
if "error" in res:
|
||||||
|
logger.warn("Fetch.GameUploads (fresh) failed for game " + str(game_id) + ": " + str(res["error"]))
|
||||||
|
return []
|
||||||
|
return res.get("uploads", [])
|
||||||
|
|
||||||
|
|
||||||
|
## Looks for updates to installed caves. When cave_ids is empty, butlerd checks
|
||||||
|
## every cave and respects per-cave snooze (matching the official itch.io app).
|
||||||
|
## Returns the list of GameUpdate dicts (each with a `caveId`).
|
||||||
|
func check_updates(cave_ids: Array = []) -> Array:
|
||||||
|
return await thread_group.exec(_check_updates.bind(cave_ids))
|
||||||
|
|
||||||
|
|
||||||
|
func _check_updates(cave_ids: Array) -> Array:
|
||||||
|
var params := {}
|
||||||
|
if not cave_ids.is_empty():
|
||||||
|
params["caveIds"] = cave_ids
|
||||||
|
var res := await _rpc_call("CheckUpdate", params)
|
||||||
|
if "error" in res:
|
||||||
|
logger.warn("CheckUpdate failed: " + str(res["error"]))
|
||||||
|
return []
|
||||||
|
return res.get("updates", [])
|
||||||
|
|
||||||
|
|
||||||
## Installs (or updates, if a caveId is given) the given game.
|
## Installs (or updates, if a caveId is given) the given game.
|
||||||
|
## `options` may carry:
|
||||||
|
## - "install_location_id": the InstallLocation id to install into (fresh installs)
|
||||||
|
## - "upload": the upload filename/displayName chosen by the user (defaults to
|
||||||
|
## butlerd's first compatible upload otherwise)
|
||||||
## Returns true when the operation finished without errors.
|
## Returns true when the operation finished without errors.
|
||||||
func install(game: Dictionary, cave_id: String = "") -> bool:
|
func install(game: Dictionary, cave_id: String = "", options: Dictionary = {}) -> bool:
|
||||||
return await thread_group.exec(_install.bind(game, cave_id))
|
return await thread_group.exec(_install.bind(game, cave_id, options))
|
||||||
|
|
||||||
|
|
||||||
## Reconciles an install/update request against what butlerd actually has
|
## Reconciles an install/update request against what butlerd actually has
|
||||||
|
|
@ -560,36 +622,30 @@ func _resolve_cave(game: Dictionary, cave_id: String) -> String:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
func _install(game: Dictionary, cave_id: String) -> bool:
|
func _install(game: Dictionary, cave_id: String, options: Dictionary) -> bool:
|
||||||
var game_id: int = game.get("id", 0)
|
var game_id: int = game.get("id", 0)
|
||||||
cave_id = await _resolve_cave(game, cave_id)
|
cave_id = await _resolve_cave(game, cave_id)
|
||||||
var reason := "update" if cave_id != "" else "install"
|
var reason := "update" if cave_id != "" else "install"
|
||||||
|
|
||||||
var uploads_params := {"gameId": game_id, "compatible": true}
|
var uploads := await _get_compatible_uploads(game_id)
|
||||||
var uploads_res := await _rpc_call("Fetch.GameUploads", uploads_params)
|
|
||||||
if "error" in uploads_res:
|
|
||||||
logger.warn("Fetch.GameUploads failed: " + str(uploads_res["error"]))
|
|
||||||
emit_signal.call_deferred("app_installed", cave_id, false)
|
|
||||||
return false
|
|
||||||
# Uploads are cached by butlerd just like owned keys, so retry fresh when
|
|
||||||
# the cached result is stale, otherwise a cold database would report every
|
|
||||||
# game as having no compatible upload.
|
|
||||||
if uploads_res.get("stale", false):
|
|
||||||
logger.info("Uploads are stale. Refreshing from itch.io.")
|
|
||||||
uploads_params["fresh"] = true
|
|
||||||
uploads_res = await _rpc_call("Fetch.GameUploads", uploads_params)
|
|
||||||
if "error" in uploads_res:
|
|
||||||
logger.warn("Fetch.GameUploads (fresh) failed: " + str(uploads_res["error"]))
|
|
||||||
emit_signal.call_deferred("app_installed", cave_id, false)
|
|
||||||
return false
|
|
||||||
var uploads: Array = uploads_res.get("uploads", [])
|
|
||||||
if uploads.is_empty():
|
if uploads.is_empty():
|
||||||
logger.error("No compatible uploads found for game id " + str(game_id))
|
logger.error("No compatible uploads found for game id " + str(game_id))
|
||||||
emit_signal.call_deferred("app_installed", cave_id, false)
|
emit_signal.call_deferred("app_installed", cave_id, false)
|
||||||
return false
|
return false
|
||||||
var upload: Dictionary = uploads[0]
|
var upload: Dictionary = uploads[0]
|
||||||
|
# Respect the upload the user picked in the install-options dialog, when
|
||||||
|
# one was offered (the game has more than one compatible upload).
|
||||||
|
var chosen_upload: Variant = options.get("upload", null)
|
||||||
|
if chosen_upload != null:
|
||||||
|
var picked := _pick_upload(uploads, str(chosen_upload))
|
||||||
|
if picked.is_empty():
|
||||||
|
logger.warn("Selected upload '" + str(chosen_upload) + "' not found. Using default upload.")
|
||||||
|
else:
|
||||||
|
upload = picked
|
||||||
|
|
||||||
var location_id := await _ensure_install_location()
|
var location_id: String = options.get("install_location_id", "") as String
|
||||||
|
if location_id == "":
|
||||||
|
location_id = await _ensure_install_location()
|
||||||
|
|
||||||
var queue_params := {
|
var queue_params := {
|
||||||
"game": game,
|
"game": game,
|
||||||
|
|
@ -637,6 +693,16 @@ func _install(game: Dictionary, cave_id: String) -> bool:
|
||||||
return success
|
return success
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the upload whose displayName or filename matches the user's pick
|
||||||
|
## from the install-options dropdown, or {} when nothing matches.
|
||||||
|
func _pick_upload(uploads: Array, chosen: String) -> Dictionary:
|
||||||
|
for u in uploads:
|
||||||
|
var upload: Dictionary = u
|
||||||
|
if upload.get("displayName", "") == chosen or upload.get("filename", "") == chosen:
|
||||||
|
return upload
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
## Uninstalls the given cave. Returns true on success.
|
## Uninstalls the given cave. Returns true on success.
|
||||||
func uninstall(cave_id: String) -> bool:
|
func uninstall(cave_id: String) -> bool:
|
||||||
return await thread_group.exec(_uninstall.bind(cave_id))
|
return await thread_group.exec(_uninstall.bind(cave_id))
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ var _active_item: LibraryLaunchItem
|
||||||
var _launch_watch := {}
|
var _launch_watch := {}
|
||||||
## pids already diagnosed, so a failed game is only reported once.
|
## pids already diagnosed, so a failed game is only reported once.
|
||||||
var _diagnosed_pids := {}
|
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:
|
func _ready() -> void:
|
||||||
|
|
@ -35,16 +38,121 @@ func _ready() -> void:
|
||||||
watch_timer.autostart = true
|
watch_timer.autostart = true
|
||||||
watch_timer.timeout.connect(_check_failed_launches)
|
watch_timer.timeout.connect(_check_failed_launches)
|
||||||
add_child(watch_timer)
|
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]:
|
func get_library_launch_items() -> Array[LibraryLaunchItem]:
|
||||||
return await _load_library(Cache.FLAGS.LOAD | Cache.FLAGS.SAVE)
|
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)
|
var game := (item.metadata.get("game", {}) as Dictionary)
|
||||||
_active_item = item
|
_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:
|
if success:
|
||||||
await _refresh_installed_item(item, game)
|
await _refresh_installed_item(item, game)
|
||||||
_active_item = null
|
_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))
|
logger.info("Install of '" + item.name + "' completed with status: " + str(success))
|
||||||
if success:
|
if success:
|
||||||
_refresh_library_menu(item)
|
_refresh_library_menu(item)
|
||||||
|
_refresh_update_flags.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
func update(item: LibraryLaunchItem) -> void:
|
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))
|
logger.info("Update of '" + item.name + "' completed with status: " + str(success))
|
||||||
if success:
|
if success:
|
||||||
_refresh_library_menu(item)
|
_refresh_library_menu(item)
|
||||||
|
_refresh_update_flags.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
## OpenGamepadUI's library menu doesn't re-render after an install or
|
## 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)
|
uninstall_completed.emit(item, success)
|
||||||
logger.info("Uninstall of '" + item.name + "' completed with status: " + str(success))
|
logger.info("Uninstall of '" + item.name + "' completed with status: " + str(success))
|
||||||
if success:
|
if success:
|
||||||
|
_update_flags.erase(item.provider_app_id)
|
||||||
_refresh_library_menu(item)
|
_refresh_library_menu(item)
|
||||||
_restore_launch_focus()
|
_restore_launch_focus()
|
||||||
|
|
||||||
|
|
||||||
## itch.io's CheckUpdate call is async (and rate-limited), so we don't poll it
|
## itch.io's update availability comes from butlerd's CheckUpdate call, which
|
||||||
## synchronously here. LibraryManager is expected to periodically call
|
## is async and hits the itch.io API, so it runs in the background and results
|
||||||
## get_library_launch_items() again, which re-fetches from butlerd's cache.
|
## 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:
|
func has_update(_item: LibraryLaunchItem) -> bool:
|
||||||
|
var cave_id: String = _item.provider_app_id
|
||||||
|
if cave_id == "":
|
||||||
return false
|
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:
|
func _on_logged_in(status: ItchClient.LOGIN_STATUS, _profile: Dictionary) -> void:
|
||||||
if status != ItchClient.LOGIN_STATUS.OK:
|
if status != ItchClient.LOGIN_STATUS.OK:
|
||||||
return
|
return
|
||||||
logger.info("Logged in. Refreshing itch.io library.")
|
logger.info("Logged in. Refreshing itch.io library.")
|
||||||
|
_refresh_update_flags.call_deferred()
|
||||||
var items: Array = await _load_library(Cache.FLAGS.SAVE)
|
var items: Array = await _load_library(Cache.FLAGS.SAVE)
|
||||||
for i in items:
|
for i in items:
|
||||||
var item: LibraryLaunchItem = i
|
var item: LibraryLaunchItem = i
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"plugin.id": "itch",
|
"plugin.id": "itch",
|
||||||
"plugin.name": "itch.io",
|
"plugin.name": "itch.io",
|
||||||
"plugin.version": "0.1.23",
|
"plugin.version": "0.1.24",
|
||||||
"plugin.min-api-version": "1.1.0",
|
"plugin.min-api-version": "1.1.0",
|
||||||
"plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
"plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
||||||
"plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
"plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-plugin",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue