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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUaX4zAa6H4sMu3jd6RFdb
This commit is contained in:
Krzysztof Rudnicki 2026-10-23 20:14:12 +02:00
parent 55cf19fae4
commit 1bdf360193
56 changed files with 2023 additions and 251 deletions

View File

@ -51,7 +51,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24.22.0 node-version: 24.22.1
cache: npm cache: npm
cache-dependency-path: web/package-lock.json cache-dependency-path: web/package-lock.json

View File

@ -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/commands` | – | `CommandSpec[]` — server-driven catalog |
| GET | `/api/setup` | – | `SetupStatus` (configured flags, no secrets) | | GET | `/api/setup` | – | `SetupStatus` (configured flags, no secrets) |
| POST | `/api/setup` | `SetupRequest` | `SetupStatus` (validated live against Steam) | | 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) | | GET | `/api/server` | – | `ServerHealth` (stale?, started_at, version) |
| POST | `/api/server/restart` | – | `202`; the user unit restarts the process | | POST | `/api/server/restart` | – | `202`; the user unit restarts the process |
| GET | `/api/backups` | – | `StateBackup[]` | | GET | `/api/backups` | – | `StateBackup[]` |
@ -205,6 +205,9 @@ hard-codes it.
`gaming_unblock{phrase}`, `gaming_reset_arm{phrase}`, `gaming_unblock{phrase}`, `gaming_reset_arm{phrase}`,
`gaming_reset_heartbeat{pending_id}`, `gaming_reset_commit{pending_id, phrase}`, `gaming_reset_heartbeat{pending_id}`, `gaming_reset_commit{pending_id, phrase}`,
`gaming_reset_cancel{pending_id}`, `restart{}`. There is no `stop`. `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) ## Verification record (2026-10-10)

View File

@ -15,9 +15,12 @@ from typing import TYPE_CHECKING, Any
from steam_backlog_enforcer._allowed_games import ( from steam_backlog_enforcer._allowed_games import (
active_manual_picks, active_manual_picks,
allowed_games, allowed_games,
drop_inactive_picks,
) )
from steam_backlog_enforcer._assignment_progress import record_assignment 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 # Marks these two as an intentional re-export (they're imported from
# _allowed_games above, not defined here) -- without this, mypy's # _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 its interactive ``YES`` confirmation. Keeping this state-only means an
automated caller (the MCP server) can never wipe installed games. automated caller (the MCP server) can never wipe installed games.
Picking is an explicit choice of another game, so released, finished and The games already allowed are kept, like plain ``pick``: released,
expired picks are dropped here (and enforcement may then remove them). 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: Args:
state: The enforcer state to mutate and save. state: The enforcer state to mutate and save.
app_id: The Steam app id to lock in. app_id: The Steam app id to lock in.
game_name: Human-readable name for the picked game. 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: Returns:
``None`` on success, or a message explaining why the pick was refused ``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) active = active_manual_picks(state)
@ -145,8 +153,15 @@ def apply_manual_pick(
f"(cap is {max_picks}): {names}." 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() 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( state.manual_picks.append(
{"app_id": app_id, "game_name": game_name, "started_at": now} {"app_id": app_id, "game_name": game_name, "started_at": now}
) )

View File

@ -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. """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 An inactive pick is *not* evicted: it stays in ``state.manual_picks``, and
so installed and playable, until the user explicitly chooses another game so installed and playable, until the user explicitly replaces it
(:func:`drop_inactive_picks`). (:func:`drop_inactive_picks`) or abandons it.
A pick stops being active once it earned a new achievement since it was 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 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. """Forget every pick that no longer holds the lock; return the dropped ones.
Released, finished and expired picks stay installed and playable for as 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 long as the user wants. Only an explicit replacement (``pick --replace``,
manual pick, or accepting the next assignment) calls this and so lets or accepting the next assignment in ``done``/``check``/``scan``) calls
enforcement remove them. Does not save. this and so lets enforcement remove them; plain ``pick`` and
``pick-manual`` keep them. Does not save.
Args: Args:
state: The enforcer state to mutate. 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 That is the current assignment plus every stored manual pick, including
released and expired ones: a game is only removed once the user has released and expired ones: a game is only removed once the user has
explicitly chosen something else. explicitly replaced or abandoned it.
Args: Args:
state: The loaded enforcer state. state: The loaded enforcer state.

View File

@ -27,7 +27,7 @@ COMMAND_DESCRIPTIONS: Final[dict[str, str]] = {
"uninstall": "Uninstall all non-assigned games", "uninstall": "Uninstall all non-assigned games",
"setup": "Run first-time setup", "setup": "Run first-time setup",
"done": "Move on after a new achievement, pick next", "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", "stats": "Show backlog completion-time estimates",
"gaming-status": "Show today's gaming time and block state", "gaming-status": "Show today's gaming time and block state",
"gaming-reset": "Reset today's gaming counter (root + phrase)", "gaming-reset": "Reset today's gaming counter (root + phrase)",

View File

@ -19,7 +19,7 @@ from steam_backlog_enforcer._store_window import (
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping from collections.abc import Mapping
ParamType = Literal["int", "string", "app_id"] ParamType = Literal["int", "string", "app_id", "bool"]
ParamValue = int | str ParamValue = int | str
@ -54,6 +54,17 @@ class ParamSpec:
_APP_ID = ParamSpec("app_id", "Steam app id", "app_id", min=1) _APP_ID = ParamSpec("app_id", "Steam app id", "app_id", min=1)
PARAMS: Final[dict[str, tuple[ParamSpec, ...]]] = { 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,), "pick-manual": (_APP_ID,),
"abandon-pick": (_APP_ID,), "abandon-pick": (_APP_ID,),
"add-exception": ( "add-exception": (
@ -106,6 +117,13 @@ def _coerce(spec: ParamSpec, raw: object) -> ParamValue:
msg = f"{spec.name} must be a non-empty string" msg = f"{spec.name} must be a non-empty string"
raise InvalidParamsError(msg) raise InvalidParamsError(msg)
return raw.strip() 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. # bool is an int subclass in Python; true/false is never a count.
if isinstance(raw, bool) or not isinstance(raw, (int, str)): if isinstance(raw, bool) or not isinstance(raw, (int, str)):
msg = f"{spec.name} must be a whole number" msg = f"{spec.name} must be a whole number"

View File

@ -26,6 +26,7 @@ from steam_backlog_enforcer._ctl_protocol import (
from steam_backlog_enforcer._ctl_types import ( from steam_backlog_enforcer._ctl_types import (
BlockResult, BlockResult,
DaemonInfo, DaemonInfo,
FastInstallInfo,
PendingReset, PendingReset,
ResetResult, ResetResult,
StoreWindow, StoreWindow,
@ -130,15 +131,29 @@ def ping() -> None:
call("ping") 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: 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") data = call("status")
until = data.get("restart_available_at") until = data.get("restart_available_at")
runs = cast("list[dict[str, object]]", data.get("fast_installs") or [])
return DaemonInfo( return DaemonInfo(
state=str(data["state"]), state=str(data["state"]),
started_at=str(data["started_at"]), started_at=str(data["started_at"]),
pid=int(cast("int", data["pid"])), pid=int(cast("int", data["pid"])),
restart_available_at=str(until) if until else None, restart_available_at=str(until) if until else None,
fast_installs=tuple(map(_fast_install_info, runs)),
) )

View File

@ -13,6 +13,7 @@ import contextlib
import logging import logging
import os import os
import subprocess import subprocess
import time
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._ctl_context import ( 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_protocol import OP_FAILED, CtlError
from steam_backlog_enforcer._ctl_reset import RESET_OPS 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._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._playtime_block import release_block
from steam_backlog_enforcer._store_window import MAX_WINDOW_MINUTES, open_store_window from steam_backlog_enforcer._store_window import MAX_WINDOW_MINUTES, open_store_window
from steam_backlog_enforcer._total_block import ( 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]: 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 { return {
"state": "restarting" if ctx.restart_event.is_set() else "running", "state": "restarting" if ctx.restart_event.is_set() else "running",
"started_at": ctx.started_at.isoformat(), "started_at": ctx.started_at.isoformat(),
"pid": os.getpid(), "pid": os.getpid(),
"restart_available_at": restart_available_at(), "restart_available_at": restart_available_at(),
"fast_installs": fast_install_status(time.monotonic()),
} }

View File

@ -9,6 +9,21 @@ from __future__ import annotations
from dataclasses import dataclass 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) @dataclass(frozen=True)
class DaemonInfo: class DaemonInfo:
"""The daemon's own account of itself (``status``).""" """The daemon's own account of itself (``status``)."""
@ -17,6 +32,8 @@ class DaemonInfo:
started_at: str started_at: str
pid: int pid: int
restart_available_at: str | None restart_available_at: str | None
# Empty from a daemon too old to report them.
fast_installs: tuple[FastInstallInfo, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@ -7,21 +7,19 @@ taking over a slow client download loses nothing.
Two modes, picked by whether anyone can answer a prompt: Two modes, picked by whether anyone can answer a prompt:
* **Foreground** (stdin is a terminal - ``pick-manual``, ``done``...): run to * **Foreground** (stdin is a terminal - ``pick-manual``, ``done``...): run with
completion with the terminal attached, so depotdownloader's progress shows the terminal attached, so depotdownloader's progress shows and a password /
and a password / Steam Guard prompt can be answered. Steam Guard prompt can be answered.
* **Detached** (no terminal): spawn in its own session. The root daemon * **Detached** (no terminal): spawn in its own session. The root daemon returns
returns at once and reaps it on every later pass through at once; :func:`poll_fast_installs` reaps the run on later passes, records a
:func:`poll_fast_installs`, which records a failure (so the caller falls failure (the caller falls back to ``steam://install``) and interrupts a run
back to ``steam://install``) and interrupts a run whose output has stalled - whose output stalled - likely a login prompt nobody will answer, with Steam
most likely a login prompt nobody will ever answer, sitting there with Steam shut down. A web job loops that reaper until the run ends. Either way the
shut down. Anything else (a web job) exits once its command returns, so it log is followed for progress (:mod:`_fast_install_progress`).
runs that same reaper in a loop until the installer is done.
The installer closes Steam to do its work, so it is refused outright while a The installer closes Steam, so it is refused while a game is running. Another
game is running. Another app mid-download is no reason to wait: the installer app mid-download is no reason to wait: asked to leave with ``steam -shutdown``,
asks Steam to leave with ``steam -shutdown``, and Steam resumes the transfer Steam resumes the transfer when it comes back.
when it comes back.
""" """
from __future__ import annotations from __future__ import annotations
@ -38,6 +36,7 @@ import subprocess
import sys import sys
import time import time
from steam_backlog_enforcer import _fast_install_progress as _fip
from steam_backlog_enforcer import config as _config 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._desktop_env import desktop_user_cmd, resolve_desktop_user
from steam_backlog_enforcer._echo import _echo from steam_backlog_enforcer._echo import _echo
@ -176,9 +175,10 @@ def poll_fast_installs() -> None:
returncode, returncode,
run.log_path, 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.""" """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 # 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. # 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"): if os.geteuid() == 0 and shutil.which("systemd-run"):
cmd = ["systemd-run", "--scope", "--quiet", "--collect", *cmd] cmd = ["systemd-run", "--scope", "--quiet", "--collect", *cmd]
with log_path.open("ab") as log: 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( proc = subprocess.Popen(
cmd, cmd,
stdin=subprocess.DEVNULL, 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: def _wait_detached(app_id: int, label: str) -> bool:
"""Reap this process's run for *app_id* until it ends; True on success.""" """Reap this process's run for *app_id* until it ends; True on success."""
_echo( reporter = _fip.WaitReporter(app_id, label, started=time.monotonic())
f" Fast-installing {label} with steam-game-installer (Steam will close); "
"waiting for it to finish..."
)
while is_fast_installing(app_id): while is_fast_installing(app_id):
time.sleep(_WAIT_POLL_SECONDS) time.sleep(_WAIT_POLL_SECONDS)
poll_fast_installs() poll_fast_installs()
reporter.report(time.monotonic())
return app_id not in _FAILED 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) cmd = _build_cmd(installer, app_id, user)
if not sys.stdin.isatty(): 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) return os.geteuid() == 0 or _wait_detached(app_id, label)
_echo(f" Fast-installing {label} with steam-game-installer (Steam will close)...") _echo(f" Fast-installing {label} with steam-game-installer (Steam will close)...")

View File

@ -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-<euid>.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()}")

View File

@ -135,6 +135,7 @@ def status_payload(state: State) -> dict[str, Any]:
if state.current_app_id if state.current_app_id
else None else None
) )
active = active_manual_picks(state)
return { return {
"current_app_id": state.current_app_id, "current_app_id": state.current_app_id,
"current_game_name": state.current_game_name or None, "current_game_name": state.current_game_name or None,
@ -153,6 +154,9 @@ def status_payload(state: State) -> dict[str, Any]:
**pick, **pick,
"age_days": manual_pick_age_days(state, pick["app_id"]), "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],
} }

View File

@ -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 With ``confirm=False`` (the default) this performs **no** mutation and
returns a preview of what confirming would do. Call again with 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 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 achievement is earned in it). Unlike the CLI's ``pick-manual``, this
mutates **state only** — it never uninstalls, installs, or hides games. 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, "app_id": app_id,
"game_name": game_name, "game_name": game_name,
"effect": ( "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 " f"for {MANUAL_LOCK_DAYS} days or until you earn one new "
"achievement in it." "achievement in it."
), ),

View File

@ -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 <app_id>'"
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]

View File

@ -20,6 +20,7 @@ from steam_backlog_enforcer._own_pick import (
OWN_PICK_VALUE, OWN_PICK_VALUE,
prompt_own_pick, 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._prompter import PromptOption, current_prompter
from steam_backlog_enforcer._scanning_candidates import ( from steam_backlog_enforcer._scanning_candidates import (
_pick_next_shortest_candidate, _pick_next_shortest_candidate,
@ -80,13 +81,24 @@ def _assign_chosen_game(
games: list[GameInfo], games: list[GameInfo],
state: State, state: State,
config: Config, config: Config,
*,
keep: bool = False,
) -> None: ) -> None:
"""Save assignment, announce it, and handle install/uninstall. """Save assignment, announce it, and handle install/uninstall.
Accepting a new game is the explicit choice that lets enforcement remove Replacing (the default, and what ``done``/``check`` do) is the explicit
released picks, so they are dropped here. 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) record_assignment(state, chosen.app_id, chosen.name)
state.save() state.save()
hours_str = ( hours_str = (

View File

@ -12,6 +12,7 @@ contract's ``PendingAction``.
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict
from http import HTTPStatus from http import HTTPStatus
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING, Final
@ -68,6 +69,7 @@ def daemon_view(_request: Request) -> Reply:
"pid": None, "pid": None,
"journal_tail": [], "journal_tail": [],
"restart_available_at": None, "restart_available_at": None,
"fast_installs": [],
} }
) )
except CtlError as exc: except CtlError as exc:
@ -83,6 +85,7 @@ def daemon_view(_request: Request) -> Reply:
"pid": info.pid, "pid": info.pid,
"journal_tail": journal, "journal_tail": journal,
"restart_available_at": info.restart_available_at, "restart_available_at": info.restart_available_at,
"fast_installs": [asdict(run) for run in info.fast_installs],
} }
) )

View File

@ -44,6 +44,10 @@ def status_view(_request: Request) -> Reply:
payload = status_payload(State.load()) payload = status_payload(State.load())
payload["assigned_game_installed"] = bool(payload["assigned_game_installed"]) payload["assigned_game_installed"] = bool(payload["assigned_game_installed"])
payload["manual_picks"] = [_manual_pick(p) for p in payload["manual_picks"]] 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) return ok(payload)

View File

@ -79,11 +79,21 @@ past its own 14-day deadline.
so freeing a slot cannot hand you an unrequested extra game. so freeing a slot cannot hand you an unrequested extra game.
- Releasing never evicts. A released, finished or expired pick stays in - Releasing never evicts. A released, finished or expired pick stays in
`State.manual_picks`, and therefore in `allowed_app_ids`, installed and `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 playable for as long as the user wants. Only an explicit replacement —
game — a new `pick-manual`, or accepting the next assignment in `pick --replace`, or accepting the next assignment in
`done`/`check`/`pick`/`scan` — calls `drop_inactive_picks`, after which `done`/`check`/`scan` — calls `drop_inactive_picks`, after which
enforcement may remove them. `abandon-pick` works on them too. When no enforcement may remove them. `abandon-pick` works on them too. When no
replacement is accepted, the current game stays assigned. 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 <id>`) 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 - `mark_finished` is the single writer of `finished_app_ids`, so the sweep
and `done`/`check` cannot both record the same completion. and `done`/`check` cannot both record the same completion.

View File

@ -95,6 +95,15 @@ def _add_exception(
return {"app_id": params["app_id"]} 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( def _enforce_demo(
params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter
) -> JobResult: ) -> JobResult:
@ -145,7 +154,7 @@ HANDLERS: dict[str, JobHandler] = {
"check": _cli(do_check), "check": _cli(do_check),
"scan": _cli(do_scan), "scan": _cli(do_scan),
"done": _cli(cmd_done), "done": _cli(cmd_done),
"pick": _cli(cmd_pick), "pick": _pick,
"pick-manual": _with_args(cmd_pick_manual, "app_id"), "pick-manual": _with_args(cmd_pick_manual, "app_id"),
"abandon-pick": _with_args(cmd_abandon_pick, "app_id"), "abandon-pick": _with_args(cmd_abandon_pick, "app_id"),
"install": _cli(cmd_install), "install": _cli(cmd_install),

View File

@ -187,6 +187,9 @@ def _dispatch_extra_command(command: str, config: Config, state: State) -> bool:
"unblock": lambda: cmd_unblock(config, state, args), "unblock": lambda: cmd_unblock(config, state, args),
"block-gaming": lambda: cmd_block_gaming(args), "block-gaming": lambda: cmd_block_gaming(args),
"serve": lambda: cmd_serve(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), "pick-manual": lambda: cmd_pick_manual(config, state, args),
"abandon-pick": lambda: cmd_abandon_pick(config, state, args), "abandon-pick": lambda: cmd_abandon_pick(config, state, args),
} }

View File

@ -3,14 +3,13 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from steam_backlog_enforcer._actions import ( from steam_backlog_enforcer._actions import (
ABANDON_COOLDOWN_DAYS as _ABANDON_COOLDOWN_DAYS, ABANDON_COOLDOWN_DAYS as _ABANDON_COOLDOWN_DAYS,
) )
from steam_backlog_enforcer._actions import ( from steam_backlog_enforcer._actions import (
active_manual_picks, active_manual_picks,
find_manual_pick,
) )
from steam_backlog_enforcer._manual_pick_lifecycle import abandon_manual_pick from steam_backlog_enforcer._manual_pick_lifecycle import abandon_manual_pick
from steam_backlog_enforcer._prompter import confirm_phrase 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]}'.") _echo(f"Error: app_id must be a number, got '{args[0]}'.")
return None 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: if not picks:
_echo("No manual pick is active — nothing to abandon.") _echo("No manual pick or kept game — nothing to abandon.")
return None 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) listed = ", ".join(f"{p['game_name']} (AppID={p['app_id']})" for p in picks)
_echo( _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 None
return app_id 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: def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None:
"""Back out of a manual pick, whatever its age. """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: if app_id is None:
sys.exit(1) 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 "" game_name = str(pick["game_name"]) if pick else ""
others = [p for p in active_manual_picks(state) if p["app_id"] != app_id] 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: if others:
kept = ", ".join(f"{p['game_name']} (AppID={p['app_id']})" for p in 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}") _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: else:
_echo("\n - Leaves you with no assigned game (run 'scan' to get one)") _echo("\n - Leaves you with no assigned game (run 'scan' to get one)")
_echo() _echo()

View File

@ -20,6 +20,11 @@ from steam_backlog_enforcer._pick_completion import (
report_completion, report_completion,
warn_stale_assignment, 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._progress import tracked
from steam_backlog_enforcer._prompter import confirm_phrase, current_prompter from steam_backlog_enforcer._prompter import confirm_phrase, current_prompter
from steam_backlog_enforcer._snapshot import load_snapshot, snapshot_game_name 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 from steam_backlog_enforcer.config import Config, State
def cmd_pick(config: Config, state: State) -> None: def cmd_pick(config: Config, state: State, args: list[str] | None = None) -> None:
"""Manually pick a new game from the shortest-first candidate list.""" """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() snapshot_data = load_snapshot()
if not snapshot_data: if not snapshot_data:
_echo("No snapshot found. Run 'scan' first.") _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: if game.app_id in hltb_cache:
game.completionist_hours = hltb_cache[game.app_id] 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: if state.current_app_id is not None:
hide_others_and_report(config, state) hide_others_and_report(config, state)
@ -84,12 +109,15 @@ def _resolve_game_name(config: Config, app_id: int) -> str | None:
return None return None
def _report_pick_slots(config: Config, state: State) -> list[dict[str, object]]: def _report_pick_slots(
"""Show which manual picks are already locked in; exit if the cap is full. 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: Args:
config: Enforcer configuration (for ``max_manual_picks``). config: Enforcer configuration (for ``max_manual_picks``).
state: Current enforcer state. state: Current enforcer state.
app_id: The game about to be picked (an allowed one adds nothing).
Returns: Returns:
The currently-active manual picks. 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." f"\ncan be abandoned at any time."
) )
sys.exit(1) 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 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. """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. 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: Args:
config: Enforcer configuration. config: Enforcer configuration.
@ -173,7 +205,7 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None:
retired = report_completion(config, state) retired = report_completion(config, state)
existing = _report_pick_slots(config, state) existing = _report_pick_slots(config, state, app_id)
_echo( _echo(
f"\nWARNING: Picking this game will:" 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 {', '.join(sorted(_MANUAL_LOCK_EXEMPT_COMMANDS))}"
f"\n - Stay undoable at any time via 'abandon-pick {app_id}'" f"\n - Stay undoable at any time via 'abandon-pick {app_id}'"
) )
active = active_manual_picks(state) kept = kept_alongside(state, app_id)
for old_pick in (p for p in state.manual_picks if p not in active): if kept:
_echo( _echo(f" - Keep these allowed, they stay installed: {', '.join(kept)}")
f" - Replace {old_pick['game_name']} (released or expired; it stays"
" playable until you confirm)"
)
_echo() _echo()
if not confirm_phrase( if not confirm_phrase(
"pick-manual", "pick-manual",

View File

@ -71,6 +71,16 @@ def cmd_status(config: Config, state: State) -> None:
_echo(f" {pick['game_name']} (AppID={pick['app_id']}){since}") _echo(f" {pick['game_name']} (AppID={pick['app_id']}){since}")
_echo("\n[MANUAL PICK LOCK is active — most commands are blocked]") _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: def cmd_list(_config: Config, state: State) -> None:
"""List games from the last snapshot.""" """List games from the last snapshot."""

View File

@ -6,6 +6,7 @@ import logging
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from steam_backlog_enforcer._actions import allowed_app_ids
from steam_backlog_enforcer._assignment_progress import ( from steam_backlog_enforcer._assignment_progress import (
release_game, release_game,
release_verdict, release_verdict,
@ -153,6 +154,7 @@ def pick_next_game(
config: Config, config: Config,
*, *,
on_select: Callable[[GameInfo], bool | GameInfo] | None = None, on_select: Callable[[GameInfo], bool | GameInfo] | None = None,
keep: bool = False,
) -> None: ) -> None:
"""Present a ranked list of eligible games and let the user pick one. """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 bypassed: the function instead presents the shortest playable
candidate to ``on_select`` (typically a yes/no prompt) and, if the candidate to ``on_select`` (typically a yes/no prompt) and, if the
callback rejects it, records a 7-day skip and re-evaluates. 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: if on_select is not None:
_pick_next_game_sequential(games, state, config, on_select) _pick_next_game_sequential(games, state, config, on_select)
return 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: if not candidates:
_clear_assignment(state, _NO_CONF_MSG) _clear_assignment(state, _NO_CONF_MSG)
return return
@ -182,8 +192,8 @@ def pick_next_game(
_clear_assignment(state, _no_pick_message(confidence_skipped, linux_skipped)) _clear_assignment(state, _no_pick_message(confidence_skipped, linux_skipped))
return return
chosen = _prompt_user_pick(qualified, games, state) chosen = _prompt_user_pick(qualified, pool, state)
_assign_chosen_game(chosen, games, state, config) _assign_chosen_game(chosen, games, state, config, keep=keep)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────

View File

@ -18,7 +18,7 @@ from unittest.mock import patch
import pytest import pytest
from steam_backlog_enforcer import _fast_install from steam_backlog_enforcer import _fast_install, _fast_install_progress
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator from collections.abc import Iterator
@ -39,6 +39,7 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]:
fake_steamapps.mkdir(exist_ok=True) fake_steamapps.mkdir(exist_ok=True)
_fast_install._RUNS.clear() _fast_install._RUNS.clear()
_fast_install._FAILED.clear() _fast_install._FAILED.clear()
_fast_install_progress._TRACKERS.clear()
patches = [patch(name, return_value=False) for name in _RUNNING_BINDINGS] patches = [patch(name, return_value=False) for name in _RUNNING_BINDINGS]
patches += [ patches += [
# Absolute, so `home / _INSTALLER` resolves here and never under ~. # Absolute, so `home / _INSTALLER` resolves here and never under ~.
@ -54,3 +55,4 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]:
p.stop() p.stop()
_fast_install._RUNS.clear() _fast_install._RUNS.clear()
_fast_install._FAILED.clear() _fast_install._FAILED.clear()
_fast_install_progress._TRACKERS.clear()

View File

@ -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...

View File

@ -197,11 +197,11 @@ class TestApplyManualPickCap:
assert refused is not None assert refused is not None
assert "already one of your manual picks" in refused assert "already one of your manual picks" in refused
def test_prunes_finished_entries(self) -> None: def test_keeps_finished_entries(self) -> None:
"""Test prunes finished entries.""" """A finished pick stays allowed: a new pick no longer drops it."""
state = State(manual_picks=[_pick(440, "TF2")], finished_app_ids=[440]) 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 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: def test_slots_left(self) -> None:
"""Test slots left.""" """Test slots left."""

View File

@ -22,6 +22,7 @@ from steam_backlog_enforcer._ctl_protocol import (
from steam_backlog_enforcer._ctl_types import ( from steam_backlog_enforcer._ctl_types import (
BlockResult, BlockResult,
DaemonInfo, DaemonInfo,
FastInstallInfo,
PendingReset, PendingReset,
ResetResult, ResetResult,
StoreWindow, StoreWindow,
@ -125,7 +126,24 @@ class TestTypedWrappers:
def test_status_no_restart_time(self) -> None: def test_status_no_restart_time(self) -> None:
data = {"state": "running", "started_at": "t", "pid": 4} data = {"state": "running", "started_at": "t", "pid": 4}
with self._call(data): 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: def test_journal_tail(self) -> None:
with self._call({"lines": ["a", 2]}) as call: with self._call({"lines": ["a", 2]}) as call:

View File

@ -13,7 +13,7 @@ from unittest.mock import MagicMock, patch
import pytest 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._ctl_protocol import CtlError
from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx
@ -35,6 +35,24 @@ class TestSimpleOps:
assert data["restart_available_at"] is None assert data["restart_available_at"] is None
assert isinstance(data["pid"], int) 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: def test_status_restarting(self) -> None:
ctx = make_ctx() ctx = make_ctx()
ctx.restart_event.set() ctx.restart_event.set()

View File

@ -12,7 +12,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch 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: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@ -26,15 +27,21 @@ def _ready(tmp_path: Path) -> Path:
return installer return installer
def _fake_spawn(*returncodes: int | None) -> MagicMock: def _fake_spawn(*returncodes: int | None, log_text: bytes | None = None) -> MagicMock:
"""A ``_spawn_detached`` that registers a run answering *returncodes*.""" """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 = MagicMock(pid=4321)
proc.poll.side_effect = list(returncodes) proc.poll.side_effect = list(returncodes)
_fast_install._RUNS.append( log = config.CONFIG_DIR / "x.log"
_fast_install._DetachedRun(app_id, proc, 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) return MagicMock(side_effect=spawn)
@ -62,6 +69,18 @@ class TestJobWaitsForInstaller:
assert self._install(tmp_path, _fake_spawn(None, 3), 1000) is False assert self._install(tmp_path, _fake_spawn(None, 3), 1000) is False
assert {7} == _fast_install._FAILED 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: def test_daemon_returns_at_once(self, tmp_path: Path) -> None:
assert self._install(tmp_path, _fake_spawn(), 0) is True assert self._install(tmp_path, _fake_spawn(), 0) is True
assert _fast_install.is_fast_installing(7) assert _fast_install.is_fast_installing(7)
@ -82,3 +101,17 @@ class TestLogIsPerUser:
_fast_install._spawn_detached(["run.sh"], 9) _fast_install._spawn_detached(["run.sh"], 9)
popen.assert_called_once() popen.assert_called_once()
assert _fast_install._RUNS[0].log_path != daemon_log 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

View File

@ -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

View File

@ -123,7 +123,7 @@ class TestPickManualCap:
class TestPickManualRetirementNotices: class TestPickManualRetirementNotices:
"""A pick retired by the sweep is announced before the typed-phrase prompt.""" """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 = locked_state(app_id=100)
state.current_app_id = 200 state.current_app_id = 200
state.current_game_name = "Finished" state.current_game_name = "Finished"
@ -146,7 +146,8 @@ class TestPickManualRetirementNotices:
): ):
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"]) cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
output = " ".join(str(c) for c in mock_echo.call_args_list) 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 assert "Aborted." in output
# Abort leaves the finished game as the assignment, so say so. # Abort leaves the finished game as the assignment, so say so.
note = " ".join(str(c) for c in mock_note.call_args_list) note = " ".join(str(c) for c in mock_note.call_args_list)

View File

@ -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

View File

@ -144,7 +144,9 @@ class TestCmdPick:
config = Config(steam_api_key="k", steam_id="i") config = Config(steam_api_key="k", steam_id="i")
state = State() 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)) captured_games.append(list(games))
with ( with (

View File

@ -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})

View File

@ -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}

View File

@ -104,12 +104,12 @@ class TestReleasedPicksStayPlayable:
assert [p["app_id"] for p in dropped] == [2] assert [p["app_id"] for p in dropped] == [2]
assert [p["app_id"] for p in state.manual_picks] == [3] 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)]) state = State(current_app_id=2, manual_picks=[pick(2, released_at=STARTED_AT)])
with patch.object(State, "save"): with patch.object(State, "save"):
assert apply_manual_pick(state, 4, "G4", max_picks=1) is None assert apply_manual_pick(state, 4, "G4", max_picks=1) is None
assert allowed_app_ids(state) == {4} assert allowed_app_ids(state) == {2, 4}
assert state.current_assigned_at == state.manual_picks[0]["started_at"] assert state.current_assigned_at == state.manual_picks[-1]["started_at"]
def test_released_pick_can_be_abandoned(self) -> None: def test_released_pick_can_be_abandoned(self) -> None:
state = State( state = State(

View File

@ -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

View File

@ -9,7 +9,12 @@ import pytest
from steam_backlog_enforcer import _web_daemon from steam_backlog_enforcer import _web_daemon
from steam_backlog_enforcer._ctl_protocol import CtlError, DaemonUnreachableError 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._web_errors import ApiError
from steam_backlog_enforcer.jobs._view import list_jobs from steam_backlog_enforcer.jobs._view import list_jobs
from steam_backlog_enforcer.tests._web_request import make_request, payload_of 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): with patch(f"{_CLIENT}.status", side_effect=DaemonUnreachableError):
reply = _web_daemon.daemon_view(make_request()) reply = _web_daemon.daemon_view(make_request())
assert payload_of(reply)["state"] == "unreachable" assert payload_of(reply)["state"] == "unreachable"
assert payload_of(reply)["fast_installs"] == []
def test_status_refusal_is_an_error(self) -> None: def test_status_refusal_is_an_error(self) -> None:
with ( with (
@ -67,8 +73,27 @@ class TestDaemonView:
"pid": 77, "pid": 77,
"journal_tail": ["a", "b"], "journal_tail": ["a", "b"],
"restart_available_at": None, "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( @pytest.mark.parametrize(
"failure", [DaemonUnreachableError(), CtlError("busy", "m")] "failure", [DaemonUnreachableError(), CtlError("busy", "m")]
) )

View File

@ -39,7 +39,13 @@ export const BASE: Base[] = [
spec('check', 'Check assigned game for a new achievement', 'backlog', 'job', { mutating: false }), 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('scan', 'Scan library & assign a game', 'backlog', 'job', { cancellable: true }),
spec('done', 'Move on after a new achievement, pick next', 'backlog', 'job'), 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', { 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}'), params: [APP_ID], friction: phrase('lock in {game_name}'),
}), }),

View File

@ -60,6 +60,7 @@ function checkParam(p: ParamSpec, raw: unknown): number | string | undefined {
} }
if (p.type === 'string') return String(raw) if (p.type === 'string') return String(raw)
const n = Number(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 (!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.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}.`) if (p.max !== undefined && n > p.max) throw new HttpError(400, 'invalid_params', `${p.label} must be at most ${p.max}.`)

View File

@ -76,6 +76,12 @@ const pick: Script = async (ctx) => {
} }
ctx.progress(2, 2, 'Assigning', gameName(Number(chosen))) ctx.progress(2, 2, 'Assigning', gameName(Number(chosen)))
await ctx.sleep(700) 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) world.currentAppId = Number(chosen)
return { summary: `Assigned ${gameName(Number(chosen))}.` } return { summary: `Assigned ${gameName(Number(chosen))}.` }
} }
@ -177,6 +183,7 @@ export const SCRIPTS: Partial<Record<CommandName, Script>> = {
'abandon-pick': async (ctx) => { 'abandon-pick': async (ctx) => {
await ctx.sleep(800) await ctx.sleep(800)
world.manualPicks = world.manualPicks.filter((p) => p.app_id !== Number(ctx.params.app_id)) 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))}.` } return { summary: `Abandoned ${gameName(Number(ctx.params.app_id))}.` }
}, },
'add-exception': async (ctx) => { 'add-exception': async (ctx) => {

View File

@ -35,6 +35,8 @@ export interface World {
storeBlockedUntil: number | null storeBlockedUntil: number | null
totalBlockUntil: number | null totalBlockUntil: number | null
manualPicks: { app_id: number; picked_at: number }[] manualPicks: { app_id: number; picked_at: number }[]
/** Games `pick` kept: allowed, no lock. */
keptPicks: number[]
installed: InstalledGame[] installed: InstalledGame[]
backups: StateBackup[] backups: StateBackup[]
journal: string[] journal: string[]
@ -57,6 +59,7 @@ export const world: World = {
storeBlockedUntil: null, storeBlockedUntil: null,
totalBlockUntil: null, totalBlockUntil: null,
manualPicks: [], manualPicks: [],
keptPicks: [],
installed: makeInstalled(1145360), installed: makeInstalled(1145360),
backups: [ backups: [
{ id: '20261009-221504', created_at: iso(now - DAY), reason: 'before reset', size_bytes: 48_211 }, { 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), name: gameName(p.app_id),
age_days: Math.floor((t - p.picked_at) / DAY), 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) : [], journal_tail: reachable ? world.journal.slice(-60) : [],
restart_available_at: restart_available_at:
world.restartAvailableAt !== null && world.restartAvailableAt > t ? iso(world.restartAvailableAt) : null, world.restartAvailableAt !== null && world.restartAvailableAt > t ? iso(world.restartAvailableAt) : null,
fast_installs: [],
} }
} }

302
web/package-lock.json generated
View File

@ -8,7 +8,7 @@
"name": "web", "name": "web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "5.104.4", "@tanstack/react-query": "5.104.5",
"@tanstack/react-router": "1.170.45", "@tanstack/react-router": "1.170.45",
"react": "19.3.1", "react": "19.3.1",
"react-dom": "19.3.1" "react-dom": "19.3.1"
@ -33,8 +33,8 @@
"globals": "17.13.0", "globals": "17.13.0",
"jsdom": "30.1.3", "jsdom": "30.1.3",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.71.2", "typescript-eslint": "8.72.0",
"vite": "8.3.4", "vite": "8.3.5",
"vitest": "5.0.5" "vitest": "5.0.5"
} }
}, },
@ -1186,9 +1186,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.153.0", "version": "0.155.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
"integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@ -1196,9 +1196,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm-eabi": { "node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
"integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1213,9 +1213,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
"integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1230,9 +1230,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
"integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1247,9 +1247,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
"integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1264,9 +1264,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
"integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1281,9 +1281,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
"integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1298,9 +1298,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
"integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1318,9 +1318,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
"integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1338,9 +1338,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
"integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1358,9 +1358,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
"integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -1378,9 +1378,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
"integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1398,9 +1398,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
"integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1418,9 +1418,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
"integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1435,9 +1435,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
"integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1452,9 +1452,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
"integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1520,9 +1520,9 @@
} }
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.104.4", "version": "5.104.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.4.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.5.tgz",
"integrity": "sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==", "integrity": "sha512-Q94gI8Opd+CbdVNOdj8ZCyV8WqLYitrDq1t96Xc8WZT6lY0r5iRnJLXYDxMex7EEf2v+mXvn4FcjaBKSxXZQ9A==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@ -1530,12 +1530,12 @@
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "5.104.4", "version": "5.104.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.4.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.5.tgz",
"integrity": "sha512-YD5QkvUNy8Ey3LbOy2vwL97JjoYycwDv++cWAibTe+9BLYvl3c09XpYp5dZ2VcxcguwoDhGF+utYJYb79RB36w==", "integrity": "sha512-RORgFe052U+93weaNuqg7qHq7vNVNzXN/VrXqD4bbAuQCWUqOl2lV5Ui9flWk5CVvMyL/Bh27QWhzwH0aaOx9g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.104.4" "@tanstack/query-core": "5.104.5"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@ -1810,17 +1810,17 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.71.2", "version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz",
"integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", "integrity": "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.71.2", "@typescript-eslint/scope-manager": "8.72.0",
"@typescript-eslint/type-utils": "8.71.2", "@typescript-eslint/type-utils": "8.72.0",
"@typescript-eslint/utils": "8.71.2", "@typescript-eslint/utils": "8.72.0",
"@typescript-eslint/visitor-keys": "8.71.2", "@typescript-eslint/visitor-keys": "8.72.0",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
@ -1833,15 +1833,15 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.71.2", "@typescript-eslint/parser": "^8.72.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.12", "version": "7.0.13",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.13.tgz",
"integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "integrity": "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1849,16 +1849,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.71.2", "version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.0.tgz",
"integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", "integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.71.2", "@typescript-eslint/scope-manager": "8.72.0",
"@typescript-eslint/types": "8.71.2", "@typescript-eslint/types": "8.72.0",
"@typescript-eslint/typescript-estree": "8.71.2", "@typescript-eslint/typescript-estree": "8.72.0",
"@typescript-eslint/visitor-keys": "8.71.2", "@typescript-eslint/visitor-keys": "8.72.0",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@ -1874,14 +1874,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.71.2", "version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz",
"integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.71.2", "@typescript-eslint/tsconfig-utils": "^8.72.0",
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.72.0",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@ -1896,14 +1896,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.71.2", "version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz",
"integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", "integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.71.2", "@typescript-eslint/types": "8.72.0",
"@typescript-eslint/visitor-keys": "8.71.2" "@typescript-eslint/visitor-keys": "8.72.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1914,9 +1914,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.71.2", "version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz",
"integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1931,15 +1931,15 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.71.2", "version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.0.tgz",
"integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", "integrity": "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.71.2", "@typescript-eslint/types": "8.72.0",
"@typescript-eslint/typescript-estree": "8.71.2", "@typescript-eslint/typescript-estree": "8.72.0",
"@typescript-eslint/utils": "8.71.2", "@typescript-eslint/utils": "8.72.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
}, },
@ -1956,9 +1956,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.71.2", "version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz",
"integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", "integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1970,16 +1970,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.71.2", "version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz",
"integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.71.2", "@typescript-eslint/project-service": "8.72.0",
"@typescript-eslint/tsconfig-utils": "8.71.2", "@typescript-eslint/tsconfig-utils": "8.72.0",
"@typescript-eslint/types": "8.71.2", "@typescript-eslint/types": "8.72.0",
"@typescript-eslint/visitor-keys": "8.71.2", "@typescript-eslint/visitor-keys": "8.72.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"minimatch": "^10.2.2", "minimatch": "^10.2.2",
"semver": "^7.7.3", "semver": "^7.7.3",
@ -2011,16 +2011,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.71.2", "version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz",
"integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", "integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.1", "@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.71.2", "@typescript-eslint/scope-manager": "8.72.0",
"@typescript-eslint/types": "8.71.2", "@typescript-eslint/types": "8.72.0",
"@typescript-eslint/typescript-estree": "8.71.2" "@typescript-eslint/typescript-estree": "8.72.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2035,13 +2035,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.71.2", "version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz",
"integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", "integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.71.2", "@typescript-eslint/types": "8.72.0",
"eslint-visitor-keys": "^5.0.0" "eslint-visitor-keys": "^5.0.0"
}, },
"engines": { "engines": {
@ -3892,9 +3892,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.29", "version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -3912,7 +3912,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.19", "nanoid": "^3.3.20",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.2" "source-map-js": "^1.2.2"
}, },
@ -4028,13 +4028,13 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
"integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.153.0", "@oxc-project/types": "=0.155.0",
"@rolldown/pluginutils": "^1.0.0" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
@ -4044,21 +4044,21 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.13", "@rolldown/binding-android-arm-eabi": "1.2.15",
"@rolldown/binding-android-arm64": "1.2.13", "@rolldown/binding-android-arm64": "1.2.15",
"@rolldown/binding-darwin-arm64": "1.2.13", "@rolldown/binding-darwin-arm64": "1.2.15",
"@rolldown/binding-darwin-x64": "1.2.13", "@rolldown/binding-darwin-x64": "1.2.15",
"@rolldown/binding-freebsd-x64": "1.2.13", "@rolldown/binding-freebsd-x64": "1.2.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.13", "@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
"@rolldown/binding-linux-arm64-gnu": "1.2.13", "@rolldown/binding-linux-arm64-gnu": "1.2.15",
"@rolldown/binding-linux-arm64-musl": "1.2.13", "@rolldown/binding-linux-arm64-musl": "1.2.15",
"@rolldown/binding-linux-ppc64-gnu": "1.2.13", "@rolldown/binding-linux-ppc64-gnu": "1.2.15",
"@rolldown/binding-linux-s390x-gnu": "1.2.13", "@rolldown/binding-linux-s390x-gnu": "1.2.15",
"@rolldown/binding-linux-x64-gnu": "1.2.13", "@rolldown/binding-linux-x64-gnu": "1.2.15",
"@rolldown/binding-linux-x64-musl": "1.2.13", "@rolldown/binding-linux-x64-musl": "1.2.15",
"@rolldown/binding-openharmony-arm64": "1.2.13", "@rolldown/binding-openharmony-arm64": "1.2.15",
"@rolldown/binding-win32-arm64-msvc": "1.2.13", "@rolldown/binding-win32-arm64-msvc": "1.2.15",
"@rolldown/binding-win32-x64-msvc": "1.2.13" "@rolldown/binding-win32-x64-msvc": "1.2.15"
} }
}, },
"node_modules/saxes": { "node_modules/saxes": {
@ -4298,16 +4298,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.71.2", "version": "8.72.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.0.tgz",
"integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", "integrity": "sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.71.2", "@typescript-eslint/eslint-plugin": "8.72.0",
"@typescript-eslint/parser": "8.71.2", "@typescript-eslint/parser": "8.72.0",
"@typescript-eslint/typescript-estree": "8.71.2", "@typescript-eslint/typescript-estree": "8.72.0",
"@typescript-eslint/utils": "8.71.2" "@typescript-eslint/utils": "8.72.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -4402,16 +4402,16 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.3.4", "version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.7", "picomatch": "^4.0.7",
"postcss": "^8.5.29", "postcss": "^8.5.30",
"rolldown": "~1.2.12", "rolldown": "~1.2.14",
"tinyglobby": "^0.2.17" "tinyglobby": "^0.2.17"
}, },
"bin": { "bin": {
@ -4428,7 +4428,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0", "@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", "esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0", "jiti": ">=1.21.0",
"less": "^4.0.0", "less": "^4.0.0",

View File

@ -14,7 +14,7 @@
"coverage": "vitest run --coverage" "coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "5.104.4", "@tanstack/react-query": "5.104.5",
"@tanstack/react-router": "1.170.45", "@tanstack/react-router": "1.170.45",
"react": "19.3.1", "react": "19.3.1",
"react-dom": "19.3.1" "react-dom": "19.3.1"
@ -39,8 +39,8 @@
"globals": "17.13.0", "globals": "17.13.0",
"jsdom": "30.1.3", "jsdom": "30.1.3",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.71.2", "typescript-eslint": "8.72.0",
"vite": "8.3.4", "vite": "8.3.5",
"vitest": "5.0.5" "vitest": "5.0.5"
} }
} }

View File

@ -47,7 +47,8 @@ export type CommandCategory = 'backlog' | 'library' | 'gaming' | 'store' | 'syst
export interface ParamSpec { export interface ParamSpec {
name: string name: string
label: string label: string
type: 'int' | 'string' | 'app_id' /** `bool` is a checkbox, sent as 0/1. */
type: 'int' | 'string' | 'app_id' | 'bool'
required: boolean required: boolean
min?: number min?: number
max?: number max?: number
@ -101,6 +102,13 @@ export interface StatusPayload {
total_block: { active: boolean; days_remaining: number; until: string | null } total_block: { active: boolean; days_remaining: number; until: string | null }
manual_pick_locked: boolean manual_pick_locked: boolean
manual_picks: ManualPickInfo[] 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 { export interface StatsPayload {
@ -156,6 +164,17 @@ export interface SetupRequest {
export type DaemonState = 'running' | 'restarting' | 'unreachable' 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 { export interface DaemonStatus {
state: DaemonState state: DaemonState
started_at: string | null started_at: string | null
@ -163,6 +182,11 @@ export interface DaemonStatus {
journal_tail: string[] journal_tail: string[]
/** ISO time before which `restart` is rate-limited, or null. */ /** ISO time before which `restart` is rate-limited, or null. */
restart_available_at: string | 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 { export interface ServerHealth {

View File

@ -56,4 +56,20 @@ describe('ParamsForm', () => {
render(<Harness disabled />) render(<Harness disabled />)
expect(screen.getByLabelText('Note')).toBeDisabled() 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<ParamValues>({ replace: '0' })
return <ParamsForm specs={box} values={values} onChange={setValues} games={[]} showErrors disabled={false} />
}
render(<BoxHarness />)
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()
})
}) })

View File

@ -18,6 +18,26 @@ export function ParamsForm({ specs, values, onChange, games, showErrors, disable
{specs.map((p, i) => { {specs.map((p, i) => {
const id = `${base}-${p.name}` const id = `${base}-${p.name}`
const raw = values[p.name] ?? '' const raw = values[p.name] ?? ''
if (p.type === 'bool') {
return (
<div className="form-field" key={p.name}>
<label className="check" htmlFor={id}>
<input
id={id}
type="checkbox"
checked={raw === '1'}
disabled={disabled}
aria-describedby={`${id}-help`}
onChange={(e) => onChange({ ...values, [p.name]: e.target.checked ? '1' : '0' })}
/>
{p.label}
</label>
<p id={`${id}-help`} className="field-help">
{p.help ?? ' '}
</p>
</div>
)
}
const err = paramError(p, raw, games) const err = paramError(p, raw, games)
const game = p.type === 'app_id' ? games.find((g) => g.app_id === Number(raw)) : undefined const game = p.type === 'app_id' ? games.find((g) => g.app_id === Number(raw)) : undefined
return ( return (

View File

@ -172,7 +172,7 @@ function Runner({ spec, fixed, preset, onClose, onArmed }: RunnerProps) {
</ErrorNotice> </ErrorNotice>
)} )}
<div className="actions"> <div className="actions">
<button type="submit" className={phrase ? 'btn btn-danger' : 'btn btn-primary'} disabled={!canSubmit || start.isPending} autoFocus={!phrase && spec.params.length === 0}> <button type="submit" className={phrase ? 'btn btn-danger' : 'btn btn-primary'} disabled={!canSubmit || start.isPending} autoFocus={!phrase && spec.params.every((p) => p.type === 'bool')}>
{start.isPending ? 'Starting…' : countdown ? 'Arm countdown' : `Run ${spec.name}`} {start.isPending ? 'Starting…' : countdown ? 'Arm countdown' : `Run ${spec.name}`}
</button> </button>
<button type="button" className="btn btn-ghost" onClick={onClose}> <button type="button" className="btn btn-ghost" onClick={onClose}>

View File

@ -14,6 +14,10 @@ describe('paramError', () => {
expect(paramError(spec({ required: false }), '', [])).toBeNull() expect(paramError(spec({ required: false }), '', [])).toBeNull()
}) })
it('never flags a checkbox', () => {
expect(paramError(spec({ type: 'bool', required: false, default: 0 }), '1', [])).toBeNull()
})
it('accepts any text for a string', () => { it('accepts any text for a string', () => {
expect(paramError(spec({ type: 'string' }), 'anything', [])).toBeNull() expect(paramError(spec({ type: 'string' }), 'anything', [])).toBeNull()
}) })
@ -52,6 +56,12 @@ describe('toParams', () => {
expect(toParams(specs, { a: '', b: ' hi ', c: '42' })).toEqual({ a: 7, b: 'hi', c: 42 }) expect(toParams(specs, { a: '', b: ' hi ', c: '42' })).toEqual({ a: 7, b: 'hi', c: 42 })
}) })
it('sends a checkbox as 0/1', () => {
const box = [spec({ name: 'r', type: 'bool', required: false, default: 0 })]
expect(toParams(box, { r: '1' })).toEqual({ r: 1 })
expect(toParams(box, {})).toEqual({ r: 0 })
})
it('keeps a typed int', () => { it('keeps a typed int', () => {
expect(toParams(specs, { a: '2', d: '9' })).toEqual({ a: 2, d: 9 }) expect(toParams(specs, { a: '2', d: '9' })).toEqual({ a: 2, d: 9 })
}) })

View File

@ -11,6 +11,8 @@ export interface GameOption {
/** Error text for one field, or null when the value is acceptable. */ /** Error text for one field, or null when the value is acceptable. */
export function paramError(p: ParamSpec, raw: string, games: GameOption[]): string | null { export function paramError(p: ParamSpec, raw: string, games: GameOption[]): string | null {
// A checkbox is always '0' or '1'; there is nothing to get wrong.
if (p.type === 'bool') return null
const v = raw.trim() const v = raw.trim()
if (v === '') return p.required && p.default === undefined ? `${p.label} is required.` : null if (v === '') return p.required && p.default === undefined ? `${p.label} is required.` : null
if (p.type === 'string') return null if (p.type === 'string') return null

View File

@ -5,8 +5,18 @@ import { makeJob, makeSpec, makeStatus } from '../test/fixtures'
import { apiError, renderRouted, stubApi } from '../test/harness' import { apiError, renderRouted, stubApi } from '../test/harness'
import { Dashboard } from './Dashboard' import { Dashboard } from './Dashboard'
const daemon = (fast_installs: unknown[] = []) => ({
state: 'running',
started_at: null,
pid: 1,
journal_tail: [],
restart_available_at: null,
fast_installs,
})
const bg3 = { app_id: 1086940, name: "Baldur's Gate 3", phase: 'downloading depot 3/8', percent: 46.9, elapsed_seconds: 312 }
const base = (over: Record<string, unknown> = {}) => ({ const base = (over: Record<string, unknown> = {}) => ({
'GET /api/status': makeStatus(), 'GET /api/status': makeStatus(),
'GET /api/daemon': daemon(),
'GET /api/budget': makeBudget(), 'GET /api/budget': makeBudget(),
'GET /api/jobs': [], 'GET /api/jobs': [],
'GET /api/commands': [makeSpec('done'), makeSpec('install'), makeSpec('pick'), makeSpec('scan'), makeSpec('check')], 'GET /api/commands': [makeSpec('done'), makeSpec('install'), makeSpec('pick'), makeSpec('scan'), makeSpec('check')],
@ -67,6 +77,27 @@ describe('Dashboard', () => {
}) })
}) })
describe('fast install', () => {
it('shows a daemon-started run with its depot percent', async () => {
show({ 'GET /api/daemon': daemon([bg3]) })
expect(await screen.findByText("Fast-installing Baldur's Gate 3 — 46% (downloading depot 3/8)")).toBeInTheDocument()
expect(screen.getByRole('progressbar', { name: "Baldur's Gate 3 download" })).toHaveAttribute('aria-valuenow', '46')
expect(screen.getByText('5m elapsed')).toBeInTheDocument()
})
it('shows the phase alone before a percent is known', async () => {
show({ 'GET /api/daemon': daemon([{ ...bg3, phase: 'shutting Steam down', percent: null, elapsed_seconds: 12 }]) })
expect(await screen.findByText("Fast-installing Baldur's Gate 3 — shutting Steam down")).toBeInTheDocument()
expect(screen.getByRole('progressbar', { name: "Baldur's Gate 3 download" })).toHaveClass('bar-indeterminate')
})
it('stays out of the way when nothing is installing', async () => {
show()
expect(await screen.findByText('Hollow Knight')).toBeInTheDocument()
expect(screen.queryByText(/Fast-installing/)).not.toBeInTheDocument()
})
})
describe('gaming today', () => { describe('gaming today', () => {
it('shows time left and what is billing', async () => { it('shows time left and what is billing', async () => {
show() show()

View File

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import type { StatusPayload } from '../api/contract' import type { FastInstallRun, StatusPayload } from '../api/contract'
import { q } from '../api/queries' import { q } from '../api/queries'
import { CommandButton } from '../commands/CommandCard' import { CommandButton } from '../commands/CommandCard'
import { commandLabel } from '../commands/catalog' import { commandLabel } from '../commands/catalog'
@ -34,6 +34,29 @@ function StatusCard({ s }: { s: StatusPayload }) {
) )
} }
function fastInstallLine(r: FastInstallRun): string {
const where = r.percent === null ? r.phase : `${Math.floor(r.percent)}% (${r.phase})`
return `Fast-installing ${r.name} — ${where}`
}
/** Daemon-started fast installs; a web job shows its own in the job view. */
function FastInstalls() {
const { data } = useQuery(q.daemon)
const runs = data?.fast_installs ?? []
if (!runs.length) return null
return (
<Card title="Fast install">
{runs.map((r) => (
<div key={r.app_id}>
<p>{fastInstallLine(r)}</p>
<ProgressBar fraction={r.percent === null ? null : Math.floor(r.percent) / 100} label={`${r.name} download`} />
<p className="field-help">{fmtDuration(r.elapsed_seconds)} elapsed</p>
</div>
))}
</Card>
)
}
function BudgetGlance() { function BudgetGlance() {
const { data, error } = useQuery(q.budget) const { data, error } = useQuery(q.budget)
if (error) return <Card title="Gaming today"><ErrorNotice error={error} /></Card> if (error) return <Card title="Gaming today"><ErrorNotice error={error} /></Card>
@ -94,6 +117,7 @@ export function Dashboard() {
{error ? <Card title="Assignment"><ErrorNotice error={error} /></Card> : data ? <StatusCard s={data} /> : <Card title="Assignment"><Loading what="status" /></Card>} {error ? <Card title="Assignment"><ErrorNotice error={error} /></Card> : data ? <StatusCard s={data} /> : <Card title="Assignment"><Loading what="status" /></Card>}
<BudgetGlance /> <BudgetGlance />
</div> </div>
<FastInstalls />
<RecentJobs /> <RecentJobs />
</> </>
) )

View File

@ -7,6 +7,7 @@ import { Actions, Card, PageHead } from '../ui/Page'
/** Everything that changes which game is assigned. */ /** Everything that changes which game is assigned. */
export function Picks() { export function Picks() {
const { data, error } = useQuery(q.status) const { data, error } = useQuery(q.status)
const none = data !== undefined && data.manual_picks.length === 0 && data.kept_picks.length === 0
return ( return (
<> <>
<PageHead <PageHead
@ -23,7 +24,7 @@ export function Picks() {
<Card title="Manual picks"> <Card title="Manual picks">
{error && <ErrorNotice error={error} />} {error && <ErrorNotice error={error} />}
{!data && !error && <Loading what="status" />} {!data && !error && <Loading what="status" />}
{data && data.manual_picks.length === 0 && ( {none && (
<Empty>No manual picks. "Lock in a game" adds one and locks most commands for 14 days.</Empty> <Empty>No manual picks. "Lock in a game" adds one and locks most commands for 14 days.</Empty>
)} )}
<ul className="rows"> <ul className="rows">
@ -35,8 +36,17 @@ export function Picks() {
<CommandButton name="abandon-pick" preset={{ app_id: p.app_id }} label="Abandon…" tone="danger" /> <CommandButton name="abandon-pick" preset={{ app_id: p.app_id }} label="Abandon…" tone="danger" />
</li> </li>
))} ))}
{/* Released, expired or kept by "Pick…": still installed and playable. */}
{data?.kept_picks.map((p) => (
<li key={p.app_id} className="row">
<span className="grow">
<strong>{p.name}</strong> <span className="muted">· kept · no lock · {p.app_id}</span>
</span>
<CommandButton name="abandon-pick" preset={{ app_id: p.app_id }} label="Abandon…" tone="danger" />
</li>
))}
</ul> </ul>
{data && data.manual_picks.length === 0 && ( {none && (
<div className="actions"> <div className="actions">
<CommandButton name="abandon-pick" label="Abandon a pick by app id…" /> <CommandButton name="abandon-pick" label="Abandon a pick by app id…" />
</div> </div>

View File

@ -37,6 +37,21 @@ describe('Picks', () => {
expect(screen.getByText('Hollow Knight', { selector: 'strong' })).toBeInTheDocument() 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(<Picks />)
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 () => { it('invites locking in a game when there are none', async () => {
stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': makeStatus({ current_game_name: null }) }) stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': makeStatus({ current_game_name: null }) })
renderRouted(<Picks />) renderRouted(<Picks />)

View File

@ -57,6 +57,7 @@ export function makeStatus(over: Partial<StatusPayload> = {}): StatusPayload {
total_block: { active: false, days_remaining: 0, until: null }, total_block: { active: false, days_remaining: 0, until: null },
manual_pick_locked: false, manual_pick_locked: false,
manual_picks: [], manual_picks: [],
kept_picks: [],
...over, ...over,
} }
} }