diff --git a/python_pkg/steam_backlog_enforcer/game_install.py b/python_pkg/steam_backlog_enforcer/game_install.py index 733ceea..40762b2 100644 --- a/python_pkg/steam_backlog_enforcer/game_install.py +++ b/python_pkg/steam_backlog_enforcer/game_install.py @@ -61,6 +61,26 @@ PROTECTED_APP_IDS = { STEAMAPPS_PATH = Path("~/.local/share/Steam/steamapps").expanduser() +def _trigger_steam_install(app_id: int, label: str) -> bool: + """Ask Steam to install a game via the ``steam://install`` URI. + + Returns True if the URI handler was invoked successfully. + """ + xdg_open = shutil.which("xdg-open") or "/usr/bin/xdg-open" + try: + subprocess.run( + [xdg_open, f"steam://install/{app_id}"], + capture_output=True, + timeout=15, + check=False, + ) + except (FileNotFoundError, OSError, subprocess.TimeoutExpired): + return False + else: + logger.info("Triggered Steam install for %s via protocol handler", label) + return True + + # ────────────────────────────────────────────────────────────── # Game install management # ────────────────────────────────────────────────────────────── @@ -142,22 +162,34 @@ def _ensure_steam_running() -> None: logger.exception("Steam executable not found") -def install_game(app_id: int, game_name: str, steam_id: str) -> bool: - """Install a game by writing an appmanifest that triggers Steam's download. +def install_game( + app_id: int, + game_name: str, + steam_id: str, + *, + use_steam_protocol: bool = False, +) -> bool: + """Install a game by triggering a Steam download. - Creates a minimal appmanifest with StateFlags=1026 (UpdateRequired | - UpdateStarted) in the steamapps directory. The running Steam client - detects the new manifest and automatically queues the download — no - dialog or user interaction required. + When *use_steam_protocol* is True the ``steam://install`` URI handler + is used, which lets Steam determine the correct install directory from + its own metadata. This avoids mismatches between the display name and + the canonical ``installdir`` that can cause "Missing game executable" + errors. Falls back to writing a fabricated appmanifest if the URI + handler is unavailable. - If Steam is not running it will be started in silent mode first. + When *use_steam_protocol* is False (the default) a minimal + appmanifest with StateFlags=1026 is written directly. This is + suitable for non-interactive / daemon contexts where opening a Steam + dialog is undesirable. Args: app_id: Steam application ID. game_name: Human-readable game name. steam_id: Steam64 ID of the account that owns the game. + use_steam_protocol: Prefer the ``steam://install`` URI handler. - Returns True if the manifest was written successfully. + Returns True if the install was triggered successfully. """ label = game_name or f"AppID={app_id}" @@ -165,6 +197,12 @@ def install_game(app_id: int, game_name: str, steam_id: str) -> bool: logger.info("Game already installed: %s", label) return True + if use_steam_protocol: + _ensure_steam_running() + if _trigger_steam_install(app_id, label): + return True + logger.debug("steam:// protocol failed; falling back to manifest") + # Build a minimal appmanifest. StateFlags 1026 = UpdateRequired (2) + # UpdateStarted (1024), which tells Steam "this app needs downloading". manifest_content = ( diff --git a/python_pkg/steam_backlog_enforcer/main.py b/python_pkg/steam_backlog_enforcer/main.py index 3b8a7dd..bf91794 100644 --- a/python_pkg/steam_backlog_enforcer/main.py +++ b/python_pkg/steam_backlog_enforcer/main.py @@ -236,7 +236,12 @@ def cmd_install(config: Config, state: State) -> None: return _echo(f"Installing {state.current_game_name} (AppID={state.current_app_id})...") - if install_game(state.current_app_id, state.current_game_name, config.steam_id): + if install_game( + state.current_app_id, + state.current_game_name, + config.steam_id, + use_steam_protocol=True, + ): _echo("Done!") else: _echo("Failed to create install manifest.") @@ -382,6 +387,7 @@ def _enforce_on_done(config: Config, state: State) -> None: state.current_app_id, state.current_game_name, config.steam_id, + use_steam_protocol=True, ) diff --git a/python_pkg/steam_backlog_enforcer/scanning.py b/python_pkg/steam_backlog_enforcer/scanning.py index 8a25b5c..3ebb532 100644 --- a/python_pkg/steam_backlog_enforcer/scanning.py +++ b/python_pkg/steam_backlog_enforcer/scanning.py @@ -199,7 +199,12 @@ def pick_next_game(games: list[GameInfo], state: State, config: Config) -> None: if not is_game_installed(chosen.app_id): _echo(f"\n Auto-installing {chosen.name}...") - install_game(chosen.app_id, chosen.name, config.steam_id) + install_game( + chosen.app_id, + chosen.name, + config.steam_id, + use_steam_protocol=True, + ) # ────────────────────────────────────────────────────────────── @@ -404,7 +409,12 @@ def _enforce_auto_install(config: Config, state: State) -> None: return if not is_game_installed(app_id): _echo(f" Auto-installing {state.current_game_name}...") - if install_game(app_id, state.current_game_name, config.steam_id): + if install_game( + app_id, + state.current_game_name, + config.steam_id, + use_steam_protocol=True, + ): send_notification( "Game Installing", f"{state.current_game_name} is being downloaded.", @@ -495,6 +505,14 @@ def do_enforce(config: Config, state: State) -> None: _echo(" Press Ctrl+C to stop.\n") try: while True: + # Reload state from disk so CLI changes (e.g. new game + # assignment via ``done`` / ``scan``) take effect immediately + # without needing to restart the daemon. + fresh = State.load() + state.current_app_id = fresh.current_app_id + state.current_game_name = fresh.current_game_name + state.finished_app_ids = fresh.finished_app_ids + _enforce_loop_iteration(config, state) time.sleep(ENFORCE_INTERVAL) except KeyboardInterrupt: