From 590eaf793dc3e040d20fd446e33e87eb887911f1 Mon Sep 17 00:00:00 2001 From: William Edwards Date: Sat, 21 Dec 2024 20:37:07 -0800 Subject: [PATCH 1/3] fix(Steam): add better login persistence and update API version --- .gitignore | 1 + Makefile | 13 +- core/boxart_steam.gd.uid | 1 + core/library_steam.gd | 100 ++++++++++-- core/library_steam.gd.uid | 1 + core/steam_api_client.gd.uid | 1 + core/steam_client.gd | 289 +++++++++++++++++++++++++++++++++-- core/steam_client.gd.uid | 1 + core/steam_settings.gd | 8 - core/steam_settings.gd.uid | 1 + core/vdf.gd | 123 --------------- plugin.gd | 136 +++++++++++++---- plugin.gd.uid | 1 + plugin.json | 2 +- 14 files changed, 494 insertions(+), 184 deletions(-) create mode 100644 core/boxart_steam.gd.uid create mode 100644 core/library_steam.gd.uid create mode 100644 core/steam_api_client.gd.uid create mode 100644 core/steam_client.gd.uid create mode 100644 core/steam_settings.gd.uid delete mode 100644 core/vdf.gd create mode 100644 plugin.gd.uid diff --git a/.gitignore b/.gitignore index 69a1846..860eb67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .godot dist +settings.mk diff --git a/Makefile b/Makefile index 9ee4867..72d59e4 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,9 @@ PLUGINS_DIR := $(OPENGAMEPAD_UI_BASE)/plugins BUILD_DIR := $(OPENGAMEPAD_UI_BASE)/build INSTALL_DIR := $(HOME)/.local/share/opengamepadui/plugins +# Include any user defined settings +-include settings.mk + ##@ General # The help target prints out all targets with their descriptions organized @@ -34,7 +37,7 @@ dist: build ## Build and package plugin .PHONY: build build: $(PLUGINS_DIR)/$(PLUGIN_ID) export_preset ## Build the plugin @echo "Exporting plugin package" - cd $(OPENGAMEPAD_UI_BASE) && $(MAKE) addons + cd $(OPENGAMEPAD_UI_BASE) && $(MAKE) import mkdir -p dist touch dist/.gdignore $(GODOT) --headless \ @@ -49,6 +52,10 @@ install: dist ## Installs the plugin rm -rf $(INSTALL_DIR)/$(PLUGIN_ID) @echo "Installed plugin to $(INSTALL_DIR)" +.PHONY: edit +edit: $(PLUGINS_DIR)/$(PLUGIN_ID) ## Open the project in the Godot editor + cd $(OPENGAMEPAD_UI_BASE) && $(MAKE) edit + $(OPENGAMEPAD_UI_BASE): git clone $(OPENGAMEPAD_UI_REPO) $@ @@ -66,3 +73,7 @@ export_preset: $(OPENGAMEPAD_UI_BASE) ## Configure plugin export preset echo "Preset not configured"; \ sed 's/PRESET_NUM/$(PRESET_NUM)/g; s/PLUGIN_NAME/$(PLUGIN_NAME)/g; s/PLUGIN_ID/$(PLUGIN_ID)/g' export_presets.cfg >> $(EXPORT_PRESETS); \ fi + +.PHONY: deploy +deploy: dist + scp ./dist/steam.zip $(SSH_USER)@$(SSH_HOST):~/.local/share/opengamepadui/plugins diff --git a/core/boxart_steam.gd.uid b/core/boxart_steam.gd.uid new file mode 100644 index 0000000..701f6b6 --- /dev/null +++ b/core/boxart_steam.gd.uid @@ -0,0 +1 @@ +uid://m2xrrcmumt2v diff --git a/core/library_steam.gd b/core/library_steam.gd index 0e797bf..f7ee1d6 100644 --- a/core/library_steam.gd +++ b/core/library_steam.gd @@ -5,7 +5,6 @@ extends Library # Steam Overlay Config is in: # ~/.steam/steam/userdata//config/localconfig.vdf -const VDF = preload("res://plugins/steam/core/vdf.gd") const SteamClient := preload("res://plugins/steam/core/steam_client.gd") const SteamAPIClient := preload("res://plugins/steam/core/steam_api_client.gd") const _apps_cache_file: String = "apps.json" @@ -22,27 +21,102 @@ var libraryfolders_path := "/".join([OS.get_environment("HOME"), ".steam/steam/s func _ready() -> void: super() add_child(steam_api_client) + thread_pool.start() logger = Log.get_logger("Steam", Log.LEVEL.DEBUG) logger.info("Steam Library loaded") steam.logged_in.connect(_on_logged_in) -# Return a list of installed steam apps. Called by the LibraryManager. +## Return a list of installed steam apps. Called by the LibraryManager. func get_library_launch_items() -> Array[LibraryLaunchItem]: return await _load_library(Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -# Installs the given library item. +## Returns an array of available install locations for this library provider. +func get_available_install_locations(item: LibraryLaunchItem = null) -> Array[InstallLocation]: + var locations: Array[InstallLocation] = [] + + # Parse libraryfolder.vdf for any extra volumes that are available. + if not FileAccess.file_exists(libraryfolders_path): + logger.warn("The libraryfolders.vdf file was not found at: " + libraryfolders_path) + return locations + + var vdf_string := FileAccess.get_file_as_string(libraryfolders_path) + var vdf := Vdf.new() + if vdf.parse(vdf_string) != OK: + logger.debug("Error parsing vdf: " + vdf.get_error_message()) + return locations + var libraryfolders := vdf.get_data() + + if not "libraryfolders" in libraryfolders: + return locations + var app_ids := PackedStringArray() + var entries := libraryfolders["libraryfolders"] as Dictionary + for folder in entries.values(): + if not "path" in folder: + continue + var path := folder["path"] as String + if not DirAccess.dir_exists_absolute(path): + continue + var location := Library.InstallLocation.new() + location.id = path + location.name = path + location.total_space_mb = 0 # TODO: calculate space + location.free_space_mb = 0 + locations.append(location) + + return locations + + +## Returns an array of install options for the given [LibraryLaunchItem]. +## Install options are arbitrary and are provider-specific. They allow the user +## to select things like the language of a game to install, etc. +func get_install_options(item: LibraryLaunchItem) -> Array[InstallOption]: + var app_id := item.provider_app_id + var options: Array[InstallOption] = [] + + var platform_option := InstallOption.new() + platform_option.id = "os" + platform_option.name = "Platform" + platform_option.description = "Download the game for the given OS platform" + platform_option.values = ["windows"] + platform_option.value_type = TYPE_STRING + if await _app_supports_linux(app_id): + platform_option.values.append("linux") + if platform_option.values.size() > 1: + options.append(platform_option) + + return options + + +## Installs the given library item. [DEPRECATED] func install(item: LibraryLaunchItem) -> void: - # Start the install + install_to(item) + + +## Installs the given library item to the given location. +func install_to(item: LibraryLaunchItem, location: InstallLocation = null, options: Dictionary = {}) -> void: var app_id := item.provider_app_id - logger.info("Installing " + item.name + " with app ID: " + app_id) + var location_name := "default location" + var target_path := "" + if location: + location_name = location.name + target_path = location.name + logger.info("Installing " + item.name + " with app ID " + app_id + " to " + location_name + " with options: " + str(options)) + + # Wait if another app is installing + while true: + if not steam.is_app_installing: + break + logger.info("Another app is currently being installed. Waiting...") + await get_tree().create_timer(2.0).timeout + # Check if title supports Linux or Windows if await _app_supports_linux(app_id): await steam.set_platform_type("linux") else: await steam.set_platform_type("windows") - steam.install(app_id) + steam.install(app_id, target_path) # Connect to progress updates var on_progress := func(id: String, bytes_cur: int, bytes_total: int): @@ -69,7 +143,7 @@ func install(item: LibraryLaunchItem) -> void: steam.install_progressed.disconnect(on_progress) -# Updates the given library item. +## Updates the given library item. func update(item: LibraryLaunchItem) -> void: # Start the install var app_id := item.provider_app_id @@ -131,10 +205,10 @@ func _on_logged_in(status: SteamClient.LOGIN_STATUS): logger.info("Logged in. Updating library cache from Steam.") var cmd := func(): return await _load_library(Cache.FLAGS.SAVE) - var items: Array = await thread_pool.exec(cmd) + var items: Array = await thread_pool.exec(cmd, "load_steam_library") for i in items: var item: LibraryLaunchItem = i - if not LibraryManager.has_app(item.name): + if not library_manager.has_app(item.name): var msg := "App {0} was not loaded. Adding item".format([item.name]) logger.info(msg) launch_item_added.emit(item) @@ -247,10 +321,9 @@ func _load_local_library( logger.info("Parsing local Steam library...") var vdf_string := FileAccess.get_file_as_string(libraryfolders_path) - var vdf: VDF = VDF.new() + var vdf := Vdf.new() if vdf.parse(vdf_string) != OK: - var err_line := vdf.get_error_line() - logger.debug("Error parsing vdf output on line " + str(err_line) + ": " + vdf.get_error_message()) + logger.debug("Error parsing vdf: " + vdf.get_error_message()) return [] var libraryfolders := vdf.get_data() @@ -363,7 +436,8 @@ func _app_info_to_launch_item(info: Dictionary, is_installed: bool) -> LibraryLa item.provider_app_id = app_id item.name = data["name"] item.command = "steam" - item.args = ["-gamepadui", "-steamos3", "-steampal", "-steamdeck", "-silent", "steam://rungameid/" + app_id] + item.args = ["-gamepadui", "steam://rungameid/" + app_id] + #item.args = ["-silent", "steam://rungameid/" + app_id] item.categories = categories item.tags = ["steam"] item.tags.append_array(tags) diff --git a/core/library_steam.gd.uid b/core/library_steam.gd.uid new file mode 100644 index 0000000..0888691 --- /dev/null +++ b/core/library_steam.gd.uid @@ -0,0 +1 @@ +uid://cu13wjeapdbu2 diff --git a/core/steam_api_client.gd.uid b/core/steam_api_client.gd.uid new file mode 100644 index 0000000..5578e57 --- /dev/null +++ b/core/steam_api_client.gd.uid @@ -0,0 +1 @@ +uid://deov6uoyk7y8x diff --git a/core/steam_client.gd b/core/steam_client.gd index d6b055a..9cd642c 100644 --- a/core/steam_client.gd +++ b/core/steam_client.gd @@ -6,7 +6,6 @@ extends NodeThread ## [InteractiveProcess] to spawn steamcmd in a psuedo terminal to read and write ## to its stdout/stdin. -const VDF = preload("res://plugins/steam/core/vdf.gd") const steamcmd_url := "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" const CACHE_DIR := "steam" @@ -37,13 +36,18 @@ signal app_updated(app_id: String, success: bool) signal app_uninstalled(app_id: String, success: bool) signal install_progressed(app_id: String, current: int, total: int) -var steamcmd_dir := "/".join([OS.get_environment("HOME"), ".steam", "steamcmd"]) +var network_manager := load("res://core/systems/network/network_manager.tres") as NetworkManagerInstance +var steamcmd_dir := "/".join([OS.get_environment("HOME"), ".local", "share", "Steam"]) var steamcmd := "/".join([steamcmd_dir, "steamcmd.sh"]) +var vdf_local_path := "/".join([steamcmd_dir, "local.vdf"]) +var vdf_config_path := "/".join([steamcmd_dir, "config", "config.vdf"]) +var tokens_save_path := "/".join([steamcmd_dir, "config", "steamcmd-tokens.json"]) var steamcmd_stderr: FileAccess var proc: InteractiveProcess var state: STATE = STATE.BOOT var is_logged_in := false var client_started := false +var is_app_installing := false var cmd_queue: Array[String] = [] var current_cmd := "" @@ -61,11 +65,13 @@ func _ready() -> void: # Bootstraps steamcmd if not found, and starts it up func bootstrap() -> void: + # Wait for an active internet connection + await wait_for_network() + # Download and install steamcmd if not found if not FileAccess.file_exists(steamcmd): logger.info("The steamcmd binary wasn't found. Trying to install it.") - var success = await install_steamcmd() - if success == false: + if not await install_steamcmd(): logger.error("Unable to install steamcmd") bootstrap_finished.emit() return @@ -80,6 +86,17 @@ func bootstrap() -> void: bootstrap_finished.emit() +## Waits until the local machine can resolve valvesoftware.com +func wait_for_network() -> void: + var network_state := network_manager.NM_STATE_UNKNOWN + while network_state != network_manager.NM_STATE_CONNECTED_GLOBAL: + logger.debug("Waiting for network connection...") + network_state = network_manager.state + await get_tree().create_timer(5.0).timeout + + logger.debug("Connected to the internet") + + ## Download steamcmd to the user directory func install_steamcmd() -> bool: # Build the request @@ -114,6 +131,22 @@ func install_steamcmd() -> bool: var out := [] OS.execute("tar", ["xvfz", "/tmp/steamcmd_linux.tar.gz", "-C", steamcmd_dir], out) + # Check if ~/Steam exists + var home := DirAccess.open(OS.get_environment("HOME")) + if not home: + logger.error("Home directory not found, wtf") + return false + if home.dir_exists("Steam") and not home.is_link("Steam"): + logger.warn("steamcmd data folder already exists, but is not a symlink") + logger.warn("Backing up ~/Steam to ~/Steam.bak...") + if home.rename("Steam", "Steam.bak") != OK: + logger.warn("Failed to back up ~/Steam to ~/Steam.bak") + return false + if not home.dir_exists("Steam"): + logger.info("Creating symlink for steamcmd") + if home.create_link(steamcmd_dir, "Steam") != OK: + logger.error("Failed to create symlink for steamcmd") + return true @@ -180,6 +213,232 @@ func _login(user: String, password := "", tfa := "") -> void: emit_signal.call_deferred("logged_in", login_status) +## Copies the current steamcmd login token(s) from $STEAM_ROOT/config/config.vdf +## to $STEAM_ROOT/config/steamcmd-tokens.json +func save_steamcmd_session() -> void: + logger.info("Saving steamcmd login session") + if not FileAccess.file_exists(vdf_config_path): + logger.warn("config.vdf does not exist at:", vdf_config_path, ". Unable to save login session.") + return + var config_vdf := FileAccess.get_file_as_string(vdf_config_path) + if config_vdf.is_empty(): + logger.warn("config.vdf at", vdf_config_path, "is empty. Unable to save login session.") + return + var vdf := Vdf.new() + if vdf.parse(config_vdf) != OK: + logger.warn("Failed to parse", vdf_config_path, ":", vdf.get_error_message()) + return + logger.trace("Successfully parsed config:", vdf.data) + + # Validate the structure. Sometimes these keys can be in lowercase or uppercase. + var config_key := "InstallConfigStore" + if "installconfigstore" in vdf.data: + config_key = "installconfigstore" + if not config_key in vdf.data: + logger.warn("Failed to find key 'InstallConfigStore' in", vdf_config_path) + return + var software_key := "Software" + if "software" in vdf.data[config_key]: + software_key = "software" + if not software_key in vdf.data[config_key]: + logger.warn("Failed to find key 'Software' in", vdf_config_path) + return + var valve_key := "Valve" + if "valve" in vdf.data[config_key][software_key]: + valve_key = "valve" + if not valve_key in vdf.data[config_key][software_key]: + logger.warn("Failed to find key 'Valve' in", vdf_config_path) + return + var steam_key := "Steam" + if "steam" in vdf.data[config_key][software_key][valve_key]: + steam_key = "steam" + if not steam_key in vdf.data[config_key][software_key][valve_key]: + logger.warn("Failed to find key 'Steam' in", vdf_config_path) + return + var cache_key := "ConnectCache" + if "connectcache" in vdf.data[config_key][software_key][valve_key][steam_key]: + cache_key = "connectcache" + if not cache_key in vdf.data[config_key][software_key][valve_key][steam_key]: + logger.warn("Failed to find key 'ConnectCache' in", vdf_config_path) + return + var tokens := vdf.data[config_key][software_key][valve_key][steam_key][cache_key] as Dictionary + if tokens.is_empty(): + logger.warn("No login sessions found in", vdf_config_path) + return + + # Store the tokens as JSON + var json_data := JSON.stringify(tokens) + var file := FileAccess.open(tokens_save_path, FileAccess.WRITE_READ) + if not file: + logger.warn("Unable to open", tokens_save_path, "to save login session.") + return + file.store_string(json_data) + logger.debug("Successfully saved steamcmd session to", tokens_save_path) + + +## Configures Steam to enable proton for all games +func enable_proton() -> void: + if not FileAccess.file_exists(vdf_config_path): + logger.warn("config.vdf does not exist at:", vdf_config_path, ". Unable to enable proton.") + return + var config_vdf := FileAccess.get_file_as_string(vdf_config_path) + if config_vdf.is_empty(): + logger.warn("config.vdf at", vdf_config_path, "is empty. Unable to enable proton.") + return + var vdf := Vdf.new() + if vdf.parse(config_vdf) != OK: + logger.warn("Failed to parse", vdf_config_path, ":", vdf.get_error_message()) + return + logger.trace("Successfully parsed config:", vdf.data) + + # Validate the structure. Sometimes these keys can be in lowercase or uppercase. + var config_key := "InstallConfigStore" + if "installconfigstore" in vdf.data: + config_key = "installconfigstore" + if not config_key in vdf.data: + logger.warn("Failed to find key 'InstallConfigStore' in", vdf_config_path) + return + var software_key := "Software" + if "software" in vdf.data[config_key]: + software_key = "software" + if not software_key in vdf.data[config_key]: + logger.warn("Failed to find key 'Software' in", vdf_config_path) + return + var valve_key := "Valve" + if "valve" in vdf.data[config_key][software_key]: + valve_key = "valve" + if not valve_key in vdf.data[config_key][software_key]: + logger.warn("Failed to find key 'Valve' in", vdf_config_path) + return + var steam_key := "Steam" + if "steam" in vdf.data[config_key][software_key][valve_key]: + steam_key = "steam" + if not steam_key in vdf.data[config_key][software_key][valve_key]: + logger.warn("Failed to find key 'Steam' in", vdf_config_path) + return + + # Check to see if the setting is already enabled + var compat_key := "CompatToolMapping" + if "compattoolmapping" in vdf.data[config_key][software_key][valve_key][steam_key]: + compat_key = "compattoolmapping" + var compat_mapping := {} + if compat_key in vdf.data[config_key][software_key][valve_key][steam_key]: + logger.debug("CompatToolMapping exists in config.vdf") + if "0" in vdf.data[config_key][software_key][valve_key][steam_key][compat_key]: + logger.debug("Proton already enabled") + return + compat_mapping = vdf.data[config_key][software_key][valve_key][steam_key][compat_key] + + # Add the entry to enable proton for all games + compat_mapping["0"] = { + "name": "proton_experimental", + "config": "", + "priority": "75", + } + var data := vdf.data + data[config_key][software_key][valve_key][steam_key][compat_key] = compat_mapping + + # Save the config + logger.info("Enabling proton compatibility tool for all games") + var serialized := Vdf.stringify(data) + if serialized.is_empty(): + logger.warn("Failed to enable proton. Unable to serialize config.vdf") + return + var file := FileAccess.open(vdf_config_path, FileAccess.WRITE) + file.store_string(serialized) + + +## Returns true if a previous steamcmd login session has been saved +func has_steamcmd_session() -> bool: + return FileAccess.file_exists(tokens_save_path) + + +## Restores the saved login session +func restore_steamcmd_session() -> int: + logger.info("Restoring steamcmd login session") + + # Load the saved session + if not has_steamcmd_session(): + logger.error("No saved steamcmd session exists. Unable to restore session.") + return ERR_DOES_NOT_EXIST + var session_data := FileAccess.get_file_as_string(tokens_save_path) + var json := JSON.new() + if json.parse(session_data) != OK: + logger.error("Failed to parse session data from", tokens_save_path, "with error:", json.get_error_message()) + return ERR_PARSE_ERROR + if not json.data is Dictionary: + logger.error("Failed to get session data from", tokens_save_path, ". Data is not a Dictionary.") + return ERR_PARSE_ERROR + var session := json.data as Dictionary + + # Load Steam's config.vdf + if not FileAccess.file_exists(vdf_config_path): + logger.warn("config.vdf does not exist at:", vdf_config_path, ". Unable to restore login session.") + return ERR_DOES_NOT_EXIST + var config_vdf := FileAccess.get_file_as_string(vdf_config_path) + if config_vdf.is_empty(): + logger.warn("config.vdf at", vdf_config_path, "is empty. Unable to restore login session.") + return ERR_PARSE_ERROR + var vdf := Vdf.new() + if vdf.parse(config_vdf) != OK: + logger.warn("Failed to parse", vdf_config_path, ":", vdf.get_error_message()) + return ERR_PARSE_ERROR + logger.trace("Successfully parsed config:", vdf.data) + + # Validate the structure. Sometimes these keys can be in lowercase or uppercase. + var config_key := "InstallConfigStore" + if "installconfigstore" in vdf.data: + config_key = "installconfigstore" + if not config_key in vdf.data: + logger.warn("Failed to find key 'InstallConfigStore' in", vdf_config_path) + return ERR_PARSE_ERROR + var software_key := "Software" + if "software" in vdf.data[config_key]: + software_key = "software" + if not software_key in vdf.data[config_key]: + logger.warn("Failed to find key 'Software' in", vdf_config_path) + return ERR_PARSE_ERROR + var valve_key := "Valve" + if "valve" in vdf.data[config_key][software_key]: + valve_key = "valve" + if not valve_key in vdf.data[config_key][software_key]: + logger.warn("Failed to find key 'Valve' in", vdf_config_path) + return ERR_PARSE_ERROR + var steam_key := "Steam" + if "steam" in vdf.data[config_key][software_key][valve_key]: + steam_key = "steam" + if not steam_key in vdf.data[config_key][software_key][valve_key]: + logger.warn("Failed to find key 'Steam' in", vdf_config_path) + return ERR_PARSE_ERROR + var cache_key := "ConnectCache" + if "connectcache" in vdf.data[config_key][software_key][valve_key][steam_key]: + cache_key = "connectcache" + if not cache_key in vdf.data[config_key][software_key][valve_key][steam_key]: + logger.warn("Failed to find key 'ConnectCache' in", vdf_config_path) + return ERR_PARSE_ERROR + + # Update the connection cache with the saved session + vdf.data[config_key][software_key][valve_key][steam_key][cache_key] = session + + # Serialize the dictionary back into VDF + var config_data := Vdf.stringify(vdf.data) + if config_data.is_empty(): + logger.warn("Failed to serialize config.vdf") + return ERR_INVALID_DATA + + # Save the updated config + var config_file := FileAccess.open(vdf_config_path, FileAccess.WRITE_READ) + config_file.store_string(config_data) + + return OK + + +## Returns true if the Steam client has been detected to have launched before. +func has_steam_run() -> bool: + var folder_to_check := "/".join([steamcmd_dir, "ubuntu12_64"]) + return DirAccess.dir_exists_absolute(folder_to_check) + + ## Log the user out of Steam func logout() -> void: await thread_group.exec(_logout) @@ -334,10 +593,9 @@ func _get_app_info(app_id: String, cache_flags: int = Cache.FLAGS.LOAD | Cache.F break # Parse the VDF output - var vdf: VDF = VDF.new() + var vdf := Vdf.new() if vdf.parse(vdf_string) != OK: - var err_line := vdf.get_error_line() - logger.debug("Error parsing vdf output on line " + str(err_line) + ": " + vdf.get_error_message()) + logger.debug("Error parsing vdf: " + vdf.get_error_message()) return {} var app_info := vdf.get_data() @@ -351,12 +609,12 @@ func _get_app_info(app_id: String, cache_flags: int = Cache.FLAGS.LOAD | Cache.F ## Install the given app. This will emit the 'install_progressed' signal to ## show install progress and emit the 'app_installed' signal with the status ## of the installation. -func install(app_id: String) -> void: - await thread_group.exec(_install.bind(app_id)) +func install(app_id: String, path: String = "") -> void: + await thread_group.exec(_install.bind(app_id, path)) -func _install(app_id: String) -> void: - var success := await _install_update(app_id) +func _install(app_id: String, path: String = "") -> void: + var success := await _install_update(app_id, path) #app_installed.emit(app_id, success) emit_signal.call_deferred("app_installed", app_id, success) @@ -375,7 +633,13 @@ func _update(app_id: String) -> void: # Shared functionality between app install and app update -func _install_update(app_id: String) -> bool: +func _install_update(app_id: String, path: String = "") -> bool: + is_app_installing = true + + # TODO: FIXME + #if not path.is_empty(): + # var lines := await _wait_for_command("force_install_dir " + path + "\n") + var cmd := "app_update " + app_id + "\n" _queue_command(cmd) var success := [] # Needs to be an array to be updated from lambda @@ -403,6 +667,7 @@ func _install_update(app_id: String) -> bool: await _follow_command(cmd, on_progress) logger.info("Install finished with success: " + str(true in success)) + is_app_installing = false return true in success diff --git a/core/steam_client.gd.uid b/core/steam_client.gd.uid new file mode 100644 index 0000000..b1b055c --- /dev/null +++ b/core/steam_client.gd.uid @@ -0,0 +1 @@ +uid://kdgpkosh1j7j diff --git a/core/steam_settings.gd b/core/steam_settings.gd index 3b58ff6..ab2a49e 100644 --- a/core/steam_settings.gd +++ b/core/steam_settings.gd @@ -75,10 +75,6 @@ func _on_login(login_status: SteamClient.LOGIN_STATUS) -> void: tfa_box.visible = true tfa_box.grab_focus.call_deferred() - var notify := Notification.new("Two-factor authentication required") - notify.icon = icon - notification_manager.show(notify) - return # If we logged, woo! @@ -86,10 +82,6 @@ func _on_login(login_status: SteamClient.LOGIN_STATUS) -> void: logged_in_status.status = logged_in_status.STATUS.CLOSED logged_in_status.color = "green" - var notify := Notification.new("Successfully logged in to Steam") - notify.icon = icon - notification_manager.show(notify) - return diff --git a/core/steam_settings.gd.uid b/core/steam_settings.gd.uid new file mode 100644 index 0000000..dc547a9 --- /dev/null +++ b/core/steam_settings.gd.uid @@ -0,0 +1 @@ +uid://dhuah8i5c5mfy diff --git a/core/vdf.gd b/core/vdf.gd deleted file mode 100644 index 7f3912d..0000000 --- a/core/vdf.gd +++ /dev/null @@ -1,123 +0,0 @@ -extends Resource - -## VDF Parsing -## -## References: -## https://github.com/ValvePython/vdf/blob/master/vdf/__init__.py -## https://developer.valvesoftware.com/wiki/KeyValues - -enum { - OK, - ERR_CANT_COMPILE_REGEX, - ERR_EXPECTED_BRACKET, - ERR_TOO_MANY_CLOSING_BRACKETS, - ERR_UNEXPECTED_EOF, -} - -var _stack := [{}] -var _expect_bracket := false -var _regex_kv = RegEx.new() -var _pattern = ( - '^("(?P(?:\\\\.|[^\\\\"])*)"|(?P#?[a-z0-9\\-\\_\\\\\\?$%<>]+))' - + "([ \\t]*(" - + '"(?P(?:\\\\.|[^\\\\"])*)(?P")?' - + "|(?P(?:(? ])+)" - + "|(?P{[ \\t]*)(?P})?" - + "))?" -) -var _err := OK -var _err_line := -1 - - -func get_error_line() -> int: - return _err_line - - -func get_error_message() -> String: - if _err == ERR_CANT_COMPILE_REGEX: - return "Can't compile regex pattern to match" - if _err == ERR_EXPECTED_BRACKET: - return "Expected an opening bracket" - if _err == ERR_TOO_MANY_CLOSING_BRACKETS: - return "One too many closing brackets" - if _err == ERR_UNEXPECTED_EOF: - return "Unexpected EOF (open key quote?)" - return "" - - -func get_data() -> Dictionary: - if _stack.size() == 0: - return {} - return _stack[0] - - -func parse(data: String) -> int: - if _regex_kv.compile(_pattern) != OK: - return ERR_CANT_COMPILE_REGEX - - var lines := data.split("\n") - for lineno in range(0, lines.size()): - var line := lines[lineno] - line = line.strip_edges() - - # Skip empty and comment lines - if line == "" or line.begins_with("/"): - continue - - # One level deeper - if line.begins_with("{"): - _expect_bracket = false - continue - - if _expect_bracket: - _err_line = lineno - _err = ERR_EXPECTED_BRACKET - return _err - - # One level back - if line.begins_with("}"): - if _stack.size() > 1: - _stack.pop_back() - continue - _err_line = lineno - _err = ERR_TOO_MANY_CLOSING_BRACKETS - return _err - - # Parse key/value pairs - var result := _regex_kv.search(line) - if not result: - _err_line = lineno - _err = ERR_UNEXPECTED_EOF - return _err - - var key := result.get_string("qkey") - if key == "": - key = result.get_string("key") - var val := result.get_string("qval") - if val == "": - val = result.get_string("val") - if val != "": - val = val.strip_edges(false, true) - - # Find the number of words on the line - var words := line.replace("\t", " ").split(" ", false) - var num_words := words.size() - - # We have a key without a value means we go a level deeper - if val == "" and num_words == 1: - var d := {} - _stack[-1][key] = d - - # Only expect a bracket if it's not already closed or - # on the same line - if result.get_string("eblock") == "": - _stack.push_back(d) - if result.get_string("sblock") == "": - _expect_bracket = true - continue - - # We've matched a simple key/value pair, map it to the last dict - # in the stack - _stack[-1][key] = val - - return OK diff --git a/plugin.gd b/plugin.gd index 10a2a87..125eb3e 100644 --- a/plugin.gd +++ b/plugin.gd @@ -4,6 +4,7 @@ const SteamClient := preload("res://plugins/steam/core/steam_client.gd") var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager var notification_manager := load("res://core/global/notification_manager.tres") as NotificationManager +var launch_manager := load("res://core/global/launch_manager.tres") as LaunchManager var settings_menu := load("res://plugins/steam/core/steam_settings.tscn") as PackedScene var icon := preload("res://plugins/steam/assets/steam.svg") @@ -38,52 +39,135 @@ func _ready() -> void: # Triggers when Steam has started func _on_client_start(): # Ensure the client was started - if not steam.client_started: - var notify := Notification.new("Unable to start steam client") - notify.icon = icon - logger.error(notify.text) - notification_manager.show(notify) + if steam.client_started: return + _ui_notification("Unable to start steam client") # Triggers when the Steam Client is ready func _on_client_ready(): # Check our settings to see if we have logged in before - if user == "": - var notify := Notification.new("Steam login required") - notify.icon = icon - logger.info(notify.text) - notification_manager.show(notify) + if user == "" or not steam.has_steamcmd_session(): + _ui_notification("Steam login required") return # If we have logged in before, try logging in with saved credentials - steam.login(user) + if steam.has_steamcmd_session(): + logger.info("Previous session exists. Trying to log in with existing session.") + if steam.restore_steamcmd_session() != OK: + logger.error("Failed to restore previous steam session") + return + logger.info("Session restored successfully") + steam.login(user) # Triggers when the Steam Client is logged in func _on_client_logged_in(status: SteamClient.LOGIN_STATUS): - var notify := Notification.new("") - notify.icon = icon - if status == SteamClient.LOGIN_STATUS.OK: - notify.text = "Successfully logged in to Steam" - logger.info(notify.text) - return + # After successful login, save the credentials so they can be restored if needed + steam.save_steamcmd_session() + steam.enable_proton() + _ui_notification("Successfully logged in to Steam") + await _ensure_tools_installed() + if not steam.has_steam_run(): + _on_first_boot.call_deferred() if status == SteamClient.LOGIN_STATUS.INVALID_PASSWORD: - notify.text = "Steam login required" - logger.info(notify.text) - return + _ui_notification("Steam login required") if status == SteamClient.LOGIN_STATUS.TFA_REQUIRED: - notify.text = "SteamGuard code required" - logger.info(notify.text) - return + _ui_notification("SteamGuard code required") if status == SteamClient.LOGIN_STATUS.FAILED: - notify.text = "Failed to login to Steam" - logger.info(notify.text) - return + _ui_notification("Failed to login to Steam") + + +## Ensure tool dependencies are installed +func _ensure_tools_installed() -> void: + # Get list of currently installed apps + logger.debug("Fetching installed apps") + var installed_apps := await steam.get_installed_apps() + var installed_app_ids := [] + for app in installed_apps: + if not "id" in app: + continue + installed_app_ids.push_back(app["id"]) + + # Linux Runtime + const SNIPER_APP_ID := "1628350" + if not SNIPER_APP_ID in installed_app_ids: + _ui_notification("Installing Sniper Linux Runtime") + var completed := await _install_tool(SNIPER_APP_ID) as bool + if not completed: + _ui_notification("Failed to install Sniper Linux Runtime") + + # Proton + const PROTON_APP_ID := "1493710" + if not PROTON_APP_ID in installed_app_ids: + _ui_notification("Installing Proton Experimental") + var completed := await _install_tool(PROTON_APP_ID) as bool + if not completed: + _ui_notification("Failed to install Proton Experimental") + + # Common Redistributables + const REDIST_APP_ID := "228980" + if not REDIST_APP_ID in installed_app_ids: + _ui_notification("Installing Steamworks Common Redistributables") + var completed := await _install_tool(REDIST_APP_ID) as bool + if not completed: + _ui_notification("Failed to install Steamworks Common Redistributables") + + +## Triggers if Steam has never been run on the system +func _on_first_boot() -> void: + var display := OS.get_environment("DISPLAY") + if display.is_empty(): + display = ":0" + var home := OS.get_environment("HOME") + + # Launch steam in a pty + var pty := Pty.new() + var cmd := "env" + var args := ["-i", "HOME=" + home, "DISPLAY=" + display, "steam", "-silent"] + pty.exec(cmd, PackedStringArray(args)) + add_child(pty) + + # Read the output from the command + var on_output := func(line: String): + logger.info("STEAM BOOTSTRAP:", line) + if line.contains("Update complete, launching"): + pty.kill() + pty.line_written.connect(on_output) + + # Wait until the command has finished + await pty.finished + remove_child(pty) + pty.queue_free() + logger.info("STEAM BOOTSTRAP: finished") + + +## Installs the given tool +func _install_tool(app_id: String) -> bool: + # Start installing + steam.install(app_id) + + # Wait for the app_installed signal + var success := false + var installed_app := "" + while installed_app != app_id: + var results = await steam.app_installed + installed_app = results[0] + success = results[1] + logger.info("Install of tool " + app_id + " completed with status: " + str(success)) + return success + + +## Show a UI notification with the given message +func _ui_notification(msg: String) -> void: + logger.info(msg) + var notify := Notification.new(msg) + notify.icon = icon + notification_manager.show(notify) # Return the settings menu scene diff --git a/plugin.gd.uid b/plugin.gd.uid new file mode 100644 index 0000000..2f1aa7c --- /dev/null +++ b/plugin.gd.uid @@ -0,0 +1 @@ +uid://c7uf0362l78np diff --git a/plugin.json b/plugin.json index 5039bd9..3baa414 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "plugin.id": "steam", "plugin.name": "Steam", "plugin.version": "1.2.0", - "plugin.min-api-version": "1.0.0", + "plugin.min-api-version": "1.1.0", "plugin.link": "https://github.com/ShadowBlip/OpenGamepadUI-steam", "plugin.source": "https://github.com/ShadowBlip/OpenGamepadUI-steam", "plugin.description": "Steam library for OpenGamepadUI", From 04040df1362e047130c13bcf001464ed294120f6 Mon Sep 17 00:00:00 2001 From: William Edwards Date: Sun, 29 Jun 2025 02:23:24 -0700 Subject: [PATCH 2/3] fix(Steam): add lifecycle hooks, better OOTBE, and remove thread pool --- core/library_steam.gd | 127 +++++++++++++- core/steam_client.gd | 347 +++++++++++++++++++++++++-------------- core/steam_settings.gd | 12 ++ core/steam_settings.tscn | 2 +- plugin.gd | 19 ++- 5 files changed, 367 insertions(+), 140 deletions(-) diff --git a/core/library_steam.gd b/core/library_steam.gd index f7ee1d6..c831436 100644 --- a/core/library_steam.gd +++ b/core/library_steam.gd @@ -10,7 +10,6 @@ const SteamAPIClient := preload("res://plugins/steam/core/steam_api_client.gd") const _apps_cache_file: String = "apps.json" const _local_apps_cache_file: String = "local_apps.json" -var thread_pool := load("res://core/systems/threading/thread_pool.tres") as ThreadPool var steam_api_client := SteamAPIClient.new() var libraryfolders_path := "/".join([OS.get_environment("HOME"), ".steam/steam/steamapps/libraryfolders.vdf"]) @@ -21,7 +20,6 @@ var libraryfolders_path := "/".join([OS.get_environment("HOME"), ".steam/steam/s func _ready() -> void: super() add_child(steam_api_client) - thread_pool.start() logger = Log.get_logger("Steam", Log.LEVEL.DEBUG) logger.info("Steam Library loaded") steam.logged_in.connect(_on_logged_in) @@ -195,6 +193,18 @@ func has_update(item: LibraryLaunchItem) -> bool: return false +## App lifecycle hooks are used to execute some logic before, during, or after +## launch, such as compiling shaders before launch or starting/stopping a service. +func get_app_lifecycle_hooks() -> Array[AppLifecycleHook]: + var hooks: Array[AppLifecycleHook] = [ + PreLaunchHook.new(steam), + ExitHook.new(steam), + ] + logger.info("Returning lifecycle hooks:", hooks) + + return hooks + + # Re-load our library when we've logged in func _on_logged_in(status: SteamClient.LOGIN_STATUS): if status != SteamClient.LOGIN_STATUS.OK: @@ -203,9 +213,7 @@ func _on_logged_in(status: SteamClient.LOGIN_STATUS): # Upon login, fetch the user's library without loading it from cache and # reconcile it with the library manager. logger.info("Logged in. Updating library cache from Steam.") - var cmd := func(): - return await _load_library(Cache.FLAGS.SAVE) - var items: Array = await thread_pool.exec(cmd, "load_steam_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): @@ -254,17 +262,20 @@ func _load_library( # Get all available apps var app_ids: PackedInt64Array = await get_available_apps() + logger.debug("Found app IDs:", app_ids) # Get installed apps var apps_installed: Array = await steam.get_installed_apps() var app_ids_installed := PackedStringArray() for app in apps_installed: app_ids_installed.append(app["id"]) + logger.debug("Found installed app ids:", app_ids_installed) # Get the app info for each discovered game and create a launch item for # it. var items := [] as Array[LibraryLaunchItem] for app_id in app_ids: + logger.debug("Fetching app info for", app_id) var id := str(app_id) var info := await get_app_info(id, caching_flags) @@ -436,8 +447,8 @@ func _app_info_to_launch_item(info: Dictionary, is_installed: bool) -> LibraryLa item.provider_app_id = app_id item.name = data["name"] item.command = "steam" + #item.args = ["-gamepadui", "-steamos3", "-steampal", "-steamdeck", "-silent", "steam://rungameid/" + app_id] item.args = ["-gamepadui", "steam://rungameid/" + app_id] - #item.args = ["-silent", "steam://rungameid/" + app_id] item.categories = categories item.tags = ["steam"] item.tags.append_array(tags) @@ -459,3 +470,107 @@ func _app_supports_linux(app_id: String) -> bool: return false return info[app_id]["data"]["platform"]["linux"] + + +## Hook to execute before app launch. This hook will try to ensure the app is +## up-to-date before starting and will update the text in the game loading menu. +## TODO: Also include shader compilation +class PreLaunchHook extends AppLifecycleHook: + var _steam: SteamClient + var logger: Logger + + func _init(steam: SteamClient) -> void: + _hook_type = AppLifecycleHook.TYPE.PRE_LAUNCH + _steam = steam + logger = Log.get_logger("Steam") + + func get_name() -> String: + return "EnsureUpdated" + + func execute(item: LibraryLaunchItem) -> void: + # Ensure the app is up-to-date + await self.ensure_app_updated(item) + + # Set the startup movie to use so it's less obnoxious + await self.set_steam_startup_video() + + logger.info("Starting app") + self.notified.emit("Starting Steam...") + + func ensure_app_updated(item: LibraryLaunchItem) -> void: + var network_manager := load("res://core/systems/network/network_manager.tres") as NetworkManagerInstance + if network_manager.connectivity <= network_manager.NM_CONNECTIVITY_NONE: + return + if not item.provider_app_id.is_valid_int(): + return + logger.info("Updating app before launch") + self.notified.emit("Updating app...") + await _steam.update(item.provider_app_id) + + func set_steam_startup_video() -> void: + var steam_path := _steam.steamcmd_dir + var source_path := "/".join([steam_path, "steamui/movies/steam_os_suspend.webm"]) + var override_dir := "/".join([steam_path, "config/uioverrides/movies"]) + var override_path := "/".join([override_dir, "bigpicture_startup.webm"]) + + if not FileAccess.file_exists(source_path): + logger.debug("No source startup video found:", source_path) + logger.debug("Skipping setting steam startup video") + return + + # Create the override directory if it does not exist + if not DirAccess.dir_exists_absolute(override_dir): + if DirAccess.make_dir_recursive_absolute(override_dir) != OK: + logger.warn("Failed to create startup video override directory") + return + + # If a video override already exists, back it up + if FileAccess.file_exists(override_path): + logger.debug("Steam startup video override already exists. Backing up...") + var err := DirAccess.rename_absolute(override_path, override_path + ".ogui_backup") + if err != OK: + logger.warn("Failed to backup existing steam startup video") + return + + # Copy the source video to the override path so that video will be played + # instead of the normal bigpicture video. + logger.debug("Setting Steam startup video to:", source_path) + var cmd := Command.create("cp", [source_path, override_path]) + if await cmd.execute() != OK: + logger.warn("Failed to set steam startup video:", cmd.stderr) + return + + +## Hook to execute when the app exits. This will try to restore the startup video. +class ExitHook extends AppLifecycleHook: + var _steam: SteamClient + var logger: Logger + + func _init(steam: SteamClient) -> void: + _hook_type = AppLifecycleHook.TYPE.EXIT + _steam = steam + logger = Log.get_logger("Steam") + + func get_name() -> String: + return "RestoreStartupVideo" + + func execute(_item: LibraryLaunchItem) -> void: + await self.restore_steam_startup_video() + + func restore_steam_startup_video() -> void: + var steam_path := _steam.steamcmd_dir + var override_path := "/".join([steam_path, "config/uioverrides/movies/bigpicture_startup.webm"]) + if not FileAccess.file_exists(override_path): + return + + logger.debug("Removing steam startup video override") + if DirAccess.remove_absolute(override_path) != OK: + logger.warn("Unable to remove steam startup video override") + + if FileAccess.file_exists(override_path + ".ogui_backup"): + logger.debug("Restoring steam startup video override") + DirAccess.rename_absolute(override_path + ".ogui_backup", override_path) + + func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE: + self.restore_steam_startup_video() diff --git a/core/steam_client.gd b/core/steam_client.gd index 9cd642c..b395dc9 100644 --- a/core/steam_client.gd +++ b/core/steam_client.gd @@ -1,4 +1,4 @@ -extends NodeThread +extends Node ## Godot interface for steamcmd ## @@ -20,6 +20,7 @@ enum LOGIN_STATUS { FAILED, INVALID_PASSWORD, TFA_REQUIRED, + WAITING_GUARD_CONFIRM, } # Steam thread signals @@ -36,15 +37,19 @@ signal app_updated(app_id: String, success: bool) signal app_uninstalled(app_id: String, success: bool) signal install_progressed(app_id: String, current: int, total: int) +var platform := load("res://core/global/platform.tres") as Platform var network_manager := load("res://core/systems/network/network_manager.tres") as NetworkManagerInstance var steamcmd_dir := "/".join([OS.get_environment("HOME"), ".local", "share", "Steam"]) var steamcmd := "/".join([steamcmd_dir, "steamcmd.sh"]) var vdf_local_path := "/".join([steamcmd_dir, "local.vdf"]) var vdf_config_path := "/".join([steamcmd_dir, "config", "config.vdf"]) +var vdf_loginusers_path := "/".join([steamcmd_dir, "config", "loginusers.vdf"]) var tokens_save_path := "/".join([steamcmd_dir, "config", "steamcmd-tokens.json"]) +var package_path := "/".join([steamcmd_dir, "package"]) var steamcmd_stderr: FileAccess -var proc: InteractiveProcess +var pty: Pty var state: STATE = STATE.BOOT +var logged_in_user := "" var is_logged_in := false var client_started := false var is_app_installing := false @@ -53,13 +58,12 @@ var cmd_queue: Array[String] = [] var current_cmd := "" var current_output: Array[String] = [] -var logger := Log.get_logger("SteamClient", Log.LEVEL.INFO) +var logger := Log.get_logger("SteamClient", Log.LEVEL.DEBUG) func _ready() -> void: add_to_group("steam_client") - thread_group = SharedThread.new() - thread_group.name = "SteamClient" + prompt_available.connect(_process_command_queue) bootstrap() @@ -67,7 +71,14 @@ func _ready() -> void: func bootstrap() -> void: # Wait for an active internet connection await wait_for_network() - + + # Ensure the client is part of the Steam Deck beta + var beta_file := "/".join([package_path, "beta"]) + if not FileAccess.file_exists(beta_file): + DirAccess.make_dir_recursive_absolute(package_path) + var f := FileAccess.open(beta_file, FileAccess.WRITE) + f.store_string("steamdeck_publicbeta") + # Download and install steamcmd if not found if not FileAccess.file_exists(steamcmd): logger.info("The steamcmd binary wasn't found. Trying to install it.") @@ -78,14 +89,45 @@ func bootstrap() -> void: logger.info("Successfully installed steamcmd") # Start steamcmd - proc = InteractiveProcess.new(steamcmd, ["+@ShutdownOnFailedCommand", "0"]) - if proc.start() != OK: - logger.error("Unable to spawn steamcmd") - return + _spawn_steamcmd() + client_started = true bootstrap_finished.emit() +## Spawn steamcmd inside a PTY +func _spawn_steamcmd() -> void: + if pty: + pty.queue_free() + pty = null + var cmd := steamcmd + var args := PackedStringArray(["+@ShutdownOnFailedCommand", "0"]) + + # Check to see if this OS requires running the command through a binary + # compatibility tool. + if platform and platform.os: + var compatibility_cmd := platform.os.get_binary_compatibility_cmd(cmd, args) + if not compatibility_cmd.is_empty(): + logger.debug("Using binary compatibility tool") + cmd = compatibility_cmd.pop_front() + args = PackedStringArray(compatibility_cmd) + + pty = Pty.new() + pty.line_written.connect(_on_line_written) + if pty.exec(cmd, args) != OK: + logger.error("Unable to spawn steamcmd") + return + add_child(pty) + + # Re-spawn steamcmd if it stops + var on_finished := func(exit_code: int): + logger.info("Steamcmd exited with code", exit_code, ". Respawning.") + pty.queue_free() + pty = null + _spawn_steamcmd.call_deferred() + pty.finished.connect(on_finished, CONNECT_ONE_SHOT) + + ## Waits until the local machine can resolve valvesoftware.com func wait_for_network() -> void: var network_state := network_manager.NM_STATE_UNKNOWN @@ -153,10 +195,6 @@ func install_steamcmd() -> bool: ## Log in to Steam. This method will fire the 'logged_in' signal with the login ## status. This should be called again if TFA is required. func login(user: String, password := "", tfa := "") -> void: - await thread_group.exec(_login.bind(user, password, tfa)) - - -func _login(user: String, password := "", tfa := "") -> void: # Build the command arguments var cmd_args := [user] if password != "": @@ -175,16 +213,17 @@ func _login(user: String, password := "", tfa := "") -> void: for line in output: # Send the user's password if prompted if line.contains("password:"): - proc.send(password + "\n") + pty.write_line(password) continue # Send the TFA if prompted if line.contains("Steam Guard code:") or line.contains("Two-factor code:"): - proc.send(tfa + "\n") + pty.write_line(tfa + "\n") continue # Set success if we see we logged in if line.contains("Logged in OK") or line.begins_with("OK"): status.append(LOGIN_STATUS.OK) is_logged_in = true + logged_in_user = user continue # Set invalid password status @@ -202,6 +241,17 @@ func _login(user: String, password := "", tfa := "") -> void: status.append(LOGIN_STATUS.FAILED) continue + # Steam Guard confirmation + # Example: + # Logging in user 'abc' [U:1:123] to Steam Public...This account is protected by a Steam Guard mobile authenticator. + # Please confirm the login in the Steam Mobile app on your phone. + # Waiting for confirmation... + # Waiting for confirmation... + # Waiting for confirmation...OK + if line.contains("Please confirm the login"): + logged_in.emit.call_deferred(LOGIN_STATUS.WAITING_GUARD_CONFIRM) + continue + # Pass the callback which will watch our command output await _follow_command(cmd, on_progress) @@ -265,7 +315,19 @@ func save_steamcmd_session() -> void: if tokens.is_empty(): logger.warn("No login sessions found in", vdf_config_path) return - + + # Ensure that a $STEAM_ROOT/config/loginusers.vdf file exists with the user + # account. + var accounts_key := "Accounts" + if "accounts" in vdf.data[config_key][software_key][valve_key][steam_key]: + accounts_key = "accounts" + if not accounts_key in vdf.data[config_key][software_key][valve_key][steam_key]: + logger.warn("Failed to find key 'Accounts' in", vdf_config_path) + else: + var accounts := vdf.data[config_key][software_key][valve_key][steam_key][accounts_key] as Dictionary + logger.debug("Found accounts:", accounts) + save_loginusers(accounts) + # Store the tokens as JSON var json_data := JSON.stringify(tokens) var file := FileAccess.open(tokens_save_path, FileAccess.WRITE_READ) @@ -276,6 +338,61 @@ func save_steamcmd_session() -> void: logger.debug("Successfully saved steamcmd session to", tokens_save_path) +## Save the given accounts (e.g. {"steamyguyx": {"SteamID": 1234}}) as an entry +## in $STEAM_ROOT/config/loginusers.vdf +func save_loginusers(accounts: Dictionary) -> void: + var users := { + "users": {} + } + + # Load the existing loginusers if it exists + if FileAccess.file_exists(vdf_loginusers_path): + logger.debug(vdf_loginusers_path, "exists, loading existing configuration") + var loginusers_vdf := FileAccess.get_file_as_string(vdf_loginusers_path) + if loginusers_vdf.is_empty(): + logger.warn("loginusers.vdf at", loginusers_vdf, "is empty. Unable to save login session.") + var vdf := Vdf.new() + if vdf.parse(loginusers_vdf) != OK: + logger.warn("Failed to parse", vdf_config_path, ":", vdf.get_error_message()) + else: + users = vdf.data + + for username in accounts.keys(): + var steam_id_key := "SteamID" + if "steamid" in accounts[username]: + steam_id_key = "steamid" + if not steam_id_key in accounts[username]: + logger.warn("No 'SteamID' key found for user:", username) + continue + var steam_id = accounts[username][steam_id_key] + logger.debug("Found Steam account ID:", steam_id) + if steam_id in users["users"]: + logger.debug("Account already exists in file:", steam_id) + continue + var now := int(Time.get_unix_time_from_system()) + users["users"][steam_id] = { + "AccountName": username, + "RememberPassword": "1", + "WantsOfflineMode": "0", + "SkipOfflineModeWarning": "0", + "AllowAutoLogin": "1", + "MostRecent": "1", + "Timestamp": str(now), + } + logger.debug("Saving accounts:", users) + + # Serialize the data into VDF format + var vdf := Vdf.new() + var data := Vdf.stringify(users) + + # Save the file + var file := FileAccess.open(vdf_loginusers_path, FileAccess.WRITE) + if not file: + logger.warn("Unable to open", vdf_loginusers_path, "to save login session.") + return + file.store_string(data) + + ## Configures Steam to enable proton for all games func enable_proton() -> void: if not FileAccess.file_exists(vdf_config_path): @@ -441,10 +558,6 @@ func has_steam_run() -> bool: ## Log the user out of Steam func logout() -> void: - await thread_group.exec(_logout) - - -func _logout() -> void: await _wait_for_command("logout\n") is_logged_in = false @@ -453,11 +566,9 @@ func _logout() -> void: ## E.g. [{"id": "1779200", "name": "Thrive", "path": "~/.local/share/Steam/steamapps/common/Thrive"}] #steamcmd +login +apps_installed +quit func get_installed_apps() -> Array[Dictionary]: - return await thread_group.exec(_get_installed_apps) - - -func _get_installed_apps() -> Array[Dictionary]: + logger.debug("Fetching installed apps") var lines := await _wait_for_command("apps_installed\n") + logger.debug("Finished fetching installed apps") var apps: Array[Dictionary] = [] for line in lines: # Example line: @@ -479,10 +590,6 @@ func _get_installed_apps() -> Array[Dictionary]: ## Returns an array of app ids available to the user func get_available_apps() -> Array: - return await thread_group.exec(_get_available_apps) - - -func _get_available_apps() -> Array: var app_ids := [] var lines := await _wait_for_command("licenses_print\n") for line in lines: @@ -512,10 +619,6 @@ func _get_available_apps() -> Array: ## Returns the status for the given app. E.g. ## {"name": "Brotato", "install state": "Fully Installed,", "size on disk": ...} -func get_app_status(app_id: String, cache_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> Dictionary: - return await thread_group.exec(_get_app_status.bind(app_id, cache_flags)) - - ## Steam>app_status 1885690 ## AppID 1885690 (Virtual Circuit Board): ## - release state: released (Subscribed,Permanent,) @@ -531,7 +634,7 @@ func get_app_status(app_id: String, cache_flags: int = Cache.FLAGS.LOAD | Cache. ## { ## "language" "english" ## } -func _get_app_status(app_id: String, cache_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> Dictionary: +func get_app_status(app_id: String, cache_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> Dictionary: # Check to see if this app status is already cached var cache_key := ".".join([app_id, "status"]) if cache_flags & Cache.FLAGS.LOAD and Cache.is_cached(CACHE_DIR, cache_key): @@ -546,8 +649,8 @@ func _get_app_status(app_id: String, cache_flags: int = Cache.FLAGS.LOAD | Cache var app_status := {} for line in lines: if line.begins_with("AppID "): - var name := line.split("(")[-1].split(")")[0].strip_edges() - app_status["name"] = name + var app_name := line.split("(")[-1].split(")")[0].strip_edges() + app_status["name"] = app_name continue if line.begins_with(" - "): var split_line := line.split(":", true, 1) @@ -562,10 +665,6 @@ func _get_app_status(app_id: String, cache_flags: int = Cache.FLAGS.LOAD | Cache ## Returns the app info for the given app func get_app_info(app_id: String, cache_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> Dictionary: - return await thread_group.exec(_get_app_info.bind(app_id, cache_flags)) - - -func _get_app_info(app_id: String, cache_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> Dictionary: # Check to see if this app info is already cached if cache_flags & Cache.FLAGS.LOAD and Cache.is_cached(CACHE_DIR, app_id): logger.debug("Using cached app info result for app: " + app_id) @@ -610,10 +709,6 @@ func _get_app_info(app_id: String, cache_flags: int = Cache.FLAGS.LOAD | Cache.F ## show install progress and emit the 'app_installed' signal with the status ## of the installation. func install(app_id: String, path: String = "") -> void: - await thread_group.exec(_install.bind(app_id, path)) - - -func _install(app_id: String, path: String = "") -> void: var success := await _install_update(app_id, path) #app_installed.emit(app_id, success) emit_signal.call_deferred("app_installed", app_id, success) @@ -623,10 +718,6 @@ func _install(app_id: String, path: String = "") -> void: ## show install progress and emit the 'app_updated' signal with the status ## of the installation. func update(app_id: String) -> void: - await thread_group.exec(_update.bind(app_id)) - - -func _update(app_id: String) -> void: var success := await _install_update(app_id) #app_updated.emit(app_id, success) emit_signal.call_deferred("app_updated", app_id, success) @@ -648,7 +739,16 @@ func _install_update(app_id: String, path: String = "") -> bool: logger.info("Install progress: " + str(output)) for line in output: if line.contains("Success! "): - success.append(true) + success.append("installed") + continue + if line.contains("not online or not logged"): + logger.info(line) + # If the user was previously logged in, but now is no longer + # logged in, the session may have just expired. By adding "login-needed", + # to the success result, we can try to log in again with the same + # token to try installing again automatically. + if not self.logged_in_user.is_empty(): + success.append("login-needed") continue if not line.contains("Update state"): continue @@ -666,18 +766,24 @@ func _install_update(app_id: String, path: String = "") -> bool: install_progressed.emit(app_id, bytes_cur, bytes_total) await _follow_command(cmd, on_progress) - logger.info("Install finished with success: " + str(true in success)) + logger.info("Install finished with success: " + str("installed" in success)) is_app_installing = false - return true in success + if "installed" in success: + return true + + # If re-login is required, try doing that, then try the install again. + if "login-needed" in success: + self.login(self.logged_in_user) + var status := await self.logged_in as LOGIN_STATUS + if status == LOGIN_STATUS.OK: + return await _install_update(app_id, path) + + return false ## Uninstalls the given app. Will emit the 'app_uninstalled' signal when ## completed. func uninstall(app_id: String) -> void: - await thread_group.exec(_uninstall.bind(app_id)) - - -func _uninstall(app_id: String) -> void: await _wait_for_command("app_uninstall " + app_id + "\n") #app_uninstalled.emit(app_id, true) emit_signal.call_deferred("app_uninstalled", app_id, true) @@ -686,28 +792,22 @@ func _uninstall(app_id: String) -> void: ## Set the platform type to install ## Must be one of: [windows | macos | linux | android] func set_platform_type(type: String = "windows") -> void: - var cmd := func(): - await _wait_for_command("@sSteamCmdForcePlatformType " + type + "\n") - await thread_group.exec(cmd) + await _wait_for_command("@sSteamCmdForcePlatformType " + type + "\n") ## Sets the install directory for the next game installed func set_install_directory(path: String, game_name: String) -> void: path = "/".join([path, "steamapps", "common", game_name]) logger.info("Setting install path to: " + path) - var cmd := func(): - await _wait_for_command("force_install_dir " + path + "\n") - await thread_group.exec(cmd) + await _wait_for_command("force_install_dir " + path + "\n") ## Steam>library_folder_list ## Index 0, ContentID 8720464880924330526, Path "/home/deck/.local/share/Steam", Label "", Disk Space 5.50 GB/499.59 GB, Apps 51, Mounted yes ## Index 1, ContentID 3027826209726610080, Path "/run/media/mmcblk0p1", Label "", Disk Space 364.86 GB/1,006.64 GB, Apps 53, Mounted yes func get_library_folders() -> PackedStringArray: - var cmd := func(): - await _wait_for_command("library_folder_list\n") var folders := PackedStringArray() - var lines := await thread_group.exec(cmd) as Array[String] + var lines := await _wait_for_command("library_folder_list\n") as Array[String] for line in lines: var parts := line.split(",") for part in parts: @@ -721,8 +821,8 @@ func get_library_folders() -> PackedStringArray: return folders -# Waits for the given command to finish running and returns the output as an -# array of lines. +## Waits for the given command to finish running and returns the output as an +## array of lines. func _wait_for_command(cmd: String) -> Array[String]: _queue_command(cmd) var out: Array = [""] @@ -731,8 +831,8 @@ func _wait_for_command(cmd: String) -> Array[String]: return out[1] as Array[String] -# Waits for the given command to produce some output and executes the given -# callback with the output. +## Waits for the given command to produce some output and executes the given +## callback with the output. func _follow_command(cmd: String, callback: Callable) -> void: # Signal output: [cmd, output, finished] var out: Array = [""] @@ -750,80 +850,55 @@ func _follow_command(cmd: String, callback: Callable) -> void: # Queues the given command func _queue_command(cmd: String) -> void: cmd_queue.push_back(cmd) - - -func _thread_process(_delta: float) -> void: - if not proc: - return - - # Process our command queue _process_command_queue() - # Read the output from the process - var output := proc.read() - - # Also read output from the stderr file - if steamcmd_stderr: - #logger.debug("steamcmd-stderr: Current position:", steamcmd_stderr.get_position(), "Current size:", steamcmd_stderr.get_length()) - var remaining_data := steamcmd_stderr.get_length() - steamcmd_stderr.get_position() - if remaining_data > 0: - var data := steamcmd_stderr.get_buffer(remaining_data) - var stderr := data.get_string_from_utf8() - logger.debug("steamcmd-stderr: " + stderr) - output += stderr - - # Return if there is no output from the process - if output == "": - return - # Split the output into lines - var lines := output.split("\n") - current_output.append_array(lines) +func _on_line_written(line: String) -> void: + current_output.append(line) # Print the output of steamcmd, except during login for security reasons if not current_cmd.begins_with("login"): - for line in lines: - logger.debug("steamcmd: " + line) + logger.debug("steamcmd: " + line) # Wait for "Redirecting stderr to" to open stderr file. # Because of new behavior as of ~2024-06, steamcmd outputs stderr to a file # instead of to normal stderr. - if not steamcmd_stderr: - for line in lines: - if not line.begins_with("Redirecting stderr to"): - continue + if not steamcmd_stderr and line.begins_with("Redirecting stderr to"): + # Parse the line: + # Redirecting stderr to '/home//.local/share/Steam/logs/stderr.txt' + var parts := line.split("'") + if parts.size() < 2: + logger.error("Unable to parse stderr path for line: " + line) + return + var stderr_path := parts[1] - # Parse the line: - # Redirecting stderr to '/home//.local/share/Steam/logs/stderr.txt' - var parts := line.split("'") - if parts.size() < 2: - logger.error("Unable to parse stderr path for line: " + line) - break - var stderr_path := parts[1] - - # Open the stderr file - steamcmd_stderr = FileAccess.open(stderr_path, FileAccess.READ) - if not steamcmd_stderr: - logger.error("Unable to open steamcmd stderr: " + str(FileAccess.get_open_error())) - break - logger.debug("Opened steamcmd stderr file: " + stderr_path) + # Open the stderr file + steamcmd_stderr = FileAccess.open(stderr_path, FileAccess.READ) + if not steamcmd_stderr: + logger.error("Unable to open steamcmd stderr: " + str(FileAccess.get_open_error())) + return + logger.debug("Opened steamcmd stderr file: " + stderr_path) # Signal when command progress has been made - if current_cmd != "": - var out := lines.duplicate() + if not current_cmd.is_empty(): #emit_signal.call_deferred("command_progressed", current_cmd, out, false) - command_progressed.emit(current_cmd, out, false) + command_progressed.emit(current_cmd, [line], false) # Signal that a steamcmd prompt is available - if lines[-1].begins_with("Steam>"): + if line.begins_with("Steam>"): + logger.debug("Prompt available") if state == STATE.BOOT: - emit_signal.call_deferred("client_ready") + client_ready.emit.call_deferred() state = STATE.PROMPT - prompt_available.emit() + prompt_available.emit.call_deferred() # If a command was executing, emit its output - if current_cmd == "": + if current_cmd.is_empty(): return + if current_cmd.begins_with("login"): + logger.debug("Command finished: login ****") + else: + logger.debug("Command finished:", current_cmd.strip_edges()) var out := current_output.duplicate() #emit_signal.call_deferred("command_progressed", current_cmd, [], true) command_progressed.emit(current_cmd, [], true) @@ -836,17 +911,37 @@ func _thread_process(_delta: float) -> void: # Processes commands in the queue by popping the first item in the queue and # setting our state to EXECUTING. func _process_command_queue() -> void: - if state != STATE.PROMPT or cmd_queue.size() == 0: + if state != STATE.PROMPT or cmd_queue.size() == 0 or not current_cmd.is_empty(): return var cmd := cmd_queue.pop_front() as String state = STATE.EXECUTING + if cmd.begins_with("login"): + logger.debug("Executing command: login ******") + else: + logger.debug("Executing command:", cmd.strip_edges()) current_cmd = cmd current_output.clear() - proc.send(cmd) + pty.write(cmd.to_utf8_buffer()) + + +func _process(_delta: float) -> void: + # Also read output from the stderr file + if not steamcmd_stderr: + return + #logger.debug("steamcmd-stderr: Current position:", steamcmd_stderr.get_position(), "Current size:", steamcmd_stderr.get_length()) + var remaining_data := steamcmd_stderr.get_length() - steamcmd_stderr.get_position() + if remaining_data <= 0: + return + var data := steamcmd_stderr.get_buffer(remaining_data) + var stderr := data.get_string_from_utf8() + logger.debug("steamcmd-stderr: " + stderr) + var lines := stderr.split("\n") + for line in lines: + _on_line_written(line) func _exit_tree() -> void: - if not proc: + if not pty: return - proc.send("quit\n") - proc.stop() + pty.write_line("quit\n") + pty.kill() diff --git a/core/steam_settings.gd b/core/steam_settings.gd index ab2a49e..a0b7dbd 100644 --- a/core/steam_settings.gd +++ b/core/steam_settings.gd @@ -15,6 +15,9 @@ const icon := preload("res://plugins/steam/assets/steam.svg") @onready var steam: SteamClient = get_tree().get_first_node_in_group("steam_client") +var logging_in := false + + # Called when the node enters the scene tree for the first time. func _ready() -> void: # If we have logged in before, populate the username box @@ -70,6 +73,11 @@ func _on_client_ready() -> void: func _on_login(login_status: SteamClient.LOGIN_STATUS) -> void: + if login_status == SteamClient.LOGIN_STATUS.WAITING_GUARD_CONFIRM: + return + logging_in = false + login_button.text = "Login" + # Un-hide the 2fa box if we require two-factor auth if login_status == SteamClient.LOGIN_STATUS.TFA_REQUIRED: tfa_box.visible = true @@ -87,8 +95,12 @@ func _on_login(login_status: SteamClient.LOGIN_STATUS) -> void: # Called when the login button is pressed func _on_login_button() -> void: + if logging_in: + return var username: String = user_box.text var password: String = pass_box.text var tfa_code: String = tfa_box.text settings_manager.set_value("plugin.steam", "user", username) + logging_in = true + login_button.text = "Logging in..." steam.login(username, password, tfa_code) diff --git a/core/steam_settings.tscn b/core/steam_settings.tscn index 4954e13..4abd5bf 100644 --- a/core/steam_settings.tscn +++ b/core/steam_settings.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=5 format=3 uid="uid://th8bv5uv0kng"] -[ext_resource type="Script" path="res://plugins/steam/core/steam_settings.gd" id="1_482tk"] +[ext_resource type="Script" uid="uid://dhuah8i5c5mfy" path="res://plugins/steam/core/steam_settings.gd" id="1_482tk"] [ext_resource type="PackedScene" uid="uid://d1hlp6c8wrqgv" path="res://core/ui/components/status.tscn" id="2_xf1mt"] [ext_resource type="PackedScene" uid="uid://d1rjdfxxrdccf" path="res://core/ui/components/text_input.tscn" id="3_d62ly"] [ext_resource type="PackedScene" uid="uid://c71ayw7pcw6u6" path="res://core/ui/components/card_button.tscn" id="4_jlnf0"] diff --git a/plugin.gd b/plugin.gd index 125eb3e..df93b8e 100644 --- a/plugin.gd +++ b/plugin.gd @@ -51,14 +51,16 @@ func _on_client_ready(): _ui_notification("Steam login required") return + if not steam.has_steamcmd_session(): + return + # If we have logged in before, try logging in with saved credentials - if steam.has_steamcmd_session(): - logger.info("Previous session exists. Trying to log in with existing session.") - if steam.restore_steamcmd_session() != OK: - logger.error("Failed to restore previous steam session") - return - logger.info("Session restored successfully") - steam.login(user) + logger.info("Previous session exists. Trying to log in with existing session.") + if steam.restore_steamcmd_session() != OK: + logger.error("Failed to restore previous steam session") + return + logger.info("Session restored successfully") + steam.login(user) # Triggers when the Steam Client is logged in @@ -72,6 +74,9 @@ func _on_client_logged_in(status: SteamClient.LOGIN_STATUS): if not steam.has_steam_run(): _on_first_boot.call_deferred() + if status == SteamClient.LOGIN_STATUS.WAITING_GUARD_CONFIRM: + _ui_notification("Please confirm the login in the Steam Mobile app on your phone.") + if status == SteamClient.LOGIN_STATUS.INVALID_PASSWORD: _ui_notification("Steam login required") From 2b476d8f6b8ca2cb5ae2487a8edf672feeab3248 Mon Sep 17 00:00:00 2001 From: William Edwards Date: Wed, 6 Aug 2025 23:34:44 -0700 Subject: [PATCH 3/3] fix(Steam): add better install hooks and progress --- core/library_steam.gd | 35 ++++++- core/steam_client.gd | 202 +++++++++++++++++++++++++++++---------- core/steam_settings.gd | 5 + core/steam_settings.tscn | 8 +- 4 files changed, 193 insertions(+), 57 deletions(-) diff --git a/core/library_steam.gd b/core/library_steam.gd index c831436..3361453 100644 --- a/core/library_steam.gd +++ b/core/library_steam.gd @@ -448,6 +448,7 @@ func _app_info_to_launch_item(info: Dictionary, is_installed: bool) -> LibraryLa item.name = data["name"] item.command = "steam" #item.args = ["-gamepadui", "-steamos3", "-steampal", "-steamdeck", "-silent", "steam://rungameid/" + app_id] + #item.args = ["-silent", "steam://rungameid/" + app_id] item.args = ["-gamepadui", "steam://rungameid/" + app_id] item.categories = categories item.tags = ["steam"] @@ -475,13 +476,16 @@ func _app_supports_linux(app_id: String) -> bool: ## Hook to execute before app launch. This hook will try to ensure the app is ## up-to-date before starting and will update the text in the game loading menu. ## TODO: Also include shader compilation +## TODO: Ensure steam is not currently running class PreLaunchHook extends AppLifecycleHook: var _steam: SteamClient + var _replace_intro_video: bool var logger: Logger func _init(steam: SteamClient) -> void: _hook_type = AppLifecycleHook.TYPE.PRE_LAUNCH _steam = steam + _replace_intro_video = false # disable for now logger = Log.get_logger("Steam") func get_name() -> String: @@ -492,7 +496,8 @@ class PreLaunchHook extends AppLifecycleHook: await self.ensure_app_updated(item) # Set the startup movie to use so it's less obnoxious - await self.set_steam_startup_video() + if _replace_intro_video: + await self.set_steam_startup_video() logger.info("Starting app") self.notified.emit("Starting Steam...") @@ -503,6 +508,27 @@ class PreLaunchHook extends AppLifecycleHook: return if not item.provider_app_id.is_valid_int(): return + if not _steam.is_logged_in: + return + + # Linux Runtime + const SNIPER_APP_ID := "1628350" + logger.info("Updating Sniper Linux Runtime before launch") + self.notified.emit("Updating Sniper Linux Runtime...") + await _steam.update(SNIPER_APP_ID) + + # Proton + const PROTON_APP_ID := "1493710" + logger.info("Updating Proton before launch") + self.notified.emit("Updating Proton...") + await _steam.update(PROTON_APP_ID) + + # Common Redistributables + const REDIST_APP_ID := "228980" + logger.info("Updating Steamworks Common Redistributables before launch") + self.notified.emit("Updating Steamworks Common Redistributables...") + await _steam.update(REDIST_APP_ID) + logger.info("Updating app before launch") self.notified.emit("Updating app...") await _steam.update(item.provider_app_id) @@ -544,18 +570,21 @@ class PreLaunchHook extends AppLifecycleHook: ## Hook to execute when the app exits. This will try to restore the startup video. class ExitHook extends AppLifecycleHook: var _steam: SteamClient + var _replace_intro_video: bool var logger: Logger func _init(steam: SteamClient) -> void: _hook_type = AppLifecycleHook.TYPE.EXIT _steam = steam + _replace_intro_video = false # disable for now logger = Log.get_logger("Steam") func get_name() -> String: return "RestoreStartupVideo" func execute(_item: LibraryLaunchItem) -> void: - await self.restore_steam_startup_video() + if _replace_intro_video: + await self.restore_steam_startup_video() func restore_steam_startup_video() -> void: var steam_path := _steam.steamcmd_dir @@ -572,5 +601,5 @@ class ExitHook extends AppLifecycleHook: DirAccess.rename_absolute(override_path + ".ogui_backup", override_path) func _notification(what: int) -> void: - if what == NOTIFICATION_PREDELETE: + if what == NOTIFICATION_PREDELETE and self._replace_intro_video: self.restore_steam_startup_video() diff --git a/core/steam_client.gd b/core/steam_client.gd index b395dc9..1b28a79 100644 --- a/core/steam_client.gd +++ b/core/steam_client.gd @@ -6,6 +6,7 @@ extends Node ## [InteractiveProcess] to spawn steamcmd in a psuedo terminal to read and write ## to its stdout/stdin. +const steam_url := "https://steamdeck-packages.steamos.cloud/archlinux-mirror/jupiter-main/os/x86_64/steam-jupiter-stable-1.0.0.81-2.5-x86_64.pkg.tar.zst" const steamcmd_url := "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" const CACHE_DIR := "steam" @@ -23,6 +24,11 @@ enum LOGIN_STATUS { WAITING_GUARD_CONFIRM, } +enum INSTALL_STATUS { + OK, + FAILED, +} + # Steam thread signals signal command_finished(cmd: String, output: Array[String]) signal command_progressed(cmd: String, output: Array[String], finished: bool) @@ -39,13 +45,15 @@ signal install_progressed(app_id: String, current: int, total: int) var platform := load("res://core/global/platform.tres") as Platform var network_manager := load("res://core/systems/network/network_manager.tres") as NetworkManagerInstance -var steamcmd_dir := "/".join([OS.get_environment("HOME"), ".local", "share", "Steam"]) +var steam_dir := "/".join([OS.get_environment("HOME"), ".local", "share", "Steam"]) +var steamcmd_dir := "/".join([steam_dir, "steamcmd"]) +var steam_bootstrap_file := "/".join([steam_dir, "linux64", "steamclient.so"]) var steamcmd := "/".join([steamcmd_dir, "steamcmd.sh"]) -var vdf_local_path := "/".join([steamcmd_dir, "local.vdf"]) -var vdf_config_path := "/".join([steamcmd_dir, "config", "config.vdf"]) -var vdf_loginusers_path := "/".join([steamcmd_dir, "config", "loginusers.vdf"]) -var tokens_save_path := "/".join([steamcmd_dir, "config", "steamcmd-tokens.json"]) -var package_path := "/".join([steamcmd_dir, "package"]) +var vdf_local_path := "/".join([steam_dir, "local.vdf"]) +var vdf_config_path := "/".join([steam_dir, "config", "config.vdf"]) +var vdf_loginusers_path := "/".join([steam_dir, "config", "loginusers.vdf"]) +var tokens_save_path := "/".join([steam_dir, "config", "steamcmd-tokens.json"]) +var package_path := "/".join([steam_dir, "package"]) var steamcmd_stderr: FileAccess var pty: Pty var state: STATE = STATE.BOOT @@ -73,16 +81,25 @@ func bootstrap() -> void: await wait_for_network() # Ensure the client is part of the Steam Deck beta - var beta_file := "/".join([package_path, "beta"]) - if not FileAccess.file_exists(beta_file): - DirAccess.make_dir_recursive_absolute(package_path) - var f := FileAccess.open(beta_file, FileAccess.WRITE) - f.store_string("steamdeck_publicbeta") + #var beta_file := "/".join([package_path, "beta"]) + #if not FileAccess.file_exists(beta_file): + #DirAccess.make_dir_recursive_absolute(package_path) + #var f := FileAccess.open(beta_file, FileAccess.WRITE) + #f.store_string("steamdeck_publicbeta") + + # Download and install steam bootstrap if not found + if not FileAccess.file_exists(steam_bootstrap_file): + logger.info("Steam doesn't seem bootstrapped. Trying to bootstrap Steam.") + if await bootstrap_steam() != OK: + logger.error("Unable to bootstrap steam") + bootstrap_finished.emit() + return + logger.info("Successfully bootstrapped steam") # Download and install steamcmd if not found if not FileAccess.file_exists(steamcmd): logger.info("The steamcmd binary wasn't found. Trying to install it.") - if not await install_steamcmd(): + if await install_steamcmd() != OK: logger.error("Unable to install steamcmd") bootstrap_finished.emit() return @@ -95,6 +112,38 @@ func bootstrap() -> void: bootstrap_finished.emit() +## Spawn steam in a PTY to bootstrap itself before launching steamcmd +func bootstrap_steam() -> Error: + var success_pattern := "Update complete" + if pty: + pty.queue_free() + pty = null + pty = Pty.new() + var cmd := "env" + var args := PackedStringArray(["DISPLAY=:0", "steam"]) + + var on_line_written := func(line: String): + logger.debug("Bootstrapping Steam:", line.strip_edges()) + if not line.contains(success_pattern): + return + pty.kill() + pty.line_written.connect(on_line_written) + if pty.exec(cmd, args) != OK: + logger.error("Unable to spawn steam for bootstrapping") + return ERR_CANT_CREATE + add_child(pty) + + # Wait for the Steam bootstrap to finish + await pty.finished + logger.debug("Finished bootstrapping steam") + remove_child(pty) + pty.queue_free() + pty = null + await get_tree().process_frame + + return OK + + ## Spawn steamcmd inside a PTY func _spawn_steamcmd() -> void: if pty: @@ -140,7 +189,7 @@ func wait_for_network() -> void: ## Download steamcmd to the user directory -func install_steamcmd() -> bool: +func install_steamcmd() -> Error: # Build the request var http: HTTPRequest = HTTPRequest.new() add_child.call_deferred(http) @@ -149,7 +198,7 @@ func install_steamcmd() -> bool: logger.error("Error downloading steamcmd: " + steamcmd_url) remove_child(http) http.queue_free() - return false + return ERR_CANT_CONNECT # Wait for the request signal to complete # result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray @@ -162,7 +211,7 @@ func install_steamcmd() -> bool: if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: logger.error("steamcmd couldn't be downloaded: " + steamcmd_url) - return false + return ERR_CANT_CONNECT # Save the archive var file := FileAccess.open("/tmp/steamcmd_linux.tar.gz", FileAccess.WRITE_READ) @@ -174,22 +223,22 @@ func install_steamcmd() -> bool: OS.execute("tar", ["xvfz", "/tmp/steamcmd_linux.tar.gz", "-C", steamcmd_dir], out) # Check if ~/Steam exists - var home := DirAccess.open(OS.get_environment("HOME")) - if not home: - logger.error("Home directory not found, wtf") - return false - if home.dir_exists("Steam") and not home.is_link("Steam"): - logger.warn("steamcmd data folder already exists, but is not a symlink") - logger.warn("Backing up ~/Steam to ~/Steam.bak...") - if home.rename("Steam", "Steam.bak") != OK: - logger.warn("Failed to back up ~/Steam to ~/Steam.bak") - return false - if not home.dir_exists("Steam"): - logger.info("Creating symlink for steamcmd") - if home.create_link(steamcmd_dir, "Steam") != OK: - logger.error("Failed to create symlink for steamcmd") - - return true + #var home := DirAccess.open(OS.get_environment("HOME")) + #if not home: + # logger.error("Home directory not found, wtf") + # return false + #if home.dir_exists("Steam") and not home.is_link("Steam"): + # logger.warn("steamcmd data folder already exists, but is not a symlink") + # logger.warn("Backing up ~/Steam to ~/Steam.bak...") + # if home.rename("Steam", "Steam.bak") != OK: + # logger.warn("Failed to back up ~/Steam to ~/Steam.bak") + # return false + #if not home.dir_exists("Steam"): + # logger.info("Creating symlink for steamcmd") + # if home.create_link(steamcmd_dir, "Steam") != OK: + # logger.error("Failed to create symlink for steamcmd") + + return OK ## Log in to Steam. This method will fire the 'logged_in' signal with the login @@ -229,16 +278,19 @@ func login(user: String, password := "", tfa := "") -> void: # Set invalid password status if line.contains("Invalid Password"): status.append(LOGIN_STATUS.INVALID_PASSWORD) + is_logged_in = false continue # Set TFA failure status if line.contains("need two-factor code"): status.append(LOGIN_STATUS.TFA_REQUIRED) + is_logged_in = false continue # Handle all other failures if line.contains("FAILED"): status.append(LOGIN_STATUS.FAILED) + is_logged_in = false continue # Steam Guard confirmation @@ -501,58 +553,88 @@ func restore_steamcmd_session() -> int: logger.warn("Failed to parse", vdf_config_path, ":", vdf.get_error_message()) return ERR_PARSE_ERROR logger.trace("Successfully parsed config:", vdf.data) + var data := vdf.data # Validate the structure. Sometimes these keys can be in lowercase or uppercase. var config_key := "InstallConfigStore" - if "installconfigstore" in vdf.data: + if "installconfigstore" in data: config_key = "installconfigstore" - if not config_key in vdf.data: + if not config_key in data: logger.warn("Failed to find key 'InstallConfigStore' in", vdf_config_path) return ERR_PARSE_ERROR var software_key := "Software" - if "software" in vdf.data[config_key]: + if "software" in data[config_key]: software_key = "software" - if not software_key in vdf.data[config_key]: + if not software_key in data[config_key]: logger.warn("Failed to find key 'Software' in", vdf_config_path) return ERR_PARSE_ERROR var valve_key := "Valve" - if "valve" in vdf.data[config_key][software_key]: + if "valve" in data[config_key][software_key]: valve_key = "valve" - if not valve_key in vdf.data[config_key][software_key]: + if not valve_key in data[config_key][software_key]: logger.warn("Failed to find key 'Valve' in", vdf_config_path) return ERR_PARSE_ERROR var steam_key := "Steam" - if "steam" in vdf.data[config_key][software_key][valve_key]: + if "steam" in data[config_key][software_key][valve_key]: steam_key = "steam" - if not steam_key in vdf.data[config_key][software_key][valve_key]: + if not steam_key in data[config_key][software_key][valve_key]: logger.warn("Failed to find key 'Steam' in", vdf_config_path) return ERR_PARSE_ERROR var cache_key := "ConnectCache" - if "connectcache" in vdf.data[config_key][software_key][valve_key][steam_key]: + if "connectcache" in data[config_key][software_key][valve_key][steam_key]: cache_key = "connectcache" - if not cache_key in vdf.data[config_key][software_key][valve_key][steam_key]: + if not cache_key in data[config_key][software_key][valve_key][steam_key]: logger.warn("Failed to find key 'ConnectCache' in", vdf_config_path) return ERR_PARSE_ERROR # Update the connection cache with the saved session - vdf.data[config_key][software_key][valve_key][steam_key][cache_key] = session + data[config_key][software_key][valve_key][steam_key][cache_key] = session # Serialize the dictionary back into VDF - var config_data := Vdf.stringify(vdf.data) + var config_data := Vdf.stringify(data) if config_data.is_empty(): logger.warn("Failed to serialize config.vdf") return ERR_INVALID_DATA # Save the updated config var config_file := FileAccess.open(vdf_config_path, FileAccess.WRITE_READ) + if not config_file: + logger.warn("Failed to open config.vdf to restore session") + return ERR_CANT_OPEN config_file.store_string(config_data) + # Update the connection cache for local.vdf + data = { + "MachineUserConfigStore": { + "Software": { + "Valve": { + "Steam": { + "ConnectCache": session, + } + } + }, + }, + } + + # Serialize the local.vdf data into VDF format + var local_data := Vdf.stringify(data) + if local_data.is_empty(): + logger.warn("Failed to serialize local.vdf") + return ERR_INVALID_DATA + + # Save the updated local config + var local_file := FileAccess.open(vdf_local_path, FileAccess.WRITE) + if not local_file: + logger.warn("Failed to open local.vdf to restore session") + return ERR_CANT_OPEN + local_file.store_string(local_data) + return OK ## Returns true if the Steam client has been detected to have launched before. func has_steam_run() -> bool: - var folder_to_check := "/".join([steamcmd_dir, "ubuntu12_64"]) + var folder_to_check := "/".join([steam_dir, "ubuntu12_64"]) return DirAccess.dir_exists_absolute(folder_to_check) @@ -711,7 +793,7 @@ func get_app_info(app_id: String, cache_flags: int = Cache.FLAGS.LOAD | Cache.FL func install(app_id: String, path: String = "") -> void: var success := await _install_update(app_id, path) #app_installed.emit(app_id, success) - emit_signal.call_deferred("app_installed", app_id, success) + emit_signal.call_deferred("app_installed", app_id, success == INSTALL_STATUS.OK) ## Install the given app. This will emit the 'install_progressed' signal to @@ -720,11 +802,14 @@ func install(app_id: String, path: String = "") -> void: func update(app_id: String) -> void: var success := await _install_update(app_id) #app_updated.emit(app_id, success) - emit_signal.call_deferred("app_updated", app_id, success) + emit_signal.call_deferred("app_updated", app_id, success == INSTALL_STATUS.OK) # Shared functionality between app install and app update -func _install_update(app_id: String, path: String = "") -> bool: +func _install_update(app_id: String, path: String = "", try: int = 0) -> INSTALL_STATUS: + if try > 10: + logger.error("Failed to install app", app_id, "after 10 attempts") + return INSTALL_STATUS.FAILED is_app_installing = true # TODO: FIXME @@ -736,9 +821,14 @@ func _install_update(app_id: String, path: String = "") -> bool: var success := [] # Needs to be an array to be updated from lambda var on_progress := func(output: Array): # [" Update state (0x61) downloading, progress: 84.45 (1421013576 / 1682619731)", ""] - logger.info("Install progress: " + str(output)) for line in output: - if line.contains("Success! "): + if line == "": + continue + logger.info("Install progress: ", line) + if line.contains("Success! App ") and line.contains("already up to date."): + success.append("up-to-date") + continue + if line.contains("Success! ") and line.contains("fully"): success.append("installed") continue if line.contains("not online or not logged"): @@ -766,19 +856,25 @@ func _install_update(app_id: String, path: String = "") -> bool: install_progressed.emit(app_id, bytes_cur, bytes_total) await _follow_command(cmd, on_progress) - logger.info("Install finished with success: " + str("installed" in success)) + logger.info("Install finished with success: " + str("installed" in success or "up-to-date" in success)) is_app_installing = false + if "up-to-date" in success: + return INSTALL_STATUS.OK + + # If app is installed, make sure it says "Already up to date" if "installed" in success: - return true + logger.debug("Application may not be up-to-date. Re-running update.") + return await _install_update(app_id, path, try + 1) # If re-login is required, try doing that, then try the install again. if "login-needed" in success: + self.restore_steamcmd_session() self.login(self.logged_in_user) var status := await self.logged_in as LOGIN_STATUS if status == LOGIN_STATUS.OK: - return await _install_update(app_id, path) + return await _install_update(app_id, path, try + 1) - return false + return INSTALL_STATUS.FAILED ## Uninstalls the given app. Will emit the 'app_uninstalled' signal when diff --git a/core/steam_settings.gd b/core/steam_settings.gd index a0b7dbd..b22b6c8 100644 --- a/core/steam_settings.gd +++ b/core/steam_settings.gd @@ -89,9 +89,14 @@ func _on_login(login_status: SteamClient.LOGIN_STATUS) -> void: if login_status == SteamClient.LOGIN_STATUS.OK: logged_in_status.status = logged_in_status.STATUS.CLOSED logged_in_status.color = "green" + pass_box.visible = false return + # Show the password box in all other cases + pass_box.visible = true + pass_box.grab_focus.call_deferred() + # Called when the login button is pressed func _on_login_button() -> void: diff --git a/core/steam_settings.tscn b/core/steam_settings.tscn index 4abd5bf..c314075 100644 --- a/core/steam_settings.tscn +++ b/core/steam_settings.tscn @@ -1,6 +1,7 @@ -[gd_scene load_steps=5 format=3 uid="uid://th8bv5uv0kng"] +[gd_scene load_steps=6 format=3 uid="uid://th8bv5uv0kng"] [ext_resource type="Script" uid="uid://dhuah8i5c5mfy" path="res://plugins/steam/core/steam_settings.gd" id="1_482tk"] +[ext_resource type="PackedScene" uid="uid://8m20p2s0v5gb" path="res://core/systems/input/focus_group.tscn" id="2_7meib"] [ext_resource type="PackedScene" uid="uid://d1hlp6c8wrqgv" path="res://core/ui/components/status.tscn" id="2_xf1mt"] [ext_resource type="PackedScene" uid="uid://d1rjdfxxrdccf" path="res://core/ui/components/text_input.tscn" id="3_d62ly"] [ext_resource type="PackedScene" uid="uid://c71ayw7pcw6u6" path="res://core/ui/components/card_button.tscn" id="4_jlnf0"] @@ -19,6 +20,10 @@ size_flags_horizontal = 3 size_flags_vertical = 0 theme_override_constants/separation = 10 +[node name="FocusGroup" parent="ContentContainer" node_paths=PackedStringArray("current_focus") instance=ExtResource("2_7meib")] +current_focus = NodePath("../Status") +wrap_focus = false + [node name="Status" parent="ContentContainer" instance=ExtResource("2_xf1mt")] unique_name_in_owner = true layout_mode = 2 @@ -52,6 +57,7 @@ description = "" [node name="PasswordTextInput" parent="ContentContainer" instance=ExtResource("3_d62ly")] unique_name_in_owner = true +visible = false layout_mode = 2 title = "Password" description = ""