From 1bdf3601934113629fdf4e721856ccdf33a627e9 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 23 Oct 2026 20:14:12 +0200 Subject: [PATCH] pick keeps already-picked games; fast install reports progress pick and pick-manual no longer uninstall games that are already allowed: the outgoing assignment is kept as a released pick (installed, no lock) until 'pick --replace', done/check, or abandon-pick. The allowed set is capped at max_manual_picks + 1; a pick over it is refused before the candidate list / confirmation, naming the games. status and the web Picks card list kept games with an Abandon button. Fast install now tracks the installer log from the spawn offset: web jobs drive the progress bar (per-depot %, phase) and echo a line every 30 s; daemon-started runs are exposed via ctl status / /api/daemon and shown on the Dashboard. Also: web deps bumped for dependency freshness (react-query 5.104.5, typescript-eslint 8.72.0, vite 8.3.5, node 24.22.1 in CI). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CUaX4zAa6H4sMu3jd6RFdb --- .github/workflows/python-tests.yml | 2 +- DOCS-web-control-api.md | 5 +- steam_backlog_enforcer/_actions.py | 27 +- steam_backlog_enforcer/_allowed_games.py | 13 +- steam_backlog_enforcer/_command_help.py | 2 +- steam_backlog_enforcer/_command_params.py | 20 +- steam_backlog_enforcer/_ctl_client.py | 17 +- steam_backlog_enforcer/_ctl_ops.py | 11 +- steam_backlog_enforcer/_ctl_types.py | 17 + steam_backlog_enforcer/_fast_install.py | 40 +-- .../_fast_install_progress.py | 226 +++++++++++++ .../_manual_pick_lifecycle.py | 6 +- steam_backlog_enforcer/_mcp_actions.py | 6 +- steam_backlog_enforcer/_pick_keep.py | 117 +++++++ steam_backlog_enforcer/_scanning_assign.py | 18 +- steam_backlog_enforcer/_web_daemon.py | 3 + steam_backlog_enforcer/_web_views.py | 4 + .../docs/DOCS-manual-pick-design.md | 16 +- steam_backlog_enforcer/jobs/_registry.py | 11 +- steam_backlog_enforcer/main/__init__.py | 3 + steam_backlog_enforcer/main/abandon.py | 23 +- steam_backlog_enforcer/main/picks.py | 55 +++- steam_backlog_enforcer/main/status.py | 10 + steam_backlog_enforcer/scanning.py | 16 +- .../tests/_no_fast_install.py | 4 +- .../tests/fast_install_log_excerpt.txt | 100 ++++++ .../tests/test_actions_abandon_manual_pick.py | 6 +- .../tests/test_ctl_client.py | 20 +- steam_backlog_enforcer/tests/test_ctl_ops.py | 20 +- .../tests/test_fast_install_job_wait.py | 47 ++- .../tests/test_fast_install_progress.py | 236 ++++++++++++++ .../tests/test_main_pick_cap.py | 5 +- .../tests/test_main_pick_keep.py | 206 ++++++++++++ .../tests/test_main_picks.py | 4 +- .../tests/test_pick_keep.py | 185 +++++++++++ .../tests/test_pick_manual_keep.py | 176 ++++++++++ .../tests/test_release_lifecycle.py | 6 +- .../tests/test_status_kept_picks.py | 72 +++++ .../tests/test_web_daemon.py | 27 +- web/mock/catalog.ts | 8 +- web/mock/http.ts | 1 + web/mock/scripts.ts | 7 + web/mock/world.ts | 5 + web/package-lock.json | 302 +++++++++--------- web/package.json | 6 +- web/src/api/contract.ts | 26 +- web/src/commands/ParamsForm.test.tsx | 16 + web/src/commands/ParamsForm.tsx | 20 ++ web/src/commands/RunnerDialog.tsx | 2 +- web/src/commands/params.test.ts | 10 + web/src/commands/params.ts | 2 + web/src/screens/Dashboard.test.tsx | 31 ++ web/src/screens/Dashboard.tsx | 26 +- web/src/screens/Picks.tsx | 14 +- web/src/screens/Small.test.tsx | 15 + web/src/test/fixtures.ts | 1 + 56 files changed, 2023 insertions(+), 251 deletions(-) create mode 100644 steam_backlog_enforcer/_fast_install_progress.py create mode 100644 steam_backlog_enforcer/_pick_keep.py create mode 100644 steam_backlog_enforcer/tests/fast_install_log_excerpt.txt create mode 100644 steam_backlog_enforcer/tests/test_fast_install_progress.py create mode 100644 steam_backlog_enforcer/tests/test_main_pick_keep.py create mode 100644 steam_backlog_enforcer/tests/test_pick_keep.py create mode 100644 steam_backlog_enforcer/tests/test_pick_manual_keep.py create mode 100644 steam_backlog_enforcer/tests/test_status_kept_picks.py diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 28b387d..51d43fa 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -51,7 +51,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 24.22.0 + node-version: 24.22.1 cache: npm cache-dependency-path: web/package-lock.json diff --git a/DOCS-web-control-api.md b/DOCS-web-control-api.md index 0a35409..0e345f6 100644 --- a/DOCS-web-control-api.md +++ b/DOCS-web-control-api.md @@ -50,7 +50,7 @@ mirror is `web/src/api/contract.ts`; change both together or neither. | GET | `/api/commands` | – | `CommandSpec[]` — server-driven catalog | | GET | `/api/setup` | – | `SetupStatus` (configured flags, no secrets) | | POST | `/api/setup` | `SetupRequest` | `SetupStatus` (validated live against Steam) | -| GET | `/api/daemon` | – | `DaemonStatus` (state, uptime, journal tail) | +| GET | `/api/daemon` | – | `DaemonStatus` (state, uptime, journal tail, fast installs) | | GET | `/api/server` | – | `ServerHealth` (stale?, started_at, version) | | POST | `/api/server/restart` | – | `202`; the user unit restarts the process | | GET | `/api/backups` | – | `StateBackup[]` | @@ -205,6 +205,9 @@ hard-codes it. `gaming_unblock{phrase}`, `gaming_reset_arm{phrase}`, `gaming_reset_heartbeat{pending_id}`, `gaming_reset_commit{pending_id, phrase}`, `gaming_reset_cancel{pending_id}`, `restart{}`. There is no `stop`. +- `status` also lists the daemon's live steam-game-installer runs as + `fast_installs: [{app_id, name, phase, percent, elapsed_seconds}]`, read + from its log by the enforce loop; `percent` is the current depot's, or null. ## Verification record (2026-10-10) diff --git a/steam_backlog_enforcer/_actions.py b/steam_backlog_enforcer/_actions.py index e7eedfd..dc16cff 100644 --- a/steam_backlog_enforcer/_actions.py +++ b/steam_backlog_enforcer/_actions.py @@ -15,9 +15,12 @@ from typing import TYPE_CHECKING, Any from steam_backlog_enforcer._allowed_games import ( active_manual_picks, allowed_games, - drop_inactive_picks, ) from steam_backlog_enforcer._assignment_progress import record_assignment +from steam_backlog_enforcer._pick_keep import ( + allowed_cap_refusal, + keep_outgoing_assignment, +) # Marks these two as an intentional re-export (they're imported from # _allowed_games above, not defined here) -- without this, mypy's @@ -120,18 +123,23 @@ def apply_manual_pick( its interactive ``YES`` confirmation. Keeping this state-only means an automated caller (the MCP server) can never wipe installed games. - Picking is an explicit choice of another game, so released, finished and - expired picks are dropped here (and enforcement may then remove them). + The games already allowed are kept, like plain ``pick``: released, + finished and expired picks stay, and the outgoing assignment is parked as + a released pick. So the pick is refused when it would push the allowed + set over ``max_picks + 1`` (:func:`allowed_cap_refusal`). Re-picking a + kept game turns its entry back into an active pick instead of adding one. Args: state: The enforcer state to mutate and save. app_id: The Steam app id to lock in. game_name: Human-readable name for the picked game. - max_picks: How many picks may be active at once. + max_picks: How many picks may be active at once; the allowed set + may hold one more game (the assignment). Returns: ``None`` on success, or a message explaining why the pick was refused - (already picked, or no slots left), in which case nothing was saved. + (already picked, no active slot left, or the allowed set is full), in + which case nothing was changed. """ active = active_manual_picks(state) @@ -145,8 +153,15 @@ def apply_manual_pick( f"(cap is {max_picks}): {names}." ) + refused = allowed_cap_refusal(state, max_picks, adding=app_id) + if refused is not None: + return refused + now = datetime.now(UTC).isoformat() - drop_inactive_picks(state) + # A kept entry for this game is replaced by the active one, not doubled. + state.manual_picks = [p for p in state.manual_picks if p.get("app_id") != app_id] + if state.current_app_id != app_id: + keep_outgoing_assignment(state) state.manual_picks.append( {"app_id": app_id, "game_name": game_name, "started_at": now} ) diff --git a/steam_backlog_enforcer/_allowed_games.py b/steam_backlog_enforcer/_allowed_games.py index f2658d5..836a151 100644 --- a/steam_backlog_enforcer/_allowed_games.py +++ b/steam_backlog_enforcer/_allowed_games.py @@ -24,8 +24,8 @@ def _pick_is_active(state: State, pick: dict[str, Any]) -> bool: """Return ``True`` if *pick* still holds the lock and a pick slot. An inactive pick is *not* evicted: it stays in ``state.manual_picks``, and - so installed and playable, until the user explicitly chooses another game - (:func:`drop_inactive_picks`). + so installed and playable, until the user explicitly replaces it + (:func:`drop_inactive_picks`) or abandons it. A pick stops being active once it earned a new achievement since it was picked (``released_at``), once its game is 100% finished, or once @@ -75,9 +75,10 @@ def drop_inactive_picks(state: State) -> list[dict[str, Any]]: """Forget every pick that no longer holds the lock; return the dropped ones. Released, finished and expired picks stay installed and playable for as - long as the user wants. Only an explicit choice of another game (a new - manual pick, or accepting the next assignment) calls this and so lets - enforcement remove them. Does not save. + long as the user wants. Only an explicit replacement (``pick --replace``, + or accepting the next assignment in ``done``/``check``/``scan``) calls + this and so lets enforcement remove them; plain ``pick`` and + ``pick-manual`` keep them. Does not save. Args: state: The enforcer state to mutate. @@ -96,7 +97,7 @@ def allowed_games(state: State) -> list[tuple[int, str]]: That is the current assignment plus every stored manual pick, including released and expired ones: a game is only removed once the user has - explicitly chosen something else. + explicitly replaced or abandoned it. Args: state: The loaded enforcer state. diff --git a/steam_backlog_enforcer/_command_help.py b/steam_backlog_enforcer/_command_help.py index 8a8c3fd..eda6c34 100644 --- a/steam_backlog_enforcer/_command_help.py +++ b/steam_backlog_enforcer/_command_help.py @@ -27,7 +27,7 @@ COMMAND_DESCRIPTIONS: Final[dict[str, str]] = { "uninstall": "Uninstall all non-assigned games", "setup": "Run first-time setup", "done": "Move on after a new achievement, pick next", - "pick": "Manually pick your next game from candidates", + "pick": "Pick another game from candidates (keeps current; --replace swaps)", "stats": "Show backlog completion-time estimates", "gaming-status": "Show today's gaming time and block state", "gaming-reset": "Reset today's gaming counter (root + phrase)", diff --git a/steam_backlog_enforcer/_command_params.py b/steam_backlog_enforcer/_command_params.py index 78c21dd..de47628 100644 --- a/steam_backlog_enforcer/_command_params.py +++ b/steam_backlog_enforcer/_command_params.py @@ -19,7 +19,7 @@ from steam_backlog_enforcer._store_window import ( if TYPE_CHECKING: from collections.abc import Mapping -ParamType = Literal["int", "string", "app_id"] +ParamType = Literal["int", "string", "app_id", "bool"] ParamValue = int | str @@ -54,6 +54,17 @@ class ParamSpec: _APP_ID = ParamSpec("app_id", "Steam app id", "app_id", min=1) PARAMS: Final[dict[str, tuple[ParamSpec, ...]]] = { + # Unchecked keeps the games already allowed; checked is `pick --replace`. + "pick": ( + ParamSpec( + "replace", + "Replace current games (uninstalls them)", + "bool", + required=False, + default=0, + help="Leave unchecked to keep playing them alongside the new pick.", + ), + ), "pick-manual": (_APP_ID,), "abandon-pick": (_APP_ID,), "add-exception": ( @@ -106,6 +117,13 @@ def _coerce(spec: ParamSpec, raw: object) -> ParamValue: msg = f"{spec.name} must be a non-empty string" raise InvalidParamsError(msg) return raw.strip() + if spec.type == "bool": + # A checkbox; carried as 0/1 so ParamValue stays int | str. + # True/False are ints too, so this accepts true, false, 0 and 1. + if isinstance(raw, int) and raw in (0, 1): + return int(raw) + msg = f"{spec.name} must be true or false" + raise InvalidParamsError(msg) # bool is an int subclass in Python; true/false is never a count. if isinstance(raw, bool) or not isinstance(raw, (int, str)): msg = f"{spec.name} must be a whole number" diff --git a/steam_backlog_enforcer/_ctl_client.py b/steam_backlog_enforcer/_ctl_client.py index 42b111c..90e88d1 100644 --- a/steam_backlog_enforcer/_ctl_client.py +++ b/steam_backlog_enforcer/_ctl_client.py @@ -26,6 +26,7 @@ from steam_backlog_enforcer._ctl_protocol import ( from steam_backlog_enforcer._ctl_types import ( BlockResult, DaemonInfo, + FastInstallInfo, PendingReset, ResetResult, StoreWindow, @@ -130,15 +131,29 @@ def ping() -> None: call("ping") +def _fast_install_info(run: dict[str, object]) -> FastInstallInfo: + """One ``status.fast_installs`` entry, typed.""" + percent = run.get("percent") + return FastInstallInfo( + app_id=int(cast("int", run["app_id"])), + name=str(run["name"]), + phase=str(run["phase"]), + percent=None if percent is None else float(cast("float", percent)), + elapsed_seconds=int(cast("int", run["elapsed_seconds"])), + ) + + def status() -> DaemonInfo: - """The daemon's state, start time, pid and next-restart time.""" + """The daemon's state, start time, pid, next-restart time and fast installs.""" data = call("status") until = data.get("restart_available_at") + runs = cast("list[dict[str, object]]", data.get("fast_installs") or []) return DaemonInfo( state=str(data["state"]), started_at=str(data["started_at"]), pid=int(cast("int", data["pid"])), restart_available_at=str(until) if until else None, + fast_installs=tuple(map(_fast_install_info, runs)), ) diff --git a/steam_backlog_enforcer/_ctl_ops.py b/steam_backlog_enforcer/_ctl_ops.py index 5ed3d4e..e3d023f 100644 --- a/steam_backlog_enforcer/_ctl_ops.py +++ b/steam_backlog_enforcer/_ctl_ops.py @@ -13,6 +13,7 @@ import contextlib import logging import os import subprocess +import time from typing import TYPE_CHECKING, Final from steam_backlog_enforcer._ctl_context import ( @@ -24,6 +25,7 @@ from steam_backlog_enforcer._ctl_context import ( from steam_backlog_enforcer._ctl_protocol import OP_FAILED, CtlError from steam_backlog_enforcer._ctl_reset import RESET_OPS from steam_backlog_enforcer._ctl_restart import RESTART_OPS, restart_available_at +from steam_backlog_enforcer._fast_install_progress import fast_install_status from steam_backlog_enforcer._playtime_block import release_block from steam_backlog_enforcer._store_window import MAX_WINDOW_MINUTES, open_store_window from steam_backlog_enforcer._total_block import ( @@ -56,12 +58,19 @@ def op_ping(_ctx: CtlContext, _args: dict[str, object]) -> dict[str, object]: def op_status(ctx: CtlContext, _args: dict[str, object]) -> dict[str, object]: - """The daemon's state, start time, pid and when a restart is next allowed.""" + """The daemon's state, start time, pid, next restart and fast installs. + + ``fast_installs`` are the steam-game-installer runs the daemon started, + as of the enforce loop's last read of their log: this thread never reads + the file. It rides on ``status`` rather than a file of its own because the + web server runs as the desktop user and a root-owned file is no use to it. + """ return { "state": "restarting" if ctx.restart_event.is_set() else "running", "started_at": ctx.started_at.isoformat(), "pid": os.getpid(), "restart_available_at": restart_available_at(), + "fast_installs": fast_install_status(time.monotonic()), } diff --git a/steam_backlog_enforcer/_ctl_types.py b/steam_backlog_enforcer/_ctl_types.py index dbfd193..6a3de4f 100644 --- a/steam_backlog_enforcer/_ctl_types.py +++ b/steam_backlog_enforcer/_ctl_types.py @@ -9,6 +9,21 @@ from __future__ import annotations from dataclasses import dataclass +@dataclass(frozen=True) +class FastInstallInfo: + """A steam-game-installer run the daemon started, as far as its log tells. + + ``percent`` is the current depot's (``None`` until one is known); ``phase`` + names the depot, e.g. ``downloading depot 3/8``. + """ + + app_id: int + name: str + phase: str + percent: float | None + elapsed_seconds: int + + @dataclass(frozen=True) class DaemonInfo: """The daemon's own account of itself (``status``).""" @@ -17,6 +32,8 @@ class DaemonInfo: started_at: str pid: int restart_available_at: str | None + # Empty from a daemon too old to report them. + fast_installs: tuple[FastInstallInfo, ...] = () @dataclass(frozen=True) diff --git a/steam_backlog_enforcer/_fast_install.py b/steam_backlog_enforcer/_fast_install.py index 604088a..1aedd3b 100644 --- a/steam_backlog_enforcer/_fast_install.py +++ b/steam_backlog_enforcer/_fast_install.py @@ -7,21 +7,19 @@ taking over a slow client download loses nothing. 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** (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. +* **Foreground** (stdin is a terminal - ``pick-manual``, ``done``...): run with + the terminal attached, so depotdownloader's progress shows and a password / + Steam Guard prompt can be answered. +* **Detached** (no terminal): spawn in its own session. The root daemon returns + at once; :func:`poll_fast_installs` reaps the run on later passes, records a + failure (the caller falls back to ``steam://install``) and interrupts a run + whose output stalled - likely a login prompt nobody will answer, with Steam + shut down. A web job loops that reaper until the run ends. Either way the + log is followed for progress (:mod:`_fast_install_progress`). -The installer closes Steam to do its work, so it is refused outright while a -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. +The installer closes Steam, so it is refused while a game is running. Another +app mid-download is no reason to wait: asked to leave with ``steam -shutdown``, +Steam resumes the transfer when it comes back. """ from __future__ import annotations @@ -38,6 +36,7 @@ import subprocess import sys import time +from steam_backlog_enforcer import _fast_install_progress as _fip 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 @@ -176,9 +175,10 @@ def poll_fast_installs() -> None: returncode, run.log_path, ) + _fip.poll_trackers({run.app_id for run in _RUNS}) -def _spawn_detached(cmd: list[str], app_id: int) -> None: +def _spawn_detached(cmd: list[str], app_id: int, label: str = "") -> None: """Start the installer in its own session, output to a log file.""" # 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. @@ -191,6 +191,8 @@ def _spawn_detached(cmd: list[str], app_id: int) -> None: if os.geteuid() == 0 and shutil.which("systemd-run"): cmd = ["systemd-run", "--scope", "--quiet", "--collect", *cmd] with log_path.open("ab") as log: + # Append mode starts at the end: earlier runs' lines are never parsed. + _fip.track(app_id, label, log_path, offset=log.tell(), now=time.monotonic()) proc = subprocess.Popen( cmd, stdin=subprocess.DEVNULL, @@ -204,13 +206,11 @@ def _spawn_detached(cmd: list[str], app_id: int) -> None: 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..." - ) + reporter = _fip.WaitReporter(app_id, label, started=time.monotonic()) while is_fast_installing(app_id): time.sleep(_WAIT_POLL_SECONDS) poll_fast_installs() + reporter.report(time.monotonic()) return app_id not in _FAILED @@ -235,7 +235,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) + _spawn_detached(cmd, app_id, label) return os.geteuid() == 0 or _wait_detached(app_id, label) _echo(f" Fast-installing {label} with steam-game-installer (Steam will close)...") diff --git a/steam_backlog_enforcer/_fast_install_progress.py b/steam_backlog_enforcer/_fast_install_progress.py new file mode 100644 index 0000000..19a204d --- /dev/null +++ b/steam_backlog_enforcer/_fast_install_progress.py @@ -0,0 +1,226 @@ +"""Live progress of a detached steam-game-installer run, read from its log. + +The installer runs depotdownloader on a pty and flushes every chunk, so the +per-user ``fast_install-.log`` grows while the download runs (CRLF line +endings, from the pty). Its markers, verbatim from a real run: + +* ``Shutting down the Steam client...`` +* ``Steam already staged 71.53 GB for this app ...`` (adopting a partial download) +* ``Downloading via depotdownloader (fast path)...`` (connecting, logging in) +* ``Processing depot 1086941`` once per depot, then ``Downloading depot 1086941`` + as each one starts +* `` 46.90% /path/of/finished/file (657.13 Mbit/s)`` per finished file +* ``Checking for owned DLC ...``, then the manifest is finished +* ``Restarting the Steam client...`` + +The percentage is depotdownloader's progress through the *current depot*: it +restarts at 0 for every depot. Depots are wildly uneven (one Baldur's Gate 3 +depot is 128 of its 132 GB), so averaging over depot count would lie; the +phase names the depot instead (``downloading depot 3/8``) and the percent +belongs to it. + +The log is opened in append mode and keeps every earlier run, so a tracker +reads only bytes past the offset recorded at spawn, and only the new ones on +each poll: the log of a large game runs to tens of megabytes. + +Who reads it: a waiting web job (:class:`WaitReporter`, through the Progress +API and a line every :data:`ECHO_SECONDS`), and the root daemon's ctl +``status`` op (:func:`fast_install_status`), which never touches the file - +the daemon's own enforce loop polls the trackers. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +import threading +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._echo import _echo +from steam_backlog_enforcer._progress import current_progress + +if TYPE_CHECKING: + from pathlib import Path + +STARTING: Final = "starting" +_DOWNLOADING: Final = "downloading" +# Line prefix -> the phase it starts. +_MARKERS: Final = ( + ("Shutting down the Steam client", "shutting Steam down"), + ("Steam already staged", "adopting Steam's partial download"), + ("Downloading via depotdownloader", "connecting to Steam"), + ("Checking for owned DLC", "finishing manifest"), + ("Reconciling with steamcmd", "finishing manifest"), + ("Restarting the Steam client", "restarting Steam"), +) +_PLANNED_DEPOT: Final = re.compile(r"Processing depot \d+$") +_STARTED_DEPOT: Final = re.compile(r"Downloading depot \d+$") +_PERCENT: Final = re.compile(r"\s*(\d{1,3}\.\d+)% ") +# A waiting job prints a status line this often; the bar moves every poll. +ECHO_SECONDS: Final = 30 +_READ_CHUNK: Final = 1 << 20 + + +def _clock(seconds: int) -> str: + """``42s`` or ``5m 12s``.""" + minutes, secs = divmod(seconds, 60) + return f"{minutes}m {secs:02d}s" if minutes else f"{secs}s" + + +@dataclass(frozen=True) +class InstallProgress: + """Where a run is: its phase, the current depot's percent, time spent.""" + + phase: str + percent: float | None + elapsed_seconds: int + + @property + def downloading(self) -> bool: + """Whether the phase is a depot download (the one with a percent).""" + return self.phase.startswith(_DOWNLOADING) + + def describe(self) -> str: + """``46% (downloading depot 3/8), 5m 12s elapsed``; no % before one is known.""" + elapsed = f"{_clock(self.elapsed_seconds)} elapsed" + if self.percent is None: + return f"{self.phase}, {elapsed}" + return f"{int(self.percent)}% ({self.phase}), {elapsed}" + + +class InstallTracker: + """Follows one run's log from its spawn offset, a complete line at a time.""" + + def __init__(self, log_path: Path, *, offset: int, started: float) -> None: + """Track *log_path* from byte *offset*; *started* is a monotonic time.""" + self._path = log_path + self._offset = offset + self._started = started + self._partial = b"" + self._phase = STARTING + self._percent: float | None = None + self._planned = 0 + self._depot = 0 + + def poll(self) -> None: + """Parse whatever the run has written since the last poll. + + A log that is missing or unreadable changes nothing: the run itself is + judged by its exit status, never by its log. + """ + try: + with self._path.open("rb") as log: + log.seek(self._offset) + while chunk := log.read(_READ_CHUNK): + self._offset += len(chunk) + *lines, self._partial = (self._partial + chunk).split(b"\n") + for line in lines: + self._feed(line.decode("utf-8", "replace").rstrip("\r")) + except OSError: + return + + def _feed(self, line: str) -> None: + """Apply one log line to the phase / percent state.""" + if match := _PERCENT.match(line): + if self._phase.startswith(_DOWNLOADING): + # Max within a depot: a depot's progress only ever grows. + self._percent = max(self._percent or 0.0, float(match.group(1))) + elif _PLANNED_DEPOT.match(line): + self._planned += 1 + elif _STARTED_DEPOT.match(line): + self._depot += 1 + # DLC depots come on top of the planned ones: no total to show then. + of = f"/{self._planned}" if self._depot <= self._planned else "" + self._phase, self._percent = f"{_DOWNLOADING} depot {self._depot}{of}", None + else: + for prefix, phase in _MARKERS: + if line.startswith(prefix): + self._phase, self._percent = phase, None + + def snapshot(self, now: float) -> InstallProgress: + """The run's progress as of monotonic time *now*.""" + elapsed = max(0, int(now - self._started)) + return InstallProgress(self._phase, self._percent, elapsed) + + +# Live detached runs of this process by app id, with the name to show. The +# enforce loop polls them; ctl connection threads read snapshots. +_TRACKERS: dict[int, tuple[str, InstallTracker]] = {} +_LOCK = threading.Lock() + + +def track(app_id: int, name: str, log_path: Path, *, offset: int, now: float) -> None: + """Start following a run just spawned with its output at *offset* of the log.""" + with _LOCK: + _TRACKERS[app_id] = (name, InstallTracker(log_path, offset=offset, started=now)) + + +def poll_trackers(live: set[int]) -> None: + """Read new log lines of the *live* runs and forget every other one.""" + with _LOCK: + for app_id in [aid for aid in _TRACKERS if aid not in live]: + del _TRACKERS[app_id] + for _name, tracker in _TRACKERS.values(): + tracker.poll() + + +def progress_of(app_id: int, now: float) -> InstallProgress | None: + """The tracked run's progress for *app_id*, or None if it is not tracked.""" + with _LOCK: + entry = _TRACKERS.get(app_id) + return entry[1].snapshot(now) if entry else None + + +def fast_install_status(now: float) -> list[dict[str, object]]: + """Every tracked run as the ctl ``status`` op reports it (no file I/O).""" + with _LOCK: + entries = [(aid, name, t.snapshot(now)) for aid, (name, t) in _TRACKERS.items()] + return [ + { + "app_id": app_id, + "name": name, + "phase": snap.phase, + "percent": None if snap.percent is None else round(snap.percent, 2), + "elapsed_seconds": snap.elapsed_seconds, + } + for app_id, name, snap in entries + ] + + +class WaitReporter: + """Shows a waiting caller's run: the job progress bar plus a periodic line. + + The installer prints a line per finished file (thousands), so nothing here + runs per line: one report per poll, one echo per :data:`ECHO_SECONDS`. + """ + + def __init__(self, app_id: int, label: str, *, started: float) -> None: + """Announce the wait for *app_id*'s run; *started* is when it began.""" + self._app_id = app_id + self._label = label + self._phase: str | None = None + self._last_echo = started + _echo( + f" Fast-installing {label} with steam-game-installer (Steam will " + f"close); progress every {ECHO_SECONDS}s..." + ) + + def report(self, now: float) -> None: + """Push the run's current progress; a no-op once it is not tracked.""" + snap = progress_of(self._app_id, now) + if snap is None: + return + progress = current_progress() + if snap.phase != self._phase: + # Only on a change: phase() restarts the job's ETA clock. + self._phase = snap.phase + total = 100 if snap.downloading else None + progress.phase(f"Fast-installing {self._label}: {snap.phase}", total) + if snap.downloading: + # Floor, so 99.99 never reads as done before the depot is. + progress.advance(self._label, step=int(snap.percent or 0)) + else: + progress.advance(f"{_clock(snap.elapsed_seconds)} elapsed", step=0) + if now - self._last_echo >= ECHO_SECONDS: + self._last_echo = now + _echo(f" Fast-installing {self._label}: {snap.describe()}") diff --git a/steam_backlog_enforcer/_manual_pick_lifecycle.py b/steam_backlog_enforcer/_manual_pick_lifecycle.py index d8ca824..7c83ca7 100644 --- a/steam_backlog_enforcer/_manual_pick_lifecycle.py +++ b/steam_backlog_enforcer/_manual_pick_lifecycle.py @@ -135,6 +135,7 @@ def status_payload(state: State) -> dict[str, Any]: if state.current_app_id else None ) + active = active_manual_picks(state) return { "current_app_id": state.current_app_id, "current_game_name": state.current_game_name or None, @@ -153,6 +154,9 @@ def status_payload(state: State) -> dict[str, Any]: **pick, "age_days": manual_pick_age_days(state, pick["app_id"]), } - for pick in active_manual_picks(state) + for pick in active ], + # Released, expired or parked by 'pick': still allowed and installed, + # but holding no lock; 'abandon-pick' removes them. + "kept_picks": [{**p} for p in state.manual_picks if p not in active], } diff --git a/steam_backlog_enforcer/_mcp_actions.py b/steam_backlog_enforcer/_mcp_actions.py index de2e9f9..7e6baae 100644 --- a/steam_backlog_enforcer/_mcp_actions.py +++ b/steam_backlog_enforcer/_mcp_actions.py @@ -38,7 +38,8 @@ def pick_manual(app_id: int, *, confirm: bool = False) -> dict[str, Any]: With ``confirm=False`` (the default) this performs **no** mutation and returns a preview of what confirming would do. Call again with - ``confirm=True`` to apply. Applying overrides the current assignment and + ``confirm=True`` to apply. Applying makes it the current assignment + (the games already allowed stay, up to the cap) and locks all other commands for ``MANUAL_LOCK_DAYS`` days (or until one new achievement is earned in it). Unlike the CLI's ``pick-manual``, this mutates **state only** — it never uninstalls, installs, or hides games. @@ -61,7 +62,8 @@ def pick_manual(app_id: int, *, confirm: bool = False) -> dict[str, Any]: "app_id": app_id, "game_name": game_name, "effect": ( - "Overrides the current assignment and locks all other commands " + "Makes it the current assignment (the games already allowed " + "stay, up to the cap) and locks all other commands " f"for {MANUAL_LOCK_DAYS} days or until you earn one new " "achievement in it." ), diff --git a/steam_backlog_enforcer/_pick_keep.py b/steam_backlog_enforcer/_pick_keep.py new file mode 100644 index 0000000..80573eb --- /dev/null +++ b/steam_backlog_enforcer/_pick_keep.py @@ -0,0 +1,117 @@ +"""``pick`` and ``pick-manual`` keep the games already allowed. + +Picking a game used to replace the assignment: the outgoing game and every +released manual pick were dropped from the allowed set and uninstalled. In +keep mode the outgoing assignment is parked in ``state.manual_picks`` as a +*released* entry instead, so it reuses the existing model end to end: it +stays in :func:`allowed_games` (installed, visible, never killed), holds no +lock and no pick slot, and leaves only on an explicit ``pick --replace``, +``done``/``check`` reassignment or ``abandon-pick``. ``pick-manual`` parks it +the same way and shares the cap (:func:`allowed_cap_refusal`). + +Stdout-free and state-only, like :mod:`._actions`: callers print and exit. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +from steam_backlog_enforcer._allowed_games import allowed_games + +if TYPE_CHECKING: + from steam_backlog_enforcer.config import Config, State + + +def keep_outgoing_assignment(state: State) -> dict[str, Any] | None: + """Park the current assignment as a released pick so it stays allowed. + + Must run before the new assignment overwrites ``current_app_id``. A game + already stored as a pick (an active ``pick-manual`` lock, or an earlier + released entry) is left exactly as it is: it is allowed already, and a + second entry would double-count it. Does not save. + + Args: + state: The enforcer state to mutate. + + Returns: + The appended entry, or ``None`` when nothing needed parking. + """ + app_id = state.current_app_id + if app_id is None or any(p.get("app_id") == app_id for p in state.manual_picks): + return None + now = datetime.now(UTC).isoformat() + entry: dict[str, Any] = { + "app_id": app_id, + "game_name": state.current_game_name, + "started_at": state.current_assigned_at or now, + # Released, not active: allowed, but no lock and no pick slot. + "released_at": now, + } + state.manual_picks.append(entry) + return entry + + +def keep_refusal(config: Config, state: State) -> str | None: + """Why ``pick`` (keep mode) cannot add another game, or ``None`` if it can. + + Args: + config: Enforcer configuration (for ``max_manual_picks``). + state: The loaded enforcer state. + + Returns: + A message naming every game using a slot and how to free one. + """ + return allowed_cap_refusal(state, config.max_manual_picks, offer_replace=True) + + +def allowed_cap_refusal( + state: State, + max_picks: int, + adding: int | None = None, + *, + offer_replace: bool = False, +) -> str | None: + """Why one more allowed game would break the cap, or ``None`` if it fits. + + The one rule ``pick`` and ``pick-manual`` share: at most ``max_picks + 1`` + games (the assignment plus the manual picks) may be allowed at once. + + Args: + state: The loaded enforcer state. + max_picks: The manual-pick cap (``Config.max_manual_picks``). + adding: The app about to be allowed. One already allowed (a kept + game picked again) does not grow the set, so it always fits. + offer_replace: Also suggest ``pick --replace`` (only ``pick`` has it). + + Returns: + A message naming every game using a slot and how to free one. + """ + games = allowed_games(state) + cap = max_picks + 1 + if len(games) < cap or any(app_id == adding for app_id, _ in games): + return None + picked = {p.get("app_id") for p in state.manual_picks} + header = ( + f"You already have {len(games)} games allowed (cap is {cap}: your" + f" assignment plus {max_picks} manual pick(s)):" + ) + lines = [header] + for app_id, name in games: + role = "current assignment" if app_id == state.current_app_id else "kept" + # Only a stored pick can be abandoned; the bare assignment cannot. + how = f"; free it with 'abandon-pick {app_id}'" if app_id in picked else "" + lines.append(f" - {name} (AppID={app_id}): {role}{how}") + tail = "Free a slot with 'abandon-pick '" + if offer_replace: + tail += ( + ", or run 'pick --replace' to swap them all for the new game" + " (uninstalls them)" + ) + lines.append(tail + ".") + return "\n".join(lines) + + +def kept_alongside(state: State, app_id: int) -> list[str]: + """Names of the allowed games that stay installed next to *app_id*.""" + return [name for aid, name in allowed_games(state) if aid != app_id] diff --git a/steam_backlog_enforcer/_scanning_assign.py b/steam_backlog_enforcer/_scanning_assign.py index 322f4d2..967a5e5 100644 --- a/steam_backlog_enforcer/_scanning_assign.py +++ b/steam_backlog_enforcer/_scanning_assign.py @@ -20,6 +20,7 @@ from steam_backlog_enforcer._own_pick import ( OWN_PICK_VALUE, prompt_own_pick, ) +from steam_backlog_enforcer._pick_keep import keep_outgoing_assignment from steam_backlog_enforcer._prompter import PromptOption, current_prompter from steam_backlog_enforcer._scanning_candidates import ( _pick_next_shortest_candidate, @@ -80,13 +81,24 @@ def _assign_chosen_game( games: list[GameInfo], state: State, config: Config, + *, + keep: bool = False, ) -> None: """Save assignment, announce it, and handle install/uninstall. - Accepting a new game is the explicit choice that lets enforcement remove - released picks, so they are dropped here. + Replacing (the default, and what ``done``/``check`` do) is the explicit + choice that lets enforcement remove released picks, so they are dropped + here. With ``keep`` (plain ``pick``) nothing is dropped and the outgoing + assignment is parked as a released pick, so it stays allowed and the + uninstall below spares it. """ - drop_inactive_picks(state) + if keep: + if chosen.app_id in allowed_app_ids(state): + _echo(f"\n{chosen.name} (AppID={chosen.app_id}) is already allowed.") + return + keep_outgoing_assignment(state) + else: + drop_inactive_picks(state) record_assignment(state, chosen.app_id, chosen.name) state.save() hours_str = ( diff --git a/steam_backlog_enforcer/_web_daemon.py b/steam_backlog_enforcer/_web_daemon.py index 34bf4b1..e81b5db 100644 --- a/steam_backlog_enforcer/_web_daemon.py +++ b/steam_backlog_enforcer/_web_daemon.py @@ -12,6 +12,7 @@ contract's ``PendingAction``. from __future__ import annotations +from dataclasses import asdict from http import HTTPStatus from typing import TYPE_CHECKING, Final @@ -68,6 +69,7 @@ def daemon_view(_request: Request) -> Reply: "pid": None, "journal_tail": [], "restart_available_at": None, + "fast_installs": [], } ) except CtlError as exc: @@ -83,6 +85,7 @@ def daemon_view(_request: Request) -> Reply: "pid": info.pid, "journal_tail": journal, "restart_available_at": info.restart_available_at, + "fast_installs": [asdict(run) for run in info.fast_installs], } ) diff --git a/steam_backlog_enforcer/_web_views.py b/steam_backlog_enforcer/_web_views.py index a3088d1..82324f7 100644 --- a/steam_backlog_enforcer/_web_views.py +++ b/steam_backlog_enforcer/_web_views.py @@ -44,6 +44,10 @@ def status_view(_request: Request) -> Reply: payload = status_payload(State.load()) payload["assigned_game_installed"] = bool(payload["assigned_game_installed"]) payload["manual_picks"] = [_manual_pick(p) for p in payload["manual_picks"]] + payload["kept_picks"] = [ + {"app_id": p["app_id"], "name": p.get("game_name") or str(p["app_id"])} + for p in payload.get("kept_picks", []) + ] return ok(payload) diff --git a/steam_backlog_enforcer/docs/DOCS-manual-pick-design.md b/steam_backlog_enforcer/docs/DOCS-manual-pick-design.md index 2784830..d702400 100644 --- a/steam_backlog_enforcer/docs/DOCS-manual-pick-design.md +++ b/steam_backlog_enforcer/docs/DOCS-manual-pick-design.md @@ -79,11 +79,21 @@ past its own 14-day deadline. so freeing a slot cannot hand you an unrequested extra game. - Releasing never evicts. A released, finished or expired pick stays in `State.manual_picks`, and therefore in `allowed_app_ids`, installed and - playable for as long as the user wants. Only an explicit choice of another - game — a new `pick-manual`, or accepting the next assignment in - `done`/`check`/`pick`/`scan` — calls `drop_inactive_picks`, after which + playable for as long as the user wants. Only an explicit replacement — + `pick --replace`, or accepting the next assignment in + `done`/`check`/`scan` — calls `drop_inactive_picks`, after which enforcement may remove them. `abandon-pick` works on them too. When no replacement is accepted, the current game stays assigned. + - Plain `pick` and `pick-manual` keep them (changed 2026-10-23): the + outgoing assignment is parked as a released pick too + (`_pick_keep.keep_outgoing_assignment`), so both commands only ever add a + game. Both share one cap, `_pick_keep.allowed_cap_refusal`: at most + `max_manual_picks + 1` games allowed, refused (naming every game and its + `abandon-pick `) before the list or the confirmation prompt. Picking + a game that is already allowed adds nothing and always fits; `pick-manual` + on a kept game turns its entry back into an active pick, never a second + entry. The active-pick cap (`max_manual_picks` locked at once) still + applies on top. - `mark_finished` is the single writer of `finished_app_ids`, so the sweep and `done`/`check` cannot both record the same completion. diff --git a/steam_backlog_enforcer/jobs/_registry.py b/steam_backlog_enforcer/jobs/_registry.py index 0309ea8..e667423 100644 --- a/steam_backlog_enforcer/jobs/_registry.py +++ b/steam_backlog_enforcer/jobs/_registry.py @@ -95,6 +95,15 @@ def _add_exception( return {"app_id": params["app_id"]} +def _pick( + params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter +) -> JobResult: + """``pick [--replace]``: the web checkbox becomes the CLI flag.""" + state = State.load() + cmd_pick(Config.load(), state, ["--replace"] if params.get("replace") else []) + return _assignment(state) + + def _enforce_demo( params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter ) -> JobResult: @@ -145,7 +154,7 @@ HANDLERS: dict[str, JobHandler] = { "check": _cli(do_check), "scan": _cli(do_scan), "done": _cli(cmd_done), - "pick": _cli(cmd_pick), + "pick": _pick, "pick-manual": _with_args(cmd_pick_manual, "app_id"), "abandon-pick": _with_args(cmd_abandon_pick, "app_id"), "install": _cli(cmd_install), diff --git a/steam_backlog_enforcer/main/__init__.py b/steam_backlog_enforcer/main/__init__.py index 1083c06..39ee2f5 100644 --- a/steam_backlog_enforcer/main/__init__.py +++ b/steam_backlog_enforcer/main/__init__.py @@ -187,6 +187,9 @@ def _dispatch_extra_command(command: str, config: Config, state: State) -> bool: "unblock": lambda: cmd_unblock(config, state, args), "block-gaming": lambda: cmd_block_gaming(args), "serve": lambda: cmd_serve(args), + # Also in COMMANDS (help order, direct callers), but only here does + # it see argv, so --replace is honoured. + "pick": lambda: cmd_pick(config, state, args), "pick-manual": lambda: cmd_pick_manual(config, state, args), "abandon-pick": lambda: cmd_abandon_pick(config, state, args), } diff --git a/steam_backlog_enforcer/main/abandon.py b/steam_backlog_enforcer/main/abandon.py index 79c329d..aa864bd 100644 --- a/steam_backlog_enforcer/main/abandon.py +++ b/steam_backlog_enforcer/main/abandon.py @@ -3,14 +3,13 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from steam_backlog_enforcer._actions import ( ABANDON_COOLDOWN_DAYS as _ABANDON_COOLDOWN_DAYS, ) from steam_backlog_enforcer._actions import ( active_manual_picks, - find_manual_pick, ) from steam_backlog_enforcer._manual_pick_lifecycle import abandon_manual_pick from steam_backlog_enforcer._prompter import confirm_phrase @@ -54,21 +53,28 @@ def _abandon_pick_target(state: State, args: list[str]) -> int | None: _echo(f"Error: app_id must be a number, got '{args[0]}'.") return None - picks = active_manual_picks(state) + # Every stored pick, not only active ones: a released pick, or a game + # 'pick' kept, is still installed and is what 'pick' points users at. + picks = state.manual_picks if not picks: - _echo("No manual pick is active — nothing to abandon.") + _echo("No manual pick or kept game — nothing to abandon.") return None - if find_manual_pick(state, app_id) is None: + if _stored_pick(state, app_id) is None: listed = ", ".join(f"{p['game_name']} (AppID={p['app_id']})" for p in picks) _echo( - f"Error: AppID={app_id} is not one of your manual picks.\nActive: {listed}." + f"Error: AppID={app_id} is not one of your manual picks.\nYours: {listed}." ) return None return app_id +def _stored_pick(state: State, app_id: int) -> dict[str, Any] | None: + """Return the stored pick for *app_id*, active, released or kept.""" + return next((p for p in state.manual_picks if p.get("app_id") == app_id), None) + + def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None: """Back out of a manual pick, whatever its age. @@ -81,7 +87,7 @@ def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None: if app_id is None: sys.exit(1) - pick = find_manual_pick(state, app_id) + pick = _stored_pick(state, app_id) game_name = str(pick["game_name"]) if pick else "" others = [p for p in active_manual_picks(state) if p["app_id"] != app_id] @@ -96,6 +102,9 @@ def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None: if others: kept = ", ".join(f"{p['game_name']} (AppID={p['app_id']})" for p in others) _echo(f"\n Your other pick(s) stay locked in: {kept}") + elif state.current_app_id not in (None, app_id): + # A kept game is not the assignment, so abandoning it leaves one. + _echo(f"\n - {state.current_game_name} stays assigned") else: _echo("\n - Leaves you with no assigned game (run 'scan' to get one)") _echo() diff --git a/steam_backlog_enforcer/main/picks.py b/steam_backlog_enforcer/main/picks.py index ecb8e20..45285ab 100644 --- a/steam_backlog_enforcer/main/picks.py +++ b/steam_backlog_enforcer/main/picks.py @@ -20,6 +20,11 @@ from steam_backlog_enforcer._pick_completion import ( report_completion, warn_stale_assignment, ) +from steam_backlog_enforcer._pick_keep import ( + allowed_cap_refusal, + keep_refusal, + kept_alongside, +) from steam_backlog_enforcer._progress import tracked from steam_backlog_enforcer._prompter import confirm_phrase, current_prompter from steam_backlog_enforcer._snapshot import load_snapshot, snapshot_game_name @@ -42,8 +47,28 @@ if TYPE_CHECKING: from steam_backlog_enforcer.config import Config, State -def cmd_pick(config: Config, state: State) -> None: - """Manually pick a new game from the shortest-first candidate list.""" +def cmd_pick(config: Config, state: State, args: list[str] | None = None) -> None: + """Pick another game from the shortest-first candidate list. + + The games already allowed stay installed and playable next to the new + one, up to the cap (refused before the list is shown). ``--replace`` + swaps them all for the new game and uninstalls them instead. + + Args: + config: Enforcer configuration. + state: Current enforcer state. + args: Remaining CLI args: nothing, or ``--replace``. + """ + unknown = [a for a in args or [] if a != "--replace"] + if unknown: + _echo(f"Unknown argument(s): {' '.join(unknown)}\nUsage: pick [--replace]") + sys.exit(1) + keep = "--replace" not in (args or []) + refusal = keep_refusal(config, state) if keep else None + if refusal is not None: + _echo(f"Error: {refusal}") + sys.exit(1) + snapshot_data = load_snapshot() if not snapshot_data: _echo("No snapshot found. Run 'scan' first.") @@ -55,7 +80,7 @@ def cmd_pick(config: Config, state: State) -> None: if game.app_id in hltb_cache: game.completionist_hours = hltb_cache[game.app_id] - pick_next_game(games, state, config) + pick_next_game(games, state, config, keep=keep) if state.current_app_id is not None: hide_others_and_report(config, state) @@ -84,12 +109,15 @@ def _resolve_game_name(config: Config, app_id: int) -> str | None: return None -def _report_pick_slots(config: Config, state: State) -> list[dict[str, object]]: - """Show which manual picks are already locked in; exit if the cap is full. +def _report_pick_slots( + config: Config, state: State, app_id: int +) -> list[dict[str, object]]: + """Show which manual picks are already locked in; exit if a cap is full. Args: config: Enforcer configuration (for ``max_manual_picks``). state: Current enforcer state. + app_id: The game about to be picked (an allowed one adds nothing). Returns: The currently-active manual picks. @@ -107,7 +135,10 @@ def _report_pick_slots(config: Config, state: State) -> list[dict[str, object]]: f"\ncan be abandoned at any time." ) sys.exit(1) - + refusal = allowed_cap_refusal(state, config.max_manual_picks, adding=app_id) + if refusal is not None: + _echo(f"\nError: {refusal}") + sys.exit(1) return existing @@ -146,6 +177,7 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None: """Manually pick a game by Steam app_id, locking the enforcer. The lock lasts 2 weeks, or until one new achievement is earned in it. + Already-allowed games stay installed, up to the cap (checked pre-prompt). Args: config: Enforcer configuration. @@ -173,7 +205,7 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None: retired = report_completion(config, state) - existing = _report_pick_slots(config, state) + existing = _report_pick_slots(config, state, app_id) _echo( f"\nWARNING: Picking this game will:" @@ -185,12 +217,9 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None: f"\n {', '.join(sorted(_MANUAL_LOCK_EXEMPT_COMMANDS))}" f"\n - Stay undoable at any time via 'abandon-pick {app_id}'" ) - active = active_manual_picks(state) - for old_pick in (p for p in state.manual_picks if p not in active): - _echo( - f" - Replace {old_pick['game_name']} (released or expired; it stays" - " playable until you confirm)" - ) + kept = kept_alongside(state, app_id) + if kept: + _echo(f" - Keep these allowed, they stay installed: {', '.join(kept)}") _echo() if not confirm_phrase( "pick-manual", diff --git a/steam_backlog_enforcer/main/status.py b/steam_backlog_enforcer/main/status.py index 60b7734..94c630b 100644 --- a/steam_backlog_enforcer/main/status.py +++ b/steam_backlog_enforcer/main/status.py @@ -71,6 +71,16 @@ def cmd_status(config: Config, state: State) -> None: _echo(f" {pick['game_name']} (AppID={pick['app_id']}){since}") _echo("\n[MANUAL PICK LOCK is active — most commands are blocked]") + # Released/expired picks and games 'pick' kept: allowed, but no lock. + kept = [p for p in state.manual_picks if p not in picks] + if kept: + _echo(f"\nKept games, no lock ({len(kept)}):") + for pick in kept: + _echo( + f" {pick.get('game_name', '')} (AppID={pick['app_id']})" + f" — 'abandon-pick {pick['app_id']}' removes it" + ) + def cmd_list(_config: Config, state: State) -> None: """List games from the last snapshot.""" diff --git a/steam_backlog_enforcer/scanning.py b/steam_backlog_enforcer/scanning.py index dc63775..1ac9264 100644 --- a/steam_backlog_enforcer/scanning.py +++ b/steam_backlog_enforcer/scanning.py @@ -6,6 +6,7 @@ import logging import time from typing import TYPE_CHECKING +from steam_backlog_enforcer._actions import allowed_app_ids from steam_backlog_enforcer._assignment_progress import ( release_game, release_verdict, @@ -153,6 +154,7 @@ def pick_next_game( config: Config, *, on_select: Callable[[GameInfo], bool | GameInfo] | None = None, + keep: bool = False, ) -> None: """Present a ranked list of eligible games and let the user pick one. @@ -165,12 +167,20 @@ def pick_next_game( bypassed: the function instead presents the shortest playable candidate to ``on_select`` (typically a yes/no prompt) and, if the callback rejects it, records a 7-day skip and re-evaluates. + + ``keep`` (plain ``pick``) adds the chosen game next to the ones already + allowed instead of replacing them, so those are not offered again. The + sequential path (``done``/``check``) always replaces. """ if on_select is not None: _pick_next_game_sequential(games, state, config, on_select) return - candidates = _open_candidates(games, state) + # Offering an allowed game in keep mode would be a no-op "pick"; the + # ranked list and the own-game search both draw from this pool. + allowed = allowed_app_ids(state) if keep else set() + pool = [g for g in games if g.app_id not in allowed] + candidates = _open_candidates(pool, state) if not candidates: _clear_assignment(state, _NO_CONF_MSG) return @@ -182,8 +192,8 @@ def pick_next_game( _clear_assignment(state, _no_pick_message(confidence_skipped, linux_skipped)) return - chosen = _prompt_user_pick(qualified, games, state) - _assign_chosen_game(chosen, games, state, config) + chosen = _prompt_user_pick(qualified, pool, state) + _assign_chosen_game(chosen, games, state, config, keep=keep) # ────────────────────────────────────────────────────────────── diff --git a/steam_backlog_enforcer/tests/_no_fast_install.py b/steam_backlog_enforcer/tests/_no_fast_install.py index f695448..f751b8b 100644 --- a/steam_backlog_enforcer/tests/_no_fast_install.py +++ b/steam_backlog_enforcer/tests/_no_fast_install.py @@ -18,7 +18,7 @@ from unittest.mock import patch import pytest -from steam_backlog_enforcer import _fast_install +from steam_backlog_enforcer import _fast_install, _fast_install_progress if TYPE_CHECKING: from collections.abc import Iterator @@ -39,6 +39,7 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]: fake_steamapps.mkdir(exist_ok=True) _fast_install._RUNS.clear() _fast_install._FAILED.clear() + _fast_install_progress._TRACKERS.clear() patches = [patch(name, return_value=False) for name in _RUNNING_BINDINGS] patches += [ # Absolute, so `home / _INSTALLER` resolves here and never under ~. @@ -54,3 +55,4 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]: p.stop() _fast_install._RUNS.clear() _fast_install._FAILED.clear() + _fast_install_progress._TRACKERS.clear() diff --git a/steam_backlog_enforcer/tests/fast_install_log_excerpt.txt b/steam_backlog_enforcer/tests/fast_install_log_excerpt.txt new file mode 100644 index 0000000..9fc6b61 --- /dev/null +++ b/steam_backlog_enforcer/tests/fast_install_log_excerpt.txt @@ -0,0 +1,100 @@ +Run log: /home/kuhy/.local/state/steam-game-installer/run-20261022-211022-1086940.log +Confirmed: Baldur's Gate 3 (appid 1086940, matched by flag) +Install target: /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3 +Shutting down the Steam client... +Steam exited after 3s. +Downloading via depotdownloader (fast path)... +Connecting to Steam3... Done! +Logging 'steam_user' into Steam3... + Done! +Using Steam3 suggested CellID: 38 +Got 2146 licenses for account! +Got AppInfo for 1086940 +Using app branch: 'public'. +Got AppInfo for 228980 +Got depot key for 228989 result: OK +Got depot key for 228990 result: OK +Got depot key for 1086941 result: OK +Got depot key for 1086942 result: OK +Got depot key for 1419652 result: OK +Got depot key for 1419653 result: OK +Depot 1419655 is not available from this account. +Got depot key for 2378500 result: OK +Got depot key for 2330358 result: OK + +Processing depot 228989 +Downloading depot 228989 manifest +Manifest 5753583882400741046 (06/30/2026 23:39:35) +Processing depot 228990 +Downloading depot 228990 manifest +Manifest 1829726630299308803 (02/18/2013 21:47:57) +Processing depot 1086941 +Downloading depot 1086941 manifest +Manifest 8253106406752983615 (09/21/2026 20:03:27) +Processing depot 1086942 +Downloading depot 1086942 manifest +Manifest 17875152607970122 (09/10/2020 10:06:04) +Processing depot 1419652 +Downloading depot 1419652 manifest +Manifest 1625295449847783523 (09/21/2026 20:04:37) +Processing depot 1419653 +Downloading depot 1419653 manifest +Manifest 3221749745104422496 (09/21/2026 20:04:48) +Processing depot 2378500 +Downloading depot 2378500 manifest +Manifest 6453229909780803137 (08/08/2023 10:54:38) +Processing depot 2330358 +Downloading depot 2330358 manifest +Manifest 7444994150796633975 (08/26/2025 13:58:18) +Downloading depot 228989 +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/Microsoft Visual C++ 2022 x86.cmd +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/installscript.vdf +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/VC_redist.x86.exe +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/VC_redist.x64.exe +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/Microsoft Visual C++ 2022 x64.cmd + 46.90% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/Microsoft Visual C++ 2022 x64.cmd (657.13 Mbit/s) + 46.90% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/Microsoft Visual C++ 2022 x86.cmd (641.92 Mbit/s) + 59.16% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/installscript.vdf (673.62 Mbit/s) + 95.92% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/VC_redist.x64.exe (762.02 Mbit/s) +100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/VC_redist.x86.exe (452.17 Mbit/s) +Depot 228989 - Downloaded 25108528 bytes (25674515 bytes uncompressed) +Downloading depot 228990 +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/DirectX/Jun2010/Apr2005_d3dx9_25_x64.cab +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/DirectX/Jun2010/Apr2005_d3dx9_25_x86.cab +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/DirectX/Jun2010/Apr2006_d3dx9_30_x64.cab + 98.04% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/DirectX/Jun2010/Nov2008_d3dx9_40_x64.cab (902.96 Mbit/s) + 98.98% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/DirectX/Jun2010/Nov2008_d3dx10_40_x86.cab (910.11 Mbit/s) +100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/DirectX/Jun2010/Mar2009_d3dx9_41_x64.cab (912.52 Mbit/s) +Depot 228990 - Downloaded 100658080 bytes (102931551 bytes uncompressed) +Downloading depot 1086941 +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/EngineShaders.pak +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/DiceSet01.pak +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/DiceSet03.pak +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/DiceSet02.pak + 92.49% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Models.pak (931.36 Mbit/s) + 96.02% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/SharedSounds.pak (931.27 Mbit/s) + 99.35% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Localization/Voice.pak (931.18 Mbit/s) +100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Gustav.pak (931.16 Mbit/s) +Depot 1086941 - Downloaded 127976565376 bytes (154767161103 bytes uncompressed) +Downloading depot 1086942 +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Localization/language.lsx + 99.91% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/DigitalDeluxe/OST/WAV Format/32 Baldur's Gate 3 OST - The Legacy Of Bhaal.wav (925.53 Mbit/s) +100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/DigitalDeluxe/OST/WAV Format/38 Baldur's Gate 3 OST - Old Time Battles - Part II.wav (921.57 Mbit/s) +Depot 2378500 - Downloaded 2868626208 bytes (3700167797 bytes uncompressed) +Downloading depot 2330358 +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Editor/Config/AllSpark/ComponentDefinition.xcd +Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Editor/Config/AllSpark/ModuleDefinition.xmd +100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Mods/Shared/Story/RawFiles/Goals/_Trade.txt (352.87 Mbit/s) +100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Mods/Shared/Story/RawFiles/Goals/__Shared_Campaign.txt (352.86 Mbit/s) +100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Mods/SharedDev/Story/RawFiles/Goals/_Doppelgangers.txt (352.86 Mbit/s) +100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Mods/Shared/Story/RawFiles/Goals/__GLOBAL_Dialogs.txt (352.86 Mbit/s) +100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Mods/Shared/Story/RawFiles/Goals/__AAA_FirstGoal.txt (352.86 Mbit/s) +100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Mods/SharedDev/Story/RawFiles/Goals/Respec.txt (352.85 Mbit/s) +100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Projects/GustavDev/thumbnail.png (352.64 Mbit/s) +Depot 2330358 - Downloaded 1098084960 bytes (11227179410 bytes uncompressed) +Total downloaded: 132328691216 bytes (170530942667 bytes uncompressed) from 8 depots +Disconnected from Steam +Checking for owned DLC with depots of its own... +No /home/kuhy/.local/share/Steam/steamapps/appmanifest_1086940.acf exists, so there is nothing to finish. This tool only ever patches a manifest Steam or steamcmd already wrote; it never fabricates one. Re-run without --skip-steamcmd so steamcmd creates it (it writes one even when it crashes). +Run log (send this): /home/kuhy/.local/state/steam-game-installer/run-20261022-211022-1086940.log +Restarting the Steam client... diff --git a/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py b/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py index 82ba735..d5c2ae0 100644 --- a/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py +++ b/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py @@ -197,11 +197,11 @@ class TestApplyManualPickCap: assert refused is not None assert "already one of your manual picks" in refused - def test_prunes_finished_entries(self) -> None: - """Test prunes finished entries.""" + def test_keeps_finished_entries(self) -> None: + """A finished pick stays allowed: a new pick no longer drops it.""" state = State(manual_picks=[_pick(440, "TF2")], finished_app_ids=[440]) assert apply_manual_pick(state, 620, "Portal 2", max_picks=2) is None - assert [p["app_id"] for p in state.manual_picks] == [620] + assert [p["app_id"] for p in state.manual_picks] == [440, 620] def test_slots_left(self) -> None: """Test slots left.""" diff --git a/steam_backlog_enforcer/tests/test_ctl_client.py b/steam_backlog_enforcer/tests/test_ctl_client.py index 7e08c6e..ca6ba72 100644 --- a/steam_backlog_enforcer/tests/test_ctl_client.py +++ b/steam_backlog_enforcer/tests/test_ctl_client.py @@ -22,6 +22,7 @@ from steam_backlog_enforcer._ctl_protocol import ( from steam_backlog_enforcer._ctl_types import ( BlockResult, DaemonInfo, + FastInstallInfo, PendingReset, ResetResult, StoreWindow, @@ -125,7 +126,24 @@ class TestTypedWrappers: def test_status_no_restart_time(self) -> None: data = {"state": "running", "started_at": "t", "pid": 4} with self._call(data): - assert _ctl_client.status().restart_available_at is None + info = _ctl_client.status() + assert info.restart_available_at is None + assert info.fast_installs == () # an older daemon sends none + + def test_status_fast_installs(self) -> None: + run = { + "app_id": 7, + "name": "G", + "phase": "downloading depot 1/8", + "percent": 46.9, + "elapsed_seconds": 60, + } + data = {"state": "running", "started_at": "t", "pid": 4, "fast_installs": [run]} + with self._call(data): + info = _ctl_client.status() + assert info.fast_installs == ( + FastInstallInfo(7, "G", "downloading depot 1/8", 46.9, 60), + ) def test_journal_tail(self) -> None: with self._call({"lines": ["a", 2]}) as call: diff --git a/steam_backlog_enforcer/tests/test_ctl_ops.py b/steam_backlog_enforcer/tests/test_ctl_ops.py index 529d114..59bbd1b 100644 --- a/steam_backlog_enforcer/tests/test_ctl_ops.py +++ b/steam_backlog_enforcer/tests/test_ctl_ops.py @@ -13,7 +13,7 @@ from unittest.mock import MagicMock, patch import pytest -from steam_backlog_enforcer import _ctl_ops +from steam_backlog_enforcer import _ctl_ops, _fast_install_progress from steam_backlog_enforcer._ctl_protocol import CtlError from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx @@ -35,6 +35,24 @@ class TestSimpleOps: assert data["restart_available_at"] is None assert isinstance(data["pid"], int) + def test_status_reports_fast_installs(self, tmp_path: Path) -> None: + log = tmp_path / "log" + log.write_bytes(b"Shutting down the Steam client...\r\n") + _fast_install_progress.track(7, "G", log, offset=0, now=0.0) + _fast_install_progress.poll_trackers({7}) + with patch.object(_ctl_ops, "restart_available_at", return_value=None): + data = _ctl_ops.op_status(make_ctx(), {}) + runs = data["fast_installs"] + assert isinstance(runs, list) + (run,) = runs + assert run["name"] == "G" + assert run["phase"] == "shutting Steam down" + assert run["percent"] is None + + def test_status_without_fast_installs(self) -> None: + with patch.object(_ctl_ops, "restart_available_at", return_value=None): + assert _ctl_ops.op_status(make_ctx(), {})["fast_installs"] == [] + def test_status_restarting(self) -> None: ctx = make_ctx() ctx.restart_event.set() diff --git a/steam_backlog_enforcer/tests/test_fast_install_job_wait.py b/steam_backlog_enforcer/tests/test_fast_install_job_wait.py index e433b74..314cf21 100644 --- a/steam_backlog_enforcer/tests/test_fast_install_job_wait.py +++ b/steam_backlog_enforcer/tests/test_fast_install_job_wait.py @@ -12,7 +12,8 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -from steam_backlog_enforcer import _fast_install, config +from steam_backlog_enforcer import _fast_install, _fast_install_progress, config +from steam_backlog_enforcer._progress import use_progress if TYPE_CHECKING: from pathlib import Path @@ -26,15 +27,21 @@ def _ready(tmp_path: Path) -> Path: return installer -def _fake_spawn(*returncodes: int | None) -> MagicMock: - """A ``_spawn_detached`` that registers a run answering *returncodes*.""" +def _fake_spawn(*returncodes: int | None, log_text: bytes | None = None) -> MagicMock: + """A ``_spawn_detached`` that registers a run answering *returncodes*. - def spawn(_cmd: list[str], app_id: int) -> None: + With *log_text*, the run also gets a log holding it, tracked from byte 0. + """ + + def spawn(_cmd: list[str], app_id: int, label: str = "") -> 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") - ) + log = config.CONFIG_DIR / "x.log" + if log_text is not None: + log.parent.mkdir(parents=True, exist_ok=True) + log.write_bytes(log_text) + _fast_install_progress.track(app_id, label, log, offset=0, now=0.0) + _fast_install._RUNS.append(_fast_install._DetachedRun(app_id, proc, log)) return MagicMock(side_effect=spawn) @@ -62,6 +69,18 @@ class TestJobWaitsForInstaller: assert self._install(tmp_path, _fake_spawn(None, 3), 1000) is False assert {7} == _fast_install._FAILED + def test_progress_is_reported_while_waiting(self, tmp_path: Path) -> None: + text = b"Shutting down the Steam client...\r\nDownloading via depot\r\n" + text += b"Processing depot 1\r\nDownloading depot 1\r\n" + text += b" 46.90% /x/a.pak (657.13 Mbit/s)\r\n" + progress = MagicMock() + with use_progress(progress): + assert self._install(tmp_path, _fake_spawn(None, 0, log_text=text), 1000) + progress.phase.assert_called_with( + "Fast-installing G: downloading depot 1/1", 100 + ) + progress.advance.assert_called_with("G", step=46) + 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) @@ -82,3 +101,17 @@ class TestLogIsPerUser: _fast_install._spawn_detached(["run.sh"], 9) popen.assert_called_once() assert _fast_install._RUNS[0].log_path != daemon_log + + def test_progress_starts_at_the_current_end(self) -> None: + config.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + log = config.CONFIG_DIR / "fast_install-1000.log" + log.write_bytes(b"Restarting the Steam client...\r\n") # the last run + with ( + patch(f"{PKG}.os.geteuid", return_value=1000), + patch(f"{PKG}.subprocess.Popen"), + ): + _fast_install._spawn_detached(["run.sh"], 9, "G") + _fast_install_progress.poll_trackers({9}) + progress = _fast_install_progress.progress_of(9, now=0.0) + assert progress is not None + assert progress.phase == _fast_install_progress.STARTING diff --git a/steam_backlog_enforcer/tests/test_fast_install_progress.py b/steam_backlog_enforcer/tests/test_fast_install_progress.py new file mode 100644 index 0000000..f8a3dc9 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_fast_install_progress.py @@ -0,0 +1,236 @@ +"""Tests for _fast_install_progress: reading a live installer log. + +The fixture is a verbatim excerpt of a real Baldur's Gate 3 run (the per-file +lines of the big depots trimmed). It is stored with LF endings and written +back with CRLF, which is what the installer's pty actually produces. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest + +from steam_backlog_enforcer import _fast_install_progress as fip + +MOD = "steam_backlog_enforcer._fast_install_progress" +EXCERPT = (Path(__file__).parent / "fast_install_log_excerpt.txt").read_text( + encoding="utf-8" +) +LINES = EXCERPT.splitlines() + + +def _upto(marker: str, *, extra: int = 0) -> str: + """The excerpt through the line equal to *marker*, else the first it starts. + + Exact first: ``Downloading depot 228989`` is also the start of the earlier + ``Downloading depot 228989 manifest``. + """ + exact = [i for i, line in enumerate(LINES) if line == marker] + index = (exact or [i for i, ln in enumerate(LINES) if ln.startswith(marker)])[0] + return "".join(f"{line}\r\n" for line in LINES[: index + 1 + extra]) + + +def _tracker(tmp_path: Path, text: str, *, offset: int = 0) -> fip.InstallTracker: + log = tmp_path / "fast_install-1000.log" + log.write_bytes(text.encode()) + tracker = fip.InstallTracker(log, offset=offset, started=100.0) + tracker.poll() + return tracker + + +class TestPhases: + """Each installer stage the log announces becomes a phase.""" + + def test_nothing_yet_is_starting(self, tmp_path: Path) -> None: + snap = _tracker(tmp_path, "").snapshot(now=103.0) + assert snap == fip.InstallProgress(fip.STARTING, None, 3) + + def test_shutting_steam_down(self, tmp_path: Path) -> None: + snap = _tracker(tmp_path, _upto("Shutting down")).snapshot(now=112.0) + assert snap.phase == "shutting Steam down" + assert snap.percent is None + assert snap.describe() == "shutting Steam down, 12s elapsed" + + def test_connecting_before_any_depot(self, tmp_path: Path) -> None: + snap = _tracker(tmp_path, _upto("Logging ")).snapshot(now=100.0) + assert snap.phase == "connecting to Steam" + + def test_adopting_steams_partial_download(self, tmp_path: Path) -> None: + line = ( + "Steam already staged 71.53 GB for this app (161 files). Hardlinking " + "it into place so depotdownloader verifies instead of refetching.\r\n" + ) + snap = _tracker(tmp_path, line).snapshot(now=100.0) + assert snap.phase == "adopting Steam's partial download" + + def test_first_depot_percent(self, tmp_path: Path) -> None: + snap = _tracker(tmp_path, _upto(" 46.90%")).snapshot(now=100.0) + assert snap.phase == "downloading depot 1/8" + assert snap.percent == pytest.approx(46.90) + assert snap.downloading + + def test_new_depot_resets_the_percent(self, tmp_path: Path) -> None: + # Pre-allocating lines only: the previous depot's 100% must not show. + text = _upto("Downloading depot 228990", extra=3) + snap = _tracker(tmp_path, text).snapshot(now=100.0) + assert snap.phase == "downloading depot 2/8" + assert snap.percent is None + assert snap.describe() == "downloading depot 2/8, 0s elapsed" + + def test_percent_outside_a_depot_is_ignored(self, tmp_path: Path) -> None: + text = _upto("Shutting down") + " 46.90% /x/y.cmd (657.13 Mbit/s)\r\n" + snap = _tracker(tmp_path, text).snapshot(now=100.0) + assert snap.phase == "shutting Steam down" + assert snap.percent is None + + def test_percent_is_per_depot(self, tmp_path: Path) -> None: + snap = _tracker(tmp_path, _upto(" 92.49%")).snapshot(now=100.0) + assert snap.phase == "downloading depot 3/8" + assert snap.percent == pytest.approx(92.49) + + def test_finishing_after_the_download(self, tmp_path: Path) -> None: + snap = _tracker(tmp_path, _upto("Checking for owned DLC")).snapshot(now=0) + assert snap.phase == "finishing manifest" + assert snap.percent is None + assert not snap.downloading + + def test_whole_run_ends_restarting_steam(self, tmp_path: Path) -> None: + snap = _tracker(tmp_path, _upto("Restarting")).snapshot(now=100.0) + assert snap.phase == "restarting Steam" + + def test_describe_with_percent_and_minutes(self, tmp_path: Path) -> None: + snap = _tracker(tmp_path, _upto(" 99.35%")).snapshot(now=100.0 + 312) + assert snap.describe() == "99% (downloading depot 3/8), 5m 12s elapsed" + + +class TestIncrementalRead: + """Only bytes written since the spawn are read, a line at a time.""" + + def test_previous_run_before_offset_is_ignored(self, tmp_path: Path) -> None: + previous = _upto("Restarting") # the last run ended at 100%... + log = tmp_path / "fast_install-1000.log" + log.write_bytes(previous.encode()) + tracker = fip.InstallTracker(log, offset=log.stat().st_size, started=0.0) + tracker.poll() + assert tracker.snapshot(now=1.0).phase == fip.STARTING + with log.open("ab") as out: # ...and the new one has just begun. + out.write(_upto("Shutting down").encode()) + tracker.poll() + snap = tracker.snapshot(now=1.0) + assert snap.phase == "shutting Steam down" + assert snap.percent is None + + def test_partial_line_waits_for_its_end(self, tmp_path: Path) -> None: + log = tmp_path / "log" + log.write_bytes(_upto("Downloading depot 228989").encode() + b" 46.9") + tracker = fip.InstallTracker(log, offset=0, started=0.0) + tracker.poll() + assert tracker.snapshot(now=0).percent is None + with log.open("ab") as out: + out.write(b"0% /x/y.cmd (657.13 Mbit/s)\r\n") + tracker.poll() + assert tracker.snapshot(now=0).percent == pytest.approx(46.90) + + def test_missing_log_is_a_no_op(self, tmp_path: Path) -> None: + tracker = fip.InstallTracker(tmp_path / "gone.log", offset=0, started=0.0) + tracker.poll() + assert tracker.snapshot(now=0).phase == fip.STARTING + + +class TestRegistry: + """Daemon runs are tracked by app id and exposed as status entries.""" + + def test_status_lists_live_runs(self, tmp_path: Path) -> None: + log = tmp_path / "log" + log.write_bytes(_upto(" 59.16%").encode()) + fip.track(7, "Baldur's Gate 3", log, offset=0, now=10.0) + fip.poll_trackers({7}) + assert fip.fast_install_status(now=70.0) == [ + { + "app_id": 7, + "name": "Baldur's Gate 3", + "phase": "downloading depot 1/8", + "percent": 59.16, + "elapsed_seconds": 60, + } + ] + + def test_finished_runs_are_dropped(self, tmp_path: Path) -> None: + fip.track(7, "G", tmp_path / "log", offset=0, now=0.0) + fip.poll_trackers(set()) + assert fip.fast_install_status(now=1.0) == [] + assert fip.progress_of(7, now=1.0) is None + + +class TestWaitReporter: + """A waiting web job moves its progress bar and prints a line every 30 s.""" + + def _reporter(self, tmp_path: Path, text: str) -> fip.WaitReporter: + log = tmp_path / "log" + log.write_bytes(text.encode()) + fip.track(7, "G", log, offset=0, now=0.0) + fip.poll_trackers({7}) + with patch(f"{MOD}._echo"): + return fip.WaitReporter(7, "G", started=0.0) + + def test_announces_the_wait(self) -> None: + with patch(f"{MOD}._echo") as echo: + fip.WaitReporter(7, "G", started=0.0) + echo.assert_called_once_with( + " Fast-installing G with steam-game-installer (Steam will close); " + "progress every 30s..." + ) + + def test_phase_then_percent_step(self, tmp_path: Path) -> None: + reporter = self._reporter(tmp_path, _upto(" 99.35%")) + progress = MagicMock() + with ( + patch(f"{MOD}.current_progress", return_value=progress), + patch(f"{MOD}._echo"), + ): + reporter.report(now=5.0) + reporter.report(now=10.0) + # The phase is announced once; 99.35 floors so it never claims done. + progress.phase.assert_called_once_with( + "Fast-installing G: downloading depot 3/8", 100 + ) + assert progress.advance.call_args_list == [ + call("G", step=99), + call("G", step=99), + ] + + def test_indeterminate_phase_shows_elapsed(self, tmp_path: Path) -> None: + reporter = self._reporter(tmp_path, _upto("Shutting down")) + progress = MagicMock() + with ( + patch(f"{MOD}.current_progress", return_value=progress), + patch(f"{MOD}._echo"), + ): + reporter.report(now=12.0) + progress.phase.assert_called_once_with( + "Fast-installing G: shutting Steam down", None + ) + progress.advance.assert_called_once_with("12s elapsed", step=0) + + def test_echo_is_throttled_to_30_seconds(self, tmp_path: Path) -> None: + reporter = self._reporter(tmp_path, _upto(" 46.90%")) + with patch(f"{MOD}.current_progress"), patch(f"{MOD}._echo") as echo: + for now in (5.0, 10.0, 29.0, 31.0, 45.0, 62.0): + reporter.report(now=now) + assert echo.call_args_list == [ + call(" Fast-installing G: 46% (downloading depot 1/8), 31s elapsed"), + call(" Fast-installing G: 46% (downloading depot 1/8), 1m 02s elapsed"), + ] + + def test_untracked_run_reports_nothing(self) -> None: + progress = MagicMock() + with ( + patch(f"{MOD}.current_progress", return_value=progress), + patch(f"{MOD}._echo") as echo, + ): + fip.WaitReporter(99, "G", started=0.0).report(now=60.0) + progress.phase.assert_not_called() + progress.advance.assert_not_called() + assert echo.call_count == 1 # the announcement only diff --git a/steam_backlog_enforcer/tests/test_main_pick_cap.py b/steam_backlog_enforcer/tests/test_main_pick_cap.py index 49dd947..0bcf620 100644 --- a/steam_backlog_enforcer/tests/test_main_pick_cap.py +++ b/steam_backlog_enforcer/tests/test_main_pick_cap.py @@ -123,7 +123,7 @@ class TestPickManualCap: class TestPickManualRetirementNotices: """A pick retired by the sweep is announced before the typed-phrase prompt.""" - def test_warns_about_uninstall_and_stale_assignment(self) -> None: + def test_lists_kept_games_and_stale_assignment(self) -> None: state = locked_state(app_id=100) state.current_app_id = 200 state.current_game_name = "Finished" @@ -146,7 +146,8 @@ class TestPickManualRetirementNotices: ): cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"]) output = " ".join(str(c) for c in mock_echo.call_args_list) - assert "Replace Finished (released or expired" in output + # The retired game is kept now, not replaced: it stays installed. + assert "they stay installed: Finished, TestGame" in output assert "Aborted." in output # Abort leaves the finished game as the assignment, so say so. note = " ".join(str(c) for c in mock_note.call_args_list) diff --git a/steam_backlog_enforcer/tests/test_main_pick_keep.py b/steam_backlog_enforcer/tests/test_main_pick_keep.py new file mode 100644 index 0000000..264b911 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_pick_keep.py @@ -0,0 +1,206 @@ +"""``pick`` CLI/web wiring: the keep default, ``--replace`` and the cap. + +The keep/replace state model itself is covered in ``test_pick_keep``; this +file covers how the choice reaches it (argv, web params, job handler), the +refusal when the allowed set is full, and abandoning a kept game. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer._command_params import ( + PARAMS, + InvalidParamsError, + validate_params, +) +from steam_backlog_enforcer._prompter import use_prompter +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.jobs import _registry +from steam_backlog_enforcer.main import ( + _dispatch_extra_command, + cmd_abandon_pick, + cmd_pick, +) +from steam_backlog_enforcer.tests._fake_prompter import FakePrompter +from steam_backlog_enforcer.tests._main_helpers import STARTED_AT, snap + +PKG = "steam_backlog_enforcer.main.picks" +_ABANDON = "steam_backlog_enforcer.main.abandon" + + +def _pick(app_id: int, name: str, *, released: bool = True) -> dict[str, str | int]: + entry: dict[str, str | int] = { + "app_id": app_id, + "game_name": name, + "started_at": STARTED_AT, + } + if released: + entry["released_at"] = STARTED_AT + return entry + + +def _full_state() -> State: + """Assignment plus two kept games: 3 allowed, the cap for 2 manual picks.""" + return State( + current_app_id=1, + current_game_name="Current", + manual_picks=[_pick(2, "KeptA"), _pick(3, "KeptB")], + ) + + +def _config() -> Config: + return Config(steam_api_key="k", steam_id="i", max_manual_picks=2) + + +def _output(mock_echo: MagicMock) -> str: + return " ".join(str(c) for c in mock_echo.call_args_list) + + +class TestCmdPickKeep: + def test_keeps_by_default(self) -> None: + with ( + patch(f"{PKG}.load_snapshot", return_value=[snap(5, "New")]), + patch(f"{PKG}.load_hltb_cache", return_value={}), + patch(f"{PKG}.pick_next_game") as mock_pick, + patch(f"{PKG}.hide_others_and_report"), + ): + cmd_pick(_config(), State(), []) + assert mock_pick.call_args.kwargs["keep"] is True + + def test_replace_flag_replaces(self) -> None: + with ( + patch(f"{PKG}.load_snapshot", return_value=[snap(5, "New")]), + patch(f"{PKG}.load_hltb_cache", return_value={}), + patch(f"{PKG}.pick_next_game") as mock_pick, + patch(f"{PKG}.hide_others_and_report"), + ): + cmd_pick(_config(), _full_state(), ["--replace"]) + assert mock_pick.call_args.kwargs["keep"] is False + + def test_unknown_argument_exits(self) -> None: + with ( + patch(f"{PKG}._echo") as mock_echo, + patch(f"{PKG}.pick_next_game") as mock_pick, + pytest.raises(SystemExit) as exc, + ): + cmd_pick(_config(), State(), ["--keep"]) + assert exc.value.code == 1 + assert "--replace" in _output(mock_echo) + mock_pick.assert_not_called() + + def test_full_allowed_set_is_refused_before_the_list(self) -> None: + # No scripted answers: being asked anything raises IndexError. + with ( + use_prompter(FakePrompter()), + patch(f"{PKG}._echo") as mock_echo, + patch(f"{PKG}.load_snapshot") as mock_snapshot, + patch(f"{PKG}.pick_next_game") as mock_pick, + pytest.raises(SystemExit) as exc, + ): + cmd_pick(_config(), _full_state(), []) + assert exc.value.code == 1 + mock_snapshot.assert_not_called() + mock_pick.assert_not_called() + output = _output(mock_echo) + for name in ("Current (AppID=1)", "KeptA (AppID=2)", "KeptB (AppID=3)"): + assert name in output + assert "abandon-pick 2" in output + assert "--replace" in output + + def test_one_below_the_cap_is_allowed(self) -> None: + state = State( + current_app_id=1, current_game_name="Current", manual_picks=[_pick(2, "A")] + ) + with ( + patch(f"{PKG}.load_snapshot", return_value=[snap(5, "New")]), + patch(f"{PKG}.load_hltb_cache", return_value={}), + patch(f"{PKG}.pick_next_game") as mock_pick, + patch(f"{PKG}.hide_others_and_report"), + ): + cmd_pick(_config(), state, []) + mock_pick.assert_called_once() + + +class TestDispatch: + def test_main_passes_the_argv_tail_to_pick(self) -> None: + config, state = _config(), State() + with ( + patch("sys.argv", ["sbe", "pick", "--replace"]), + patch("steam_backlog_enforcer.main.cmd_pick") as mock_pick, + ): + assert _dispatch_extra_command("pick", config, state) is True + mock_pick.assert_called_once_with(config, state, ["--replace"]) + + +class TestReplaceParam: + def test_defaults_to_keep(self) -> None: + assert validate_params("pick", {}) == {"replace": 0} + + @pytest.mark.parametrize(("raw", "want"), [(True, 1), (False, 0), (1, 1)]) + def test_accepts_booleans(self, raw: object, want: int) -> None: + assert validate_params("pick", {"replace": raw}) == {"replace": want} + + @pytest.mark.parametrize("raw", [2, "yes", None]) + def test_rejects_non_booleans(self, raw: object) -> None: + with pytest.raises(InvalidParamsError): + validate_params("pick", {"replace": raw}) + + def test_is_an_unchecked_checkbox_in_the_catalog(self) -> None: + [spec] = PARAMS["pick"] + assert spec.to_json() == { + "name": "replace", + "label": "Replace current games (uninstalls them)", + "type": "bool", + "required": False, + "default": 0, + "help": spec.help, + } + + +class TestPickJob: + @pytest.mark.parametrize(("replace", "argv"), [(1, ["--replace"]), (0, [])]) + def test_replace_param_becomes_the_flag( + self, replace: int, argv: list[str] + ) -> None: + with patch("steam_backlog_enforcer.jobs._registry.cmd_pick") as command: + _registry.HANDLERS["pick"]({"replace": replace}, MagicMock(), MagicMock()) + assert command.call_args.args[2] == argv + + +class TestAbandonKeptGame: + def test_a_kept_game_can_be_abandoned(self) -> None: + state = State( + current_app_id=1, + current_game_name="Current", + manual_picks=[_pick(2, "KeptA")], + ) + with ( + patch(f"{_ABANDON}._echo"), + patch("builtins.input", return_value="abandon KeptA"), + patch.object(State, "save"), + patch(f"{_ABANDON}.is_game_installed", return_value=True), + patch(f"{_ABANDON}.uninstall_game", return_value=True) as uninstall, + ): + cmd_abandon_pick(Config(), state, ["2"]) + assert state.manual_picks == [] + assert state.current_app_id == 1 + uninstall.assert_called_once_with(2, "KeptA") + + def test_warning_names_the_assignment_that_survives(self) -> None: + """Abandoning a kept game does not leave you without an assignment.""" + state = State( + current_app_id=1, + current_game_name="Current", + manual_picks=[_pick(2, "KeptA")], + ) + with ( + patch(f"{_ABANDON}._echo") as echo, + patch("builtins.input", return_value="no"), + ): + cmd_abandon_pick(Config(), state, ["2"]) + output = " ".join(str(c) for c in echo.call_args_list) + assert "no assigned game" not in output + assert "Current stays assigned" in output diff --git a/steam_backlog_enforcer/tests/test_main_picks.py b/steam_backlog_enforcer/tests/test_main_picks.py index 46c04ea..07d1bde 100644 --- a/steam_backlog_enforcer/tests/test_main_picks.py +++ b/steam_backlog_enforcer/tests/test_main_picks.py @@ -144,7 +144,9 @@ class TestCmdPick: config = Config(steam_api_key="k", steam_id="i") state = State() - def capture_pick(games: list[GameInfo], *_args: object) -> None: + def capture_pick( + games: list[GameInfo], *_args: object, **_kwargs: object + ) -> None: captured_games.append(list(games)) with ( diff --git a/steam_backlog_enforcer/tests/test_pick_keep.py b/steam_backlog_enforcer/tests/test_pick_keep.py new file mode 100644 index 0000000..2a520f6 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_pick_keep.py @@ -0,0 +1,185 @@ +"""``pick`` keeps the games already allowed; ``--replace`` and ``done`` swap. + +Drives :func:`steam_backlog_enforcer.scanning.pick_next_game` end to end with +every filesystem, network and Steam effect patched out, and asserts on the +allowed set (``allowed_app_ids``), which is what the daemon, the hider, the +uninstall guard and the auto-installer all read. +""" + +from __future__ import annotations + +from contextlib import ExitStack, contextmanager +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import MagicMock, patch + +from steam_backlog_enforcer._actions import active_manual_picks, allowed_app_ids +from steam_backlog_enforcer._prompter import use_prompter +from steam_backlog_enforcer._scanning_assign import _assign_chosen_game +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.scanning import pick_next_game +from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._fake_prompter import FakePrompter +from steam_backlog_enforcer.tests._main_helpers import STARTED_AT + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + +_ASSIGN = "steam_backlog_enforcer._scanning_assign" +_SCAN = "steam_backlog_enforcer.scanning" +_RELEASED = {"released_at": STARTED_AT} + + +def _game(app_id: int, name: str) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=10, + unlocked_achievements=1, + playtime_minutes=60, + completionist_hours=5.0, + ) + + +OLD, NEW = _game(1, "Old"), _game(2, "New") + + +def _config() -> Config: + return Config(steam_api_key="k", steam_id="i", max_manual_picks=2) + + +def _state_on( + app_id: int = 1, + name: str = "Old", + *, + manual_picks: list[dict[str, Any]] | None = None, +) -> State: + state = State(manual_picks=manual_picks or []) + state.current_app_id = app_id + state.current_game_name = name + state.current_assigned_at = STARTED_AT + state.last_assigned_at[str(app_id)] = STARTED_AT + return state + + +class _Effects: + """Every side effect of an assignment, patched; the mocks for asserts.""" + + def __init__(self, stack: ExitStack, *, installed: bool) -> None: + def p(target: str, **kw: object) -> MagicMock: + return stack.enter_context(patch(target, **cast("dict[str, Any]", kw))) + + p(f"{_SCAN}._collect_qualified_candidates", side_effect=lambda c: (c, 0, 0)) + p( + f"{_ASSIGN}._pick_next_shortest_candidate", + side_effect=lambda c: (c[0], 0, 0), + ) + p(f"{_ASSIGN}._apply_cached_confidence_to_candidates") + p(f"{_ASSIGN}._report_poll_confidence") + p(f"{_ASSIGN}._echo") + p(f"{_ASSIGN}.is_game_fully_installed", return_value=installed) + self.uninstall = p(f"{_ASSIGN}.uninstall_other_games", return_value=0) + self.install = p(f"{_ASSIGN}.install_game") + stack.enter_context(patch.object(State, "save")) + + +@contextmanager +def _effects(*, installed: bool = True) -> Iterator[_Effects]: + with ExitStack() as stack: + yield _Effects(stack, installed=installed) + + +def _pick( + state: State, + prompter: FakePrompter, + *, + keep: bool = False, + on_select: Callable[[GameInfo], bool] | None = None, +) -> _Effects: + with _effects(installed=False) as effects, use_prompter(prompter): + pick_next_game([OLD, NEW], state, _config(), keep=keep, on_select=on_select) + return effects + + +class TestKeep: + def test_old_game_stays_allowed_and_is_not_uninstalled(self) -> None: + state = _state_on() + effects = _pick(state, FakePrompter(choices=["0"]), keep=True) + assert state.current_app_id == 2 + assert allowed_app_ids(state) == {1, 2} + effects.uninstall.assert_called_once_with({1, 2}) + effects.install.assert_called_once_with(2, "New", "i", use_steam_protocol=True) + + def test_kept_game_is_a_released_pick_holding_no_lock(self) -> None: + state = _state_on() + _pick(state, FakePrompter(choices=["0"]), keep=True) + [kept] = state.manual_picks + assert kept["app_id"] == 1 + assert kept["game_name"] == "Old" + assert kept["started_at"] == STARTED_AT + assert kept["released_at"] + assert active_manual_picks(state) == [] + assert state.finished_app_ids == [] + assert state.released_at == {} + + def test_allowed_games_are_not_offered(self) -> None: + state = _state_on() + prompter = FakePrompter(choices=["0"]) + _pick(state, prompter, keep=True) + assert prompter.asked[0][1] == ["0", "own"] + + def test_own_pick_search_excludes_allowed_games(self) -> None: + state = _state_on() + with patch(f"{_ASSIGN}.prompt_own_pick", return_value=NEW) as own: + _pick(state, FakePrompter(choices=["own"]), keep=True) + assert [g.app_id for g in own.call_args.args[0]] == [2] + + def test_pick_manual_then_pick_does_not_duplicate_the_active_pick(self) -> None: + state = _state_on( + manual_picks=[{"app_id": 1, "game_name": "Old", "started_at": STARTED_AT}] + ) + _pick(state, FakePrompter(choices=["0"]), keep=True) + assert state.manual_picks == [ + {"app_id": 1, "game_name": "Old", "started_at": STARTED_AT} + ] + assert [p["app_id"] for p in active_manual_picks(state)] == [1] + assert allowed_app_ids(state) == {1, 2} + + def test_released_entry_for_the_current_game_is_not_duplicated(self) -> None: + entry = {"app_id": 1, "game_name": "Old", "started_at": STARTED_AT} + state = _state_on(manual_picks=[entry | _RELEASED]) + _pick(state, FakePrompter(choices=["0"]), keep=True) + assert [p["app_id"] for p in state.manual_picks] == [1] + + def test_earlier_kept_games_survive_another_keep(self) -> None: + entry = {"app_id": 9, "game_name": "Kept", "started_at": STARTED_AT} + state = _state_on(manual_picks=[entry | _RELEASED]) + effects = _pick(state, FakePrompter(choices=["0"]), keep=True) + assert allowed_app_ids(state) == {1, 2, 9} + effects.uninstall.assert_called_once_with({1, 2, 9}) + + def test_choosing_an_allowed_game_changes_nothing(self) -> None: + state = _state_on() + with _effects() as effects: + _assign_chosen_game(OLD, [OLD, NEW], state, _config(), keep=True) + assert state.manual_picks == [] + assert state.current_app_id == 1 + effects.uninstall.assert_not_called() + + +class TestReplace: + def test_replace_drops_old_and_released_games(self) -> None: + entry = {"app_id": 9, "game_name": "Kept", "started_at": STARTED_AT} + state = _state_on(manual_picks=[entry | _RELEASED]) + effects = _pick(state, FakePrompter(choices=["0"])) + assert state.current_app_id == 2 + assert allowed_app_ids(state) == {2} + effects.uninstall.assert_called_once_with({2}) + + def test_done_path_still_replaces(self) -> None: + """``done``/``check`` reach the sequential picker; it never keeps.""" + state = _state_on() + state.skip_for_days(1, 7) + effects = _pick(state, FakePrompter(), on_select=lambda _g: True) + assert state.current_app_id == 2 + assert state.manual_picks == [] + effects.uninstall.assert_called_once_with({2}) diff --git a/steam_backlog_enforcer/tests/test_pick_manual_keep.py b/steam_backlog_enforcer/tests/test_pick_manual_keep.py new file mode 100644 index 0000000..7ec76d3 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_pick_manual_keep.py @@ -0,0 +1,176 @@ +"""``pick-manual`` keeps the games already allowed, under the ``pick`` cap. + +Covers the shared core (:func:`apply_manual_pick`), the CLI +(``cmd_pick_manual``) and the MCP ``pick_manual`` tool. Every Steam, +filesystem and network effect is patched out. +""" + +from __future__ import annotations + +from contextlib import ExitStack +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _mcp_actions as mcp_actions +from steam_backlog_enforcer._actions import allowed_app_ids, apply_manual_pick +from steam_backlog_enforcer._allowed_games import active_manual_picks +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.main import cmd_pick_manual +from steam_backlog_enforcer.tests._main_helpers import STARTED_AT + +PKG = "steam_backlog_enforcer.main.picks" + + +def _kept(app_id: int, name: str) -> dict[str, str | int]: + return { + "app_id": app_id, + "game_name": name, + "started_at": STARTED_AT, + "released_at": STARTED_AT, + } + + +def _state(*kept: tuple[int, str], current: tuple[int, str] = (1, "Current")) -> State: + """An auto assignment (not a stored pick) plus released, kept games.""" + return State( + current_app_id=current[0], + current_game_name=current[1], + current_assigned_at=STARTED_AT, + manual_picks=[_kept(a, n) for a, n in kept], + ) + + +def _entries(state: State, app_id: int) -> list[dict[str, object]]: + return [p for p in state.manual_picks if p.get("app_id") == app_id] + + +class TestApplyManualPickKeeps: + def test_kept_games_and_outgoing_assignment_stay_allowed(self) -> None: + state = _state((2, "KeptA")) + with patch.object(State, "save"): + assert apply_manual_pick(state, 5, "New", max_picks=2) is None + assert allowed_app_ids(state) == {1, 2, 5} + assert [p["app_id"] for p in active_manual_picks(state)] == [5] + assert state.current_app_id == 5 + + def test_over_cap_is_refused_naming_the_games(self) -> None: + state = _state((2, "KeptA"), (3, "KeptB")) + with patch.object(State, "save") as save: + refused = apply_manual_pick(state, 5, "New", max_picks=2) + assert refused is not None + for name in ("Current (AppID=1)", "KeptA (AppID=2)", "KeptB (AppID=3)"): + assert name in refused + assert "abandon-pick 2" in refused + assert "--replace" not in refused + save.assert_not_called() + assert state.current_app_id == 1 + assert [p["app_id"] for p in state.manual_picks] == [2, 3] + + def test_repicking_a_kept_game_activates_it_without_duplicating(self) -> None: + # At the cap, but re-picking a game already allowed adds nothing. + state = _state((2, "KeptA"), (3, "KeptB")) + with patch.object(State, "save"): + assert apply_manual_pick(state, 2, "KeptA", max_picks=2) is None + assert len(_entries(state, 2)) == 1 + assert "released_at" not in _entries(state, 2)[0] + assert [p["app_id"] for p in active_manual_picks(state)] == [2] + assert allowed_app_ids(state) == {1, 2, 3} + + def test_repicking_the_kept_current_game_does_not_duplicate(self) -> None: + state = _state((2, "KeptA"), current=(2, "KeptA")) + with patch.object(State, "save"): + assert apply_manual_pick(state, 2, "KeptA", max_picks=2) is None + assert len(state.manual_picks) == 1 + assert active_manual_picks(state) == state.manual_picks + + def test_finished_pick_is_kept_too(self) -> None: + state = _state((2, "Done")) + state.finished_app_ids = [2] + with patch.object(State, "save"): + assert apply_manual_pick(state, 5, "New", max_picks=2) is None + assert 2 in allowed_app_ids(state) + + +def _cli_effects(stack: ExitStack, *, confirm: bool = True) -> dict[str, MagicMock]: + mocks = { + "echo": stack.enter_context(patch(f"{PKG}._echo")), + "confirm": stack.enter_context( + patch(f"{PKG}.confirm_phrase", return_value=confirm) + ), + "uninstall": stack.enter_context( + patch(f"{PKG}.uninstall_other_games", return_value=0) + ), + } + stack.enter_context(patch(f"{PKG}.report_completion", return_value=[])) + stack.enter_context(patch(f"{PKG}._resolve_game_name", return_value="New")) + stack.enter_context(patch.object(State, "save")) + stack.enter_context(patch(f"{PKG}.is_game_fully_installed", return_value=True)) + stack.enter_context( + patch( + "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", + return_value=[], + ) + ) + return mocks + + +def _output(mock_echo: MagicMock) -> str: + return " ".join(str(c) for c in mock_echo.call_args_list) + + +class TestCmdPickManualKeeps: + def test_kept_games_are_not_uninstalled(self) -> None: + state = _state((2, "KeptA")) + with ExitStack() as stack: + mocks = _cli_effects(stack) + cmd_pick_manual(Config(max_manual_picks=2), state, ["5"]) + mocks["uninstall"].assert_called_once_with({1, 2, 5}) + output = _output(mocks["echo"]) + assert "Replace" not in output + assert "stay installed" in output + assert "KeptA" in output + + def test_over_cap_is_refused_before_the_confirmation(self) -> None: + state = _state((2, "KeptA"), (3, "KeptB")) + with ExitStack() as stack: + mocks = _cli_effects(stack) + with pytest.raises(SystemExit) as exc: + cmd_pick_manual(Config(max_manual_picks=2), state, ["5"]) + assert exc.value.code == 1 + mocks["confirm"].assert_not_called() + mocks["uninstall"].assert_not_called() + output = _output(mocks["echo"]) + for name in ("Current (AppID=1)", "KeptA (AppID=2)", "KeptB (AppID=3)"): + assert name in output + assert "abandon-pick 3" in output + + +class TestMcpPickManualKeeps: + def _run(self, state: State) -> dict[str, object]: + with ( + patch( + "steam_backlog_enforcer._snapshot.load_snapshot", + return_value=[{"app_id": 5, "name": "New"}], + ), + patch.object(Config, "load", return_value=Config()), + patch.object(State, "load", return_value=state), + patch.object(State, "save"), + patch.object(mcp_actions, "retire_completed_manual_picks", return_value=[]), + patch(f"{PKG}.uninstall_other_games") as uninstall, + ): + out = mcp_actions.pick_manual(5, confirm=True) + uninstall.assert_not_called() + return out + + def test_kept_game_survives(self) -> None: + state = _state((2, "KeptA")) + assert self._run(state)["ok"] is True + assert allowed_app_ids(state) == {1, 2, 5} + + def test_over_cap_is_refused(self) -> None: + state = _state((2, "KeptA"), (3, "KeptB")) + out = self._run(state) + assert out["ok"] is False + assert "KeptB (AppID=3)" in str(out["reason"]) + assert allowed_app_ids(state) == {1, 2, 3} diff --git a/steam_backlog_enforcer/tests/test_release_lifecycle.py b/steam_backlog_enforcer/tests/test_release_lifecycle.py index 85d0ff2..c688979 100644 --- a/steam_backlog_enforcer/tests/test_release_lifecycle.py +++ b/steam_backlog_enforcer/tests/test_release_lifecycle.py @@ -104,12 +104,12 @@ class TestReleasedPicksStayPlayable: assert [p["app_id"] for p in dropped] == [2] assert [p["app_id"] for p in state.manual_picks] == [3] - def test_new_manual_pick_replaces_released_ones(self) -> None: + def test_new_manual_pick_keeps_released_ones(self) -> None: state = State(current_app_id=2, manual_picks=[pick(2, released_at=STARTED_AT)]) with patch.object(State, "save"): assert apply_manual_pick(state, 4, "G4", max_picks=1) is None - assert allowed_app_ids(state) == {4} - assert state.current_assigned_at == state.manual_picks[0]["started_at"] + assert allowed_app_ids(state) == {2, 4} + assert state.current_assigned_at == state.manual_picks[-1]["started_at"] def test_released_pick_can_be_abandoned(self) -> None: state = State( diff --git a/steam_backlog_enforcer/tests/test_status_kept_picks.py b/steam_backlog_enforcer/tests/test_status_kept_picks.py new file mode 100644 index 0000000..2c20c97 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_status_kept_picks.py @@ -0,0 +1,72 @@ +"""Kept games (released, no-lock picks) are visible in every status view. + +``pick`` parks the outgoing game as a released pick; it is installed and +playable, so ``status``, ``GET /api/status`` and the web Picks card must list +it (under the active picks) with a way to abandon it. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer import _web_views +from steam_backlog_enforcer._manual_pick_lifecycle import status_payload +from steam_backlog_enforcer._total_block import TotalBlockStatus +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.main import cmd_status +from steam_backlog_enforcer.tests._main_helpers import STARTED_AT +from steam_backlog_enforcer.tests._web_request import make_request, payload_of + +_ACTIVE = {"app_id": 1, "game_name": "Locked", "started_at": STARTED_AT} +_KEPT = { + "app_id": 2, + "game_name": "Kept", + "started_at": STARTED_AT, + "released_at": STARTED_AT, +} +_NO_BLOCK = TotalBlockStatus( + active=False, started_at=None, until=None, days=0, days_remaining=0.0 +) +_STATUS = "steam_backlog_enforcer.main.status" + + +def _state() -> State: + return State( + current_app_id=3, + current_game_name="Current", + manual_picks=[dict(_ACTIVE), dict(_KEPT)], + ) + + +def test_payload_lists_kept_picks_apart_from_active_ones() -> None: + with patch.multiple( + "steam_backlog_enforcer._manual_pick_lifecycle", + get_total_block_status=lambda: _NO_BLOCK, + get_installed_games=list, + is_store_blocked=lambda: True, + is_protected_app=lambda _aid: False, + ): + payload = status_payload(_state()) + assert [p["app_id"] for p in payload["manual_picks"]] == [1] + assert payload["kept_picks"] == [_KEPT] + + +def test_status_view_projects_kept_picks() -> None: + raw = {"assigned_game_installed": True, "manual_picks": [], "kept_picks": [_KEPT]} + with patch(f"{_web_views.__name__}.status_payload", return_value=raw): + body = payload_of(_web_views.status_view(make_request())) + assert body["kept_picks"] == [{"app_id": 2, "name": "Kept"}] + + +def test_cli_status_lists_kept_games() -> None: + with ( + patch(f"{_STATUS}.is_store_blocked", return_value=False), + patch(f"{_STATUS}.get_installed_games", return_value=[]), + patch(f"{_STATUS}.report_completion", return_value=[]), + patch(f"{_STATUS}._echo") as mock_echo, + ): + cmd_status(Config(), _state()) + output = " ".join(str(c) for c in mock_echo.call_args_list) + assert "Kept games, no lock (1):" in output + assert "Kept (AppID=2)" in output + assert "abandon-pick 2" in output diff --git a/steam_backlog_enforcer/tests/test_web_daemon.py b/steam_backlog_enforcer/tests/test_web_daemon.py index fb02a59..d6037c7 100644 --- a/steam_backlog_enforcer/tests/test_web_daemon.py +++ b/steam_backlog_enforcer/tests/test_web_daemon.py @@ -9,7 +9,12 @@ import pytest from steam_backlog_enforcer import _web_daemon from steam_backlog_enforcer._ctl_protocol import CtlError, DaemonUnreachableError -from steam_backlog_enforcer._ctl_types import DaemonInfo, PendingReset, ResetResult +from steam_backlog_enforcer._ctl_types import ( + DaemonInfo, + FastInstallInfo, + PendingReset, + ResetResult, +) from steam_backlog_enforcer._web_errors import ApiError from steam_backlog_enforcer.jobs._view import list_jobs from steam_backlog_enforcer.tests._web_request import make_request, payload_of @@ -45,6 +50,7 @@ class TestDaemonView: with patch(f"{_CLIENT}.status", side_effect=DaemonUnreachableError): reply = _web_daemon.daemon_view(make_request()) assert payload_of(reply)["state"] == "unreachable" + assert payload_of(reply)["fast_installs"] == [] def test_status_refusal_is_an_error(self) -> None: with ( @@ -67,8 +73,27 @@ class TestDaemonView: "pid": 77, "journal_tail": ["a", "b"], "restart_available_at": None, + "fast_installs": [], } + def test_fast_installs_pass_through(self) -> None: + run = FastInstallInfo(7, "G", "downloading depot 3/8", 46.9, 60) + info = DaemonInfo("running", "t", 77, None, fast_installs=(run,)) + with ( + patch(f"{_CLIENT}.status", return_value=info), + patch(f"{_CLIENT}.journal_tail", return_value=[]), + ): + reply = _web_daemon.daemon_view(make_request()) + assert payload_of(reply)["fast_installs"] == [ + { + "app_id": 7, + "name": "G", + "phase": "downloading depot 3/8", + "percent": 46.9, + "elapsed_seconds": 60, + } + ] + @pytest.mark.parametrize( "failure", [DaemonUnreachableError(), CtlError("busy", "m")] ) diff --git a/web/mock/catalog.ts b/web/mock/catalog.ts index e6d7c96..ab4c7c9 100644 --- a/web/mock/catalog.ts +++ b/web/mock/catalog.ts @@ -39,7 +39,13 @@ export const BASE: Base[] = [ spec('check', 'Check assigned game for a new achievement', 'backlog', 'job', { mutating: false }), spec('scan', 'Scan library & assign a game', 'backlog', 'job', { cancellable: true }), spec('done', 'Move on after a new achievement, pick next', 'backlog', 'job'), - spec('pick', 'Manually pick your next game from candidates', 'backlog', 'job', { cancellable: true }), + spec('pick', 'Pick another game from candidates (keeps current; --replace swaps)', 'backlog', 'job', { + cancellable: true, + params: [{ + name: 'replace', label: 'Replace current games (uninstalls them)', type: 'bool', required: false, default: 0, + help: 'Leave unchecked to keep playing them alongside the new pick.', + }], + }), spec('pick-manual', 'Pick a game by app_id, lock enforcer for 14 days', 'backlog', 'job', { params: [APP_ID], friction: phrase('lock in {game_name}'), }), diff --git a/web/mock/http.ts b/web/mock/http.ts index 68c4d08..5234ea8 100644 --- a/web/mock/http.ts +++ b/web/mock/http.ts @@ -60,6 +60,7 @@ function checkParam(p: ParamSpec, raw: unknown): number | string | undefined { } if (p.type === 'string') return String(raw) const n = Number(raw) + if (p.type === 'bool' && n !== 0 && n !== 1) throw new HttpError(400, 'invalid_params', `${p.label} must be true or false.`) if (!Number.isInteger(n)) throw new HttpError(400, 'invalid_params', `${p.label} must be a whole number.`) if (p.min !== undefined && n < p.min) throw new HttpError(400, 'invalid_params', `${p.label} must be at least ${p.min}.`) if (p.max !== undefined && n > p.max) throw new HttpError(400, 'invalid_params', `${p.label} must be at most ${p.max}.`) diff --git a/web/mock/scripts.ts b/web/mock/scripts.ts index 2122150..9741c76 100644 --- a/web/mock/scripts.ts +++ b/web/mock/scripts.ts @@ -76,6 +76,12 @@ const pick: Script = async (ctx) => { } ctx.progress(2, 2, 'Assigning', gameName(Number(chosen))) await ctx.sleep(700) + // Mirrors _pick_keep: unchecked parks the outgoing game; replace drops all. + const old = world.currentAppId + if (Number(ctx.params.replace)) world.keptPicks = [] + else if (old !== null && !world.keptPicks.includes(old) && !world.manualPicks.some((p) => p.app_id === old)) { + world.keptPicks.push(old) + } world.currentAppId = Number(chosen) return { summary: `Assigned ${gameName(Number(chosen))}.` } } @@ -177,6 +183,7 @@ export const SCRIPTS: Partial> = { 'abandon-pick': async (ctx) => { await ctx.sleep(800) world.manualPicks = world.manualPicks.filter((p) => p.app_id !== Number(ctx.params.app_id)) + world.keptPicks = world.keptPicks.filter((id) => id !== Number(ctx.params.app_id)) return { summary: `Abandoned ${gameName(Number(ctx.params.app_id))}.` } }, 'add-exception': async (ctx) => { diff --git a/web/mock/world.ts b/web/mock/world.ts index d90c34e..f3631ee 100644 --- a/web/mock/world.ts +++ b/web/mock/world.ts @@ -35,6 +35,8 @@ export interface World { storeBlockedUntil: number | null totalBlockUntil: number | null manualPicks: { app_id: number; picked_at: number }[] + /** Games `pick` kept: allowed, no lock. */ + keptPicks: number[] installed: InstalledGame[] backups: StateBackup[] journal: string[] @@ -57,6 +59,7 @@ export const world: World = { storeBlockedUntil: null, totalBlockUntil: null, manualPicks: [], + keptPicks: [], installed: makeInstalled(1145360), backups: [ { id: '20261009-221504', created_at: iso(now - DAY), reason: 'before reset', size_bytes: 48_211 }, @@ -106,6 +109,7 @@ export function status(): StatusPayload { name: gameName(p.app_id), age_days: Math.floor((t - p.picked_at) / DAY), })), + kept_picks: world.keptPicks.map((id) => ({ app_id: id, name: gameName(id) })), } } @@ -163,6 +167,7 @@ export function daemonStatus(): DaemonStatus { journal_tail: reachable ? world.journal.slice(-60) : [], restart_available_at: world.restartAvailableAt !== null && world.restartAvailableAt > t ? iso(world.restartAvailableAt) : null, + fast_installs: [], } } diff --git a/web/package-lock.json b/web/package-lock.json index e0c5161..c8aa81e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,7 @@ "name": "web", "version": "0.0.0", "dependencies": { - "@tanstack/react-query": "5.104.4", + "@tanstack/react-query": "5.104.5", "@tanstack/react-router": "1.170.45", "react": "19.3.1", "react-dom": "19.3.1" @@ -33,8 +33,8 @@ "globals": "17.13.0", "jsdom": "30.1.3", "typescript": "6.0.3", - "typescript-eslint": "8.71.2", - "vite": "8.3.4", + "typescript-eslint": "8.72.0", + "vite": "8.3.5", "vitest": "5.0.5" } }, @@ -1186,9 +1186,9 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.153.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", - "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", "dev": true, "license": "MIT", "funding": { @@ -1196,9 +1196,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", - "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", "cpu": [ "arm" ], @@ -1213,9 +1213,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", - "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", "cpu": [ "arm64" ], @@ -1230,9 +1230,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", - "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", "cpu": [ "arm64" ], @@ -1247,9 +1247,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", - "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", "cpu": [ "x64" ], @@ -1264,9 +1264,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", - "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", "cpu": [ "x64" ], @@ -1281,9 +1281,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", - "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", "cpu": [ "arm" ], @@ -1298,9 +1298,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", - "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", "cpu": [ "arm64" ], @@ -1318,9 +1318,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", - "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", "cpu": [ "arm64" ], @@ -1338,9 +1338,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", - "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", "cpu": [ "ppc64" ], @@ -1358,9 +1358,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", - "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", "cpu": [ "s390x" ], @@ -1378,9 +1378,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", - "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", "cpu": [ "x64" ], @@ -1398,9 +1398,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", - "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", "cpu": [ "x64" ], @@ -1418,9 +1418,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", - "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", "cpu": [ "arm64" ], @@ -1435,9 +1435,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", - "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", "cpu": [ "arm64" ], @@ -1452,9 +1452,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", - "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", "cpu": [ "x64" ], @@ -1520,9 +1520,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.104.4", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.4.tgz", - "integrity": "sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==", + "version": "5.104.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.5.tgz", + "integrity": "sha512-Q94gI8Opd+CbdVNOdj8ZCyV8WqLYitrDq1t96Xc8WZT6lY0r5iRnJLXYDxMex7EEf2v+mXvn4FcjaBKSxXZQ9A==", "license": "MIT", "funding": { "type": "github", @@ -1530,12 +1530,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.104.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.4.tgz", - "integrity": "sha512-YD5QkvUNy8Ey3LbOy2vwL97JjoYycwDv++cWAibTe+9BLYvl3c09XpYp5dZ2VcxcguwoDhGF+utYJYb79RB36w==", + "version": "5.104.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.5.tgz", + "integrity": "sha512-RORgFe052U+93weaNuqg7qHq7vNVNzXN/VrXqD4bbAuQCWUqOl2lV5Ui9flWk5CVvMyL/Bh27QWhzwH0aaOx9g==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.104.4" + "@tanstack/query-core": "5.104.5" }, "funding": { "type": "github", @@ -1810,17 +1810,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", - "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz", + "integrity": "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.71.2", - "@typescript-eslint/type-utils": "8.71.2", - "@typescript-eslint/utils": "8.71.2", - "@typescript-eslint/visitor-keys": "8.71.2", + "@typescript-eslint/scope-manager": "8.72.0", + "@typescript-eslint/type-utils": "8.72.0", + "@typescript-eslint/utils": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1833,15 +1833,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.71.2", + "@typescript-eslint/parser": "^8.72.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", - "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.13.tgz", + "integrity": "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==", "dev": true, "license": "MIT", "engines": { @@ -1849,16 +1849,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", - "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.0.tgz", + "integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.71.2", - "@typescript-eslint/types": "8.71.2", - "@typescript-eslint/typescript-estree": "8.71.2", - "@typescript-eslint/visitor-keys": "8.71.2", + "@typescript-eslint/scope-manager": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", "debug": "^4.4.3" }, "engines": { @@ -1874,14 +1874,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", - "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz", + "integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.2", - "@typescript-eslint/types": "^8.71.2", + "@typescript-eslint/tsconfig-utils": "^8.72.0", + "@typescript-eslint/types": "^8.72.0", "debug": "^4.4.3" }, "engines": { @@ -1896,14 +1896,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", - "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz", + "integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.2", - "@typescript-eslint/visitor-keys": "8.71.2" + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1914,9 +1914,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", - "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz", + "integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==", "dev": true, "license": "MIT", "engines": { @@ -1931,15 +1931,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", - "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.0.tgz", + "integrity": "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.2", - "@typescript-eslint/typescript-estree": "8.71.2", - "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0", + "@typescript-eslint/utils": "8.72.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1956,9 +1956,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", - "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz", + "integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==", "dev": true, "license": "MIT", "engines": { @@ -1970,16 +1970,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", - "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz", + "integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.2", - "@typescript-eslint/tsconfig-utils": "8.71.2", - "@typescript-eslint/types": "8.71.2", - "@typescript-eslint/visitor-keys": "8.71.2", + "@typescript-eslint/project-service": "8.72.0", + "@typescript-eslint/tsconfig-utils": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2011,16 +2011,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", - "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz", + "integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.71.2", - "@typescript-eslint/types": "8.71.2", - "@typescript-eslint/typescript-estree": "8.71.2" + "@typescript-eslint/scope-manager": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2035,13 +2035,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", - "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz", + "integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/types": "8.72.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3892,9 +3892,9 @@ } }, "node_modules/postcss": { - "version": "8.5.29", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", - "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "dev": true, "funding": [ { @@ -3912,7 +3912,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.19", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" }, @@ -4028,13 +4028,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", - "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.153.0", + "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4044,21 +4044,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.13", - "@rolldown/binding-android-arm64": "1.2.13", - "@rolldown/binding-darwin-arm64": "1.2.13", - "@rolldown/binding-darwin-x64": "1.2.13", - "@rolldown/binding-freebsd-x64": "1.2.13", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", - "@rolldown/binding-linux-arm64-gnu": "1.2.13", - "@rolldown/binding-linux-arm64-musl": "1.2.13", - "@rolldown/binding-linux-ppc64-gnu": "1.2.13", - "@rolldown/binding-linux-s390x-gnu": "1.2.13", - "@rolldown/binding-linux-x64-gnu": "1.2.13", - "@rolldown/binding-linux-x64-musl": "1.2.13", - "@rolldown/binding-openharmony-arm64": "1.2.13", - "@rolldown/binding-win32-arm64-msvc": "1.2.13", - "@rolldown/binding-win32-x64-msvc": "1.2.13" + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, "node_modules/saxes": { @@ -4298,16 +4298,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", - "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.0.tgz", + "integrity": "sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.71.2", - "@typescript-eslint/parser": "8.71.2", - "@typescript-eslint/typescript-estree": "8.71.2", - "@typescript-eslint/utils": "8.71.2" + "@typescript-eslint/eslint-plugin": "8.72.0", + "@typescript-eslint/parser": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0", + "@typescript-eslint/utils": "8.72.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4402,16 +4402,16 @@ } }, "node_modules/vite": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", - "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.29", - "rolldown": "~1.2.12", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -4428,7 +4428,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/web/package.json b/web/package.json index 841e037..cf3cb6f 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,7 @@ "coverage": "vitest run --coverage" }, "dependencies": { - "@tanstack/react-query": "5.104.4", + "@tanstack/react-query": "5.104.5", "@tanstack/react-router": "1.170.45", "react": "19.3.1", "react-dom": "19.3.1" @@ -39,8 +39,8 @@ "globals": "17.13.0", "jsdom": "30.1.3", "typescript": "6.0.3", - "typescript-eslint": "8.71.2", - "vite": "8.3.4", + "typescript-eslint": "8.72.0", + "vite": "8.3.5", "vitest": "5.0.5" } } diff --git a/web/src/api/contract.ts b/web/src/api/contract.ts index 7152843..136caf4 100644 --- a/web/src/api/contract.ts +++ b/web/src/api/contract.ts @@ -47,7 +47,8 @@ export type CommandCategory = 'backlog' | 'library' | 'gaming' | 'store' | 'syst export interface ParamSpec { name: string label: string - type: 'int' | 'string' | 'app_id' + /** `bool` is a checkbox, sent as 0/1. */ + type: 'int' | 'string' | 'app_id' | 'bool' required: boolean min?: number max?: number @@ -101,6 +102,13 @@ export interface StatusPayload { total_block: { active: boolean; days_remaining: number; until: string | null } manual_pick_locked: boolean manual_picks: ManualPickInfo[] + /** Released/expired picks and games `pick` kept: allowed, no lock. */ + kept_picks: KeptPickInfo[] +} + +export interface KeptPickInfo { + app_id: number + name: string } export interface StatsPayload { @@ -156,6 +164,17 @@ export interface SetupRequest { export type DaemonState = 'running' | 'restarting' | 'unreachable' +/** A steam-game-installer run the root daemon started, read from its log. */ +export interface FastInstallRun { + app_id: number + name: string + /** e.g. `shutting Steam down`, `downloading depot 3/8`, `finishing manifest`. */ + phase: string + /** The current depot's percent; null until depotdownloader reports one. */ + percent: number | null + elapsed_seconds: number +} + export interface DaemonStatus { state: DaemonState started_at: string | null @@ -163,6 +182,11 @@ export interface DaemonStatus { journal_tail: string[] /** ISO time before which `restart` is rate-limited, or null. */ restart_available_at: string | null + /** + * Empty when none is running or the daemon is unreachable; absent from a + * web server older than this UI (the window the `stale` health flag covers). + */ + fast_installs?: FastInstallRun[] } export interface ServerHealth { diff --git a/web/src/commands/ParamsForm.test.tsx b/web/src/commands/ParamsForm.test.tsx index d7c94b3..4101c06 100644 --- a/web/src/commands/ParamsForm.test.tsx +++ b/web/src/commands/ParamsForm.test.tsx @@ -56,4 +56,20 @@ describe('ParamsForm', () => { render() expect(screen.getByLabelText('Note')).toBeDisabled() }) + + it('renders a bool as an unchecked checkbox that toggles 0/1', async () => { + const box: ParamSpec[] = [{ name: 'replace', label: 'Replace', type: 'bool', required: false, default: 0 }] + function BoxHarness() { + const [values, setValues] = useState({ replace: '0' }) + return + } + render() + const input = screen.getByLabelText('Replace') + expect(input).toHaveAttribute('type', 'checkbox') + expect(input).not.toBeChecked() + await userEvent.click(input) + expect(input).toBeChecked() + await userEvent.click(input) + expect(input).not.toBeChecked() + }) }) diff --git a/web/src/commands/ParamsForm.tsx b/web/src/commands/ParamsForm.tsx index e488b4c..93dd10b 100644 --- a/web/src/commands/ParamsForm.tsx +++ b/web/src/commands/ParamsForm.tsx @@ -18,6 +18,26 @@ export function ParamsForm({ specs, values, onChange, games, showErrors, disable {specs.map((p, i) => { const id = `${base}-${p.name}` const raw = values[p.name] ?? '' + if (p.type === 'bool') { + return ( +
+ +

+ {p.help ?? ' '} +

+
+ ) + } const err = paramError(p, raw, games) const game = p.type === 'app_id' ? games.find((g) => g.app_id === Number(raw)) : undefined return ( diff --git a/web/src/commands/RunnerDialog.tsx b/web/src/commands/RunnerDialog.tsx index 21390a8..be881ba 100644 --- a/web/src/commands/RunnerDialog.tsx +++ b/web/src/commands/RunnerDialog.tsx @@ -172,7 +172,7 @@ function Runner({ spec, fixed, preset, onClose, onArmed }: RunnerProps) { )}
-
+ ) diff --git a/web/src/screens/Picks.tsx b/web/src/screens/Picks.tsx index 57d458f..6c1aa3b 100644 --- a/web/src/screens/Picks.tsx +++ b/web/src/screens/Picks.tsx @@ -7,6 +7,7 @@ import { Actions, Card, PageHead } from '../ui/Page' /** Everything that changes which game is assigned. */ export function Picks() { const { data, error } = useQuery(q.status) + const none = data !== undefined && data.manual_picks.length === 0 && data.kept_picks.length === 0 return ( <> {error && } {!data && !error && } - {data && data.manual_picks.length === 0 && ( + {none && ( No manual picks. "Lock in a game" adds one and locks most commands for 14 days. )}
    @@ -35,8 +36,17 @@ export function Picks() { ))} + {/* Released, expired or kept by "Pick…": still installed and playable. */} + {data?.kept_picks.map((p) => ( +
  • + + {p.name} · kept · no lock · {p.app_id} + + +
  • + ))}
- {data && data.manual_picks.length === 0 && ( + {none && (
diff --git a/web/src/screens/Small.test.tsx b/web/src/screens/Small.test.tsx index f26d722..3e79c20 100644 --- a/web/src/screens/Small.test.tsx +++ b/web/src/screens/Small.test.tsx @@ -37,6 +37,21 @@ describe('Picks', () => { expect(screen.getByText('Hollow Knight', { selector: 'strong' })).toBeInTheDocument() }) + it('lists kept games under the active picks, each abandonable', async () => { + stubApi({ + 'GET /api/commands': SPECS, + 'GET /api/status': makeStatus({ + manual_picks: [{ app_id: 5, name: 'Five', age_days: 3 }], + kept_picks: [{ app_id: 7, name: 'Seven' }], + }), + }) + renderRouted() + expect(await screen.findByText('Seven')).toBeInTheDocument() + expect(screen.getByText(/kept · no lock · 7/)).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: 'Abandon…' })).toHaveLength(2) + expect(screen.queryByText(/No manual picks/)).toBeNull() + }) + it('invites locking in a game when there are none', async () => { stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': makeStatus({ current_game_name: null }) }) renderRouted() diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts index 40fd3b1..3d69abf 100644 --- a/web/src/test/fixtures.ts +++ b/web/src/test/fixtures.ts @@ -57,6 +57,7 @@ export function makeStatus(over: Partial = {}): StatusPayload { total_block: { active: false, days_remaining: 0, until: null }, manual_pick_locked: false, manual_picks: [], + kept_picks: [], ...over, } }