diff --git a/steam_backlog_enforcer/_command_help.py b/steam_backlog_enforcer/_command_help.py index bf9e569..8a8c3fd 100644 --- a/steam_backlog_enforcer/_command_help.py +++ b/steam_backlog_enforcer/_command_help.py @@ -18,7 +18,7 @@ COMMAND_DESCRIPTIONS: Final[dict[str, str]] = { "check": "Check assigned game for a new achievement", "status": "Show current status", "list": "List games from snapshot", - "install": "Install the assigned game", + "install": "Install an allowed game (fast installer first)", "hide": "Hide all non-assigned games in library", "unhide": "Unhide all games in library", "buy-dlc": "Unblock the store for 15 min to buy a game/DLC", diff --git a/steam_backlog_enforcer/_fast_install.py b/steam_backlog_enforcer/_fast_install.py index 36d3ed5..604088a 100644 --- a/steam_backlog_enforcer/_fast_install.py +++ b/steam_backlog_enforcer/_fast_install.py @@ -10,14 +10,18 @@ Two modes, picked by whether anyone can answer a prompt: * **Foreground** (stdin is a terminal - ``pick-manual``, ``done``...): run to completion with the terminal attached, so depotdownloader's progress shows and a password / Steam Guard prompt can be answered. -* **Detached** (the daemon): spawn in its own session and return. Every later - pass reaps it through :func:`poll_fast_installs`, which records a failure (so - the caller falls back to ``steam://install``) and interrupts a run whose - output has stalled - most likely a login prompt nobody will ever answer, - sitting there with Steam shut down. +* **Detached** (no terminal): spawn in its own session. The root daemon + returns at once and reaps it on every later pass through + :func:`poll_fast_installs`, which records a failure (so the caller falls + back to ``steam://install``) and interrupts a run whose output has stalled - + most likely a login prompt nobody will ever answer, sitting there with Steam + shut down. Anything else (a web job) exits once its command returns, so it + runs that same reaper in a loop until the installer is done. The installer closes Steam to do its work, so it is refused outright while a -game is running or another app is mid-update (the AoE2 corruption case). +game is running. Another app mid-download is no reason to wait: the installer +asks Steam to leave with ``steam -shutdown``, and Steam resumes the transfer +when it comes back. """ from __future__ import annotations @@ -38,10 +42,7 @@ from steam_backlog_enforcer import config as _config from steam_backlog_enforcer._desktop_env import desktop_user_cmd, resolve_desktop_user from steam_backlog_enforcer._echo import _echo from steam_backlog_enforcer._steam_restart_guard import game_is_running -from steam_backlog_enforcer._steam_state import ( - STEAMAPPS_PATH, - steam_update_in_progress, -) +from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH logger = logging.getLogger(__name__) @@ -53,6 +54,8 @@ _LANGUAGE = "english" # depotdownloader prints a line per finished chunk, so a healthy download is # never silent this long; a login prompt waiting on /dev/null is. STALL_SECONDS = 15 * 60 +# How often a waiting (non-daemon) caller reaps its run. +_WAIT_POLL_SECONDS = 5 @dataclass @@ -100,14 +103,10 @@ def _steam_account_name() -> str | None: return match.group(1) if match else None -def _refusal(app_id: int) -> str | None: +def _refusal() -> str | None: """Why closing Steam right now would do damage, or None if it is safe.""" if game_is_running(): return "a game is running and closing Steam would kill it" - if steam_update_in_progress(exclude_app_id=app_id): - return ( - "another Steam download is in progress and closing Steam could corrupt it" - ) return None @@ -181,7 +180,9 @@ def poll_fast_installs() -> None: def _spawn_detached(cmd: list[str], app_id: int) -> None: """Start the installer in its own session, output to a log file.""" - log_path = _config.CONFIG_DIR / "fast_install.log" + # Per user: the daemon's log is root-owned, and a web job runs as the + # desktop user - sharing one file made every job spawn raise. + log_path = _config.CONFIG_DIR / f"fast_install-{os.geteuid()}.log" log_path.parent.mkdir(parents=True, exist_ok=True) # The unit is KillMode=control-group: restarting the daemon would SIGTERM # an installer left in its cgroup, skipping the `finally` that restarts @@ -201,6 +202,18 @@ def _spawn_detached(cmd: list[str], app_id: int) -> None: logger.info("Started steam-game-installer for AppID=%d (log: %s)", app_id, log_path) +def _wait_detached(app_id: int, label: str) -> bool: + """Reap this process's run for *app_id* until it ends; True on success.""" + _echo( + f" Fast-installing {label} with steam-game-installer (Steam will close); " + "waiting for it to finish..." + ) + while is_fast_installing(app_id): + time.sleep(_WAIT_POLL_SECONDS) + poll_fast_installs() + return app_id not in _FAILED + + def fast_install(app_id: int, label: str) -> bool: """Install *app_id* with steam-game-installer. @@ -215,7 +228,7 @@ def fast_install(app_id: int, label: str) -> bool: if installer is None: logger.warning("steam-game-installer not found; using Steam's download.") return False - reason = _refusal(app_id) + reason = _refusal() if reason is not None: _echo(f" Fast install of {label} skipped: {reason}.") return False @@ -223,7 +236,7 @@ def fast_install(app_id: int, label: str) -> bool: cmd = _build_cmd(installer, app_id, user) if not sys.stdin.isatty(): _spawn_detached(cmd, app_id) - return True + return os.geteuid() == 0 or _wait_detached(app_id, label) _echo(f" Fast-installing {label} with steam-game-installer (Steam will close)...") returncode = subprocess.run(cmd, check=False).returncode diff --git a/steam_backlog_enforcer/_steam_state.py b/steam_backlog_enforcer/_steam_state.py index db38729..96f5121 100644 --- a/steam_backlog_enforcer/_steam_state.py +++ b/steam_backlog_enforcer/_steam_state.py @@ -79,7 +79,7 @@ def _manifest_transfer_active(manifest: Path) -> bool: ) > _field("BytesStaged") -def steam_update_in_progress(exclude_app_id: int | None = None) -> bool: +def steam_update_in_progress() -> bool: """Return True if any installed Steam app is mid-update. Steam records live transfer progress in each ``appmanifest_*.acf``: a game @@ -90,19 +90,14 @@ def steam_update_in_progress(exclude_app_id: int | None = None) -> bool: failure that corrupted Age of Empires II DE into a deterministic launch crash). - Args: - exclude_app_id: An app whose own transfer does not count - the one a - fast install is about to take over. - Returns: True if at least one appmanifest shows an unfinished download/stage. """ - skip = f"appmanifest_{exclude_app_id}.acf" try: manifests = list(STEAMAPPS_PATH.glob("appmanifest_*.acf")) except OSError: return False - return any(_manifest_transfer_active(m) for m in manifests if m.name != skip) + return any(_manifest_transfer_active(m) for m in manifests) def is_game_fully_installed(app_id: int) -> bool: diff --git a/steam_backlog_enforcer/game_uninstall.py b/steam_backlog_enforcer/game_uninstall.py index 843f312..4a5c5dd 100644 --- a/steam_backlog_enforcer/game_uninstall.py +++ b/steam_backlog_enforcer/game_uninstall.py @@ -146,17 +146,22 @@ def uninstall_other_games(allowed_app_ids: set[int]) -> int: Returns: number of games uninstalled. """ - installed = get_installed_games() - count = 0 - - for app_id, name in tracked(installed, "Uninstalling games", lambda g: g[1]): + to_remove: list[tuple[int, str]] = [] + kept_protected: list[str] = [] + for app_id, name in get_installed_games(): if app_id in allowed_app_ids: logger.info("KEEPING allowed game: %s (AppID=%d)", name, app_id) - continue - if is_protected_app(app_id): - logger.debug("Skipping protected: %s (AppID=%d)", name, app_id) - continue + elif is_protected_app(app_id): + kept_protected.append(name) + else: + to_remove.append((app_id, name)) + if kept_protected: + logger.info("KEEPING protected: %s", ", ".join(kept_protected)) + # Only removals go through the progress bar: it names the item it just + # finished, and naming a kept runtime there reads as "removed it". + count = 0 + for app_id, name in tracked(to_remove, "Uninstalling games", lambda g: g[1]): logger.info("UNINSTALLING: %s (AppID=%d)", name, app_id) if uninstall_game(app_id, name): count += 1 diff --git a/steam_backlog_enforcer/main/install.py b/steam_backlog_enforcer/main/install.py index b841591..30f990f 100644 --- a/steam_backlog_enforcer/main/install.py +++ b/steam_backlog_enforcer/main/install.py @@ -7,7 +7,11 @@ from typing import TYPE_CHECKING from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids from steam_backlog_enforcer._progress import current_progress -from steam_backlog_enforcer._prompter import confirm_phrase +from steam_backlog_enforcer._prompter import ( + PromptOption, + confirm_phrase, + current_prompter, +) from steam_backlog_enforcer._steam_errors import SteamUnavailableError from steam_backlog_enforcer._steam_state import is_game_fully_installed from steam_backlog_enforcer.game_install import ( @@ -73,29 +77,48 @@ def cmd_uninstall(_config: Config, state: State) -> None: _echo(f"\nUninstalled {count} games.") +def _pick_install_target(state: State) -> tuple[int, str] | None: + """The allowed game to install: the only unfinished one, or the user's pick. + + Every allowed game counts, not just the assignment: a manual pick whose + fast install was skipped (a game was running at pick time) needs a way to + start it later too. + """ + pending = [ + (aid, n) for aid, n in allowed_games(state) if not is_game_fully_installed(aid) + ] + if not pending: + _echo("All allowed games are already installed.") + return None + if len(pending) == 1: + return pending[0] + options = [PromptOption(str(aid), name or f"AppID={aid}") for aid, name in pending] + picked = int(current_prompter().choice("Install which game", options)) + return next(game for game in pending if game[0] == picked) + + def cmd_install(config: Config, state: State) -> None: - """Manually trigger install of the assigned game.""" - if state.current_app_id is None: + """Install an allowed game, through the fast installer when it can be used. + + A download Steam already started is taken over, so this is also how a + fast install that was skipped earlier gets started. + """ + if not allowed_games(state): _echo("No game currently assigned. Run 'scan' first.") return - - if is_game_fully_installed(state.current_app_id): - _echo(f"{state.current_game_name} is already installed.") + target = _pick_install_target(state) + if target is None: return + app_id, name = target - _echo(f"Installing {state.current_game_name} (AppID={state.current_app_id})...") + _echo(f"Installing {name} (AppID={app_id})...") # install_game only *starts* the download (fast installer or steam://); # there is no download-progress loop to report on yet. - current_progress().phase(f"Starting install of {state.current_game_name}") - if install_game( - state.current_app_id, - state.current_game_name, - config.steam_id, - use_steam_protocol=True, - ): + current_progress().phase(f"Starting install of {name}") + if install_game(app_id, name, config.steam_id, use_steam_protocol=True): _echo("Done!") else: - _echo("Failed to create install manifest.") + _echo("Failed to start the install.") def cmd_hide(config: Config, state: State) -> None: diff --git a/steam_backlog_enforcer/tests/test_fast_install_guards.py b/steam_backlog_enforcer/tests/test_fast_install_guards.py index 1c7f5cd..0852aa7 100644 --- a/steam_backlog_enforcer/tests/test_fast_install_guards.py +++ b/steam_backlog_enforcer/tests/test_fast_install_guards.py @@ -111,10 +111,9 @@ class TestFullyInstalled: path.write_text('"AppState"\n{\n}\n') assert _steam_state.is_game_fully_installed(1) is False - def test_update_check_can_exclude_the_target(self) -> None: + def test_update_check_sees_a_transfer(self) -> None: _manifest(7, 1026, to_download=10) assert _steam_state.steam_update_in_progress() is True - assert _steam_state.steam_update_in_progress(exclude_app_id=7) is False class TestInstallGameFastPath: diff --git a/steam_backlog_enforcer/tests/test_fast_install_helpers.py b/steam_backlog_enforcer/tests/test_fast_install_helpers.py index ca31691..0e4fcea 100644 --- a/steam_backlog_enforcer/tests/test_fast_install_helpers.py +++ b/steam_backlog_enforcer/tests/test_fast_install_helpers.py @@ -10,7 +10,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch -from steam_backlog_enforcer import _fast_install +from steam_backlog_enforcer import _fast_install, _steam_state PKG = "steam_backlog_enforcer._fast_install" @@ -71,22 +71,18 @@ class TestRefusal: def test_game_running(self) -> None: with patch(f"{PKG}.game_is_running", return_value=True): - assert "game is running" in (_fast_install._refusal(1) or "") + assert "game is running" in (_fast_install._refusal() or "") - def test_other_update_in_progress(self) -> None: - with ( - patch(f"{PKG}.game_is_running", return_value=False), - patch(f"{PKG}.steam_update_in_progress", return_value=True) as update, - ): - assert "download is in progress" in (_fast_install._refusal(7) or "") - update.assert_called_once_with(exclude_app_id=7) - - def test_safe(self) -> None: - with ( - patch(f"{PKG}.game_is_running", return_value=False), - patch(f"{PKG}.steam_update_in_progress", return_value=False), - ): - assert _fast_install._refusal(7) is None + def test_other_download_does_not_refuse(self) -> None: + # The installer closes Steam with ``steam -shutdown``, which Steam + # survives mid-download; another transfer is no reason to skip. + (_steam_state.STEAMAPPS_PATH / "appmanifest_9.acf").write_text( + '"AppState"\n{\n\t"BytesToDownload"\t\t"10"\n' + '\t"BytesDownloaded"\t\t"0"\n}\n' + ) + assert _steam_state.steam_update_in_progress() is True + with patch(f"{PKG}.game_is_running", return_value=False): + assert _fast_install._refusal() is None class TestBuildCmd: diff --git a/steam_backlog_enforcer/tests/test_fast_install_job_wait.py b/steam_backlog_enforcer/tests/test_fast_install_job_wait.py new file mode 100644 index 0000000..e433b74 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_fast_install_job_wait.py @@ -0,0 +1,84 @@ +"""Tests for a fast install started outside the daemon (a web job, a pipe). + +Only the root daemon reaps detached runs on its later passes. Any other +process exits once the command returns, so it must wait for the installer +itself - otherwise a stalled login prompt would hold Steam closed with +nobody left to interrupt it. Its log must also be its own: the daemon's +log is root-owned, and opening it as the desktop user used to raise. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from steam_backlog_enforcer import _fast_install, config + +if TYPE_CHECKING: + from pathlib import Path + +PKG = "steam_backlog_enforcer._fast_install" + + +def _ready(tmp_path: Path) -> Path: + installer = tmp_path / "run.sh" + installer.write_text("#!/bin/bash\n") + return installer + + +def _fake_spawn(*returncodes: int | None) -> MagicMock: + """A ``_spawn_detached`` that registers a run answering *returncodes*.""" + + def spawn(_cmd: list[str], app_id: int) -> None: + proc = MagicMock(pid=4321) + proc.poll.side_effect = list(returncodes) + _fast_install._RUNS.append( + _fast_install._DetachedRun(app_id, proc, config.CONFIG_DIR / "x.log") + ) + + return MagicMock(side_effect=spawn) + + +class TestJobWaitsForInstaller: + """A non-root, non-terminal caller blocks until the run is reaped.""" + + def _install(self, tmp_path: Path, spawn: MagicMock, euid: int) -> bool: + with ( + patch(f"{PKG}._installer_path", return_value=_ready(tmp_path)), + patch(f"{PKG}._refusal", return_value=None), + patch(f"{PKG}.sys.stdin.isatty", return_value=False), + patch(f"{PKG}.os.geteuid", return_value=euid), + patch(f"{PKG}._spawn_detached", spawn), + patch(f"{PKG}.time.sleep"), + patch(f"{PKG}._echo"), + ): + return _fast_install.fast_install(7, "G") + + def test_success_after_waiting(self, tmp_path: Path) -> None: + assert self._install(tmp_path, _fake_spawn(None, None, 0), 1000) is True + assert _fast_install._RUNS == [] + + def test_failure_falls_back(self, tmp_path: Path) -> None: + assert self._install(tmp_path, _fake_spawn(None, 3), 1000) is False + assert {7} == _fast_install._FAILED + + def test_daemon_returns_at_once(self, tmp_path: Path) -> None: + assert self._install(tmp_path, _fake_spawn(), 0) is True + assert _fast_install.is_fast_installing(7) + + +class TestLogIsPerUser: + """A root-owned daemon log never blocks the desktop user's spawn.""" + + def test_unwritable_daemon_log_is_not_used(self) -> None: + config.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + daemon_log = config.CONFIG_DIR / "fast_install.log" + daemon_log.write_text("root's\n") + daemon_log.chmod(0o400) + with ( + patch(f"{PKG}.os.geteuid", return_value=1000), + patch(f"{PKG}.subprocess.Popen") as popen, + ): + _fast_install._spawn_detached(["run.sh"], 9) + popen.assert_called_once() + assert _fast_install._RUNS[0].log_path != daemon_log diff --git a/steam_backlog_enforcer/tests/test_fast_install_runs.py b/steam_backlog_enforcer/tests/test_fast_install_runs.py index f122f5a..db16c67 100644 --- a/steam_backlog_enforcer/tests/test_fast_install_runs.py +++ b/steam_backlog_enforcer/tests/test_fast_install_runs.py @@ -86,7 +86,7 @@ class TestSpawnDetached: assert kwargs["start_new_session"] is True run = _fast_install._RUNS[0] assert run.app_id == 9 - assert run.log_path == config.CONFIG_DIR / "fast_install.log" + assert run.log_path == config.CONFIG_DIR / "fast_install-1000.log" assert run.log_path.exists() def test_as_root_escapes_the_service_cgroup(self) -> None: diff --git a/steam_backlog_enforcer/tests/test_game_install_part2.py b/steam_backlog_enforcer/tests/test_game_install_part2.py index 10edf33..99f79ef 100644 --- a/steam_backlog_enforcer/tests/test_game_install_part2.py +++ b/steam_backlog_enforcer/tests/test_game_install_part2.py @@ -12,6 +12,7 @@ from steam_backlog_enforcer.game_uninstall import ( ) if TYPE_CHECKING: + from collections.abc import Callable, Iterator from pathlib import Path PKG = "steam_backlog_enforcer.game_install" @@ -165,3 +166,31 @@ class TestUninstallOtherGames: count = uninstall_other_games({440}) assert count == 0 mock_uninstall.assert_not_called() + + +class TestUninstallProgressNames: + """The progress bar names only games that are actually removed.""" + + def test_protected_and_allowed_never_shown(self) -> None: + shown: list[str] = [] + + def fake_tracked( + items: list[tuple[int, str]], + _label: str, + name: Callable[[tuple[int, str]], str], + ) -> Iterator[tuple[int, str]]: + for item in items: + yield item + shown.append(name(item)) + + with ( + patch( + f"{GAME_UNINSTALL_PKG}.get_installed_games", + return_value=[(440, "TF2"), (730, "CS"), (228980, "Redist")], + ), + patch(f"{GAME_UNINSTALL_PKG}.tracked", side_effect=fake_tracked), + patch(f"{GAME_UNINSTALL_PKG}.uninstall_game", return_value=True), + ): + count = uninstall_other_games({440}) + assert count == 1 + assert shown == ["CS"] diff --git a/steam_backlog_enforcer/tests/test_main_install_pick.py b/steam_backlog_enforcer/tests/test_main_install_pick.py new file mode 100644 index 0000000..f18f667 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_install_pick.py @@ -0,0 +1,62 @@ +"""Tests for ``install`` choosing which allowed game to (fast-)install. + +A fast install that was skipped at pick time must be startable later, for the +assignment and for every manual pick alike. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer._prompter import use_prompter +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.main import cmd_install +from steam_backlog_enforcer.tests._fake_prompter import FakePrompter + +PKG = "steam_backlog_enforcer.main.install" + + +def _state() -> State: + return State( + current_app_id=1, + current_game_name="Assigned", + manual_picks=[{"app_id": 2, "game_name": "Picked"}], + ) + + +class TestInstallPicksGame: + """Every allowed game that is not fully installed is offered.""" + + def test_only_unfinished_game_installed_without_asking(self) -> None: + prompter = FakePrompter() + with ( + use_prompter(prompter), + patch(f"{PKG}.is_game_fully_installed", side_effect=lambda a: a == 1), + patch(f"{PKG}.install_game", return_value=True) as install, + patch(f"{PKG}._echo"), + ): + cmd_install(Config(steam_id="i"), _state()) + assert prompter.asked == [] + assert install.call_args.args[:2] == (2, "Picked") + + def test_several_unfinished_games_prompt(self) -> None: + prompter = FakePrompter(choices=["2"]) + with ( + use_prompter(prompter), + patch(f"{PKG}.is_game_fully_installed", return_value=False), + patch(f"{PKG}.install_game", return_value=True) as install, + patch(f"{PKG}._echo"), + ): + cmd_install(Config(steam_id="i"), _state()) + assert prompter.asked[0][1] == ["1", "2"] + assert install.call_args.args[:2] == (2, "Picked") + + def test_all_installed(self) -> None: + with ( + patch(f"{PKG}.is_game_fully_installed", return_value=True), + patch(f"{PKG}.install_game") as install, + patch(f"{PKG}._echo") as echo, + ): + cmd_install(Config(steam_id="i"), _state()) + install.assert_not_called() + assert any("already installed" in str(c) for c in echo.call_args_list)