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
with:
node-version: 24.22.0
node-version: 24.22.1
cache: npm
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/setup` | – | `SetupStatus` (configured flags, no secrets) |
| POST | `/api/setup` | `SetupRequest` | `SetupStatus` (validated live against Steam) |
| GET | `/api/daemon` | – | `DaemonStatus` (state, uptime, journal tail) |
| GET | `/api/daemon` | – | `DaemonStatus` (state, uptime, journal tail, fast installs) |
| GET | `/api/server` | – | `ServerHealth` (stale?, started_at, version) |
| POST | `/api/server/restart` | – | `202`; the user unit restarts the process |
| GET | `/api/backups` | – | `StateBackup[]` |
@ -205,6 +205,9 @@ hard-codes it.
`gaming_unblock{phrase}`, `gaming_reset_arm{phrase}`,
`gaming_reset_heartbeat{pending_id}`, `gaming_reset_commit{pending_id, phrase}`,
`gaming_reset_cancel{pending_id}`, `restart{}`. There is no `stop`.
- `status` also lists the daemon's live steam-game-installer runs as
`fast_installs: [{app_id, name, phase, percent, elapsed_seconds}]`, read
from its log by the enforce loop; `percent` is the current depot's, or null.
## Verification record (2026-10-10)

View File

@ -15,9 +15,12 @@ from typing import TYPE_CHECKING, Any
from steam_backlog_enforcer._allowed_games import (
active_manual_picks,
allowed_games,
drop_inactive_picks,
)
from steam_backlog_enforcer._assignment_progress import record_assignment
from steam_backlog_enforcer._pick_keep import (
allowed_cap_refusal,
keep_outgoing_assignment,
)
# Marks these two as an intentional re-export (they're imported from
# _allowed_games above, not defined here) -- without this, mypy's
@ -120,18 +123,23 @@ def apply_manual_pick(
its interactive ``YES`` confirmation. Keeping this state-only means an
automated caller (the MCP server) can never wipe installed games.
Picking is an explicit choice of another game, so released, finished and
expired picks are dropped here (and enforcement may then remove them).
The games already allowed are kept, like plain ``pick``: released,
finished and expired picks stay, and the outgoing assignment is parked as
a released pick. So the pick is refused when it would push the allowed
set over ``max_picks + 1`` (:func:`allowed_cap_refusal`). Re-picking a
kept game turns its entry back into an active pick instead of adding one.
Args:
state: The enforcer state to mutate and save.
app_id: The Steam app id to lock in.
game_name: Human-readable name for the picked game.
max_picks: How many picks may be active at once.
max_picks: How many picks may be active at once; the allowed set
may hold one more game (the assignment).
Returns:
``None`` on success, or a message explaining why the pick was refused
(already picked, or no slots left), in which case nothing was saved.
(already picked, no active slot left, or the allowed set is full), in
which case nothing was changed.
"""
active = active_manual_picks(state)
@ -145,8 +153,15 @@ def apply_manual_pick(
f"(cap is {max_picks}): {names}."
)
refused = allowed_cap_refusal(state, max_picks, adding=app_id)
if refused is not None:
return refused
now = datetime.now(UTC).isoformat()
drop_inactive_picks(state)
# A kept entry for this game is replaced by the active one, not doubled.
state.manual_picks = [p for p in state.manual_picks if p.get("app_id") != app_id]
if state.current_app_id != app_id:
keep_outgoing_assignment(state)
state.manual_picks.append(
{"app_id": app_id, "game_name": game_name, "started_at": now}
)

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

View File

@ -27,7 +27,7 @@ COMMAND_DESCRIPTIONS: Final[dict[str, str]] = {
"uninstall": "Uninstall all non-assigned games",
"setup": "Run first-time setup",
"done": "Move on after a new achievement, pick next",
"pick": "Manually pick your next game from candidates",
"pick": "Pick another game from candidates (keeps current; --replace swaps)",
"stats": "Show backlog completion-time estimates",
"gaming-status": "Show today's gaming time and block state",
"gaming-reset": "Reset today's gaming counter (root + phrase)",

View File

@ -19,7 +19,7 @@ from steam_backlog_enforcer._store_window import (
if TYPE_CHECKING:
from collections.abc import Mapping
ParamType = Literal["int", "string", "app_id"]
ParamType = Literal["int", "string", "app_id", "bool"]
ParamValue = int | str
@ -54,6 +54,17 @@ class ParamSpec:
_APP_ID = ParamSpec("app_id", "Steam app id", "app_id", min=1)
PARAMS: Final[dict[str, tuple[ParamSpec, ...]]] = {
# Unchecked keeps the games already allowed; checked is `pick --replace`.
"pick": (
ParamSpec(
"replace",
"Replace current games (uninstalls them)",
"bool",
required=False,
default=0,
help="Leave unchecked to keep playing them alongside the new pick.",
),
),
"pick-manual": (_APP_ID,),
"abandon-pick": (_APP_ID,),
"add-exception": (
@ -106,6 +117,13 @@ def _coerce(spec: ParamSpec, raw: object) -> ParamValue:
msg = f"{spec.name} must be a non-empty string"
raise InvalidParamsError(msg)
return raw.strip()
if spec.type == "bool":
# A checkbox; carried as 0/1 so ParamValue stays int | str.
# True/False are ints too, so this accepts true, false, 0 and 1.
if isinstance(raw, int) and raw in (0, 1):
return int(raw)
msg = f"{spec.name} must be true or false"
raise InvalidParamsError(msg)
# bool is an int subclass in Python; true/false is never a count.
if isinstance(raw, bool) or not isinstance(raw, (int, str)):
msg = f"{spec.name} must be a whole number"

View File

@ -26,6 +26,7 @@ from steam_backlog_enforcer._ctl_protocol import (
from steam_backlog_enforcer._ctl_types import (
BlockResult,
DaemonInfo,
FastInstallInfo,
PendingReset,
ResetResult,
StoreWindow,
@ -130,15 +131,29 @@ def ping() -> None:
call("ping")
def _fast_install_info(run: dict[str, object]) -> FastInstallInfo:
"""One ``status.fast_installs`` entry, typed."""
percent = run.get("percent")
return FastInstallInfo(
app_id=int(cast("int", run["app_id"])),
name=str(run["name"]),
phase=str(run["phase"]),
percent=None if percent is None else float(cast("float", percent)),
elapsed_seconds=int(cast("int", run["elapsed_seconds"])),
)
def status() -> DaemonInfo:
"""The daemon's state, start time, pid and next-restart time."""
"""The daemon's state, start time, pid, next-restart time and fast installs."""
data = call("status")
until = data.get("restart_available_at")
runs = cast("list[dict[str, object]]", data.get("fast_installs") or [])
return DaemonInfo(
state=str(data["state"]),
started_at=str(data["started_at"]),
pid=int(cast("int", data["pid"])),
restart_available_at=str(until) if until else None,
fast_installs=tuple(map(_fast_install_info, runs)),
)

View File

@ -13,6 +13,7 @@ import contextlib
import logging
import os
import subprocess
import time
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._ctl_context import (
@ -24,6 +25,7 @@ from steam_backlog_enforcer._ctl_context import (
from steam_backlog_enforcer._ctl_protocol import OP_FAILED, CtlError
from steam_backlog_enforcer._ctl_reset import RESET_OPS
from steam_backlog_enforcer._ctl_restart import RESTART_OPS, restart_available_at
from steam_backlog_enforcer._fast_install_progress import fast_install_status
from steam_backlog_enforcer._playtime_block import release_block
from steam_backlog_enforcer._store_window import MAX_WINDOW_MINUTES, open_store_window
from steam_backlog_enforcer._total_block import (
@ -56,12 +58,19 @@ def op_ping(_ctx: CtlContext, _args: dict[str, object]) -> dict[str, object]:
def op_status(ctx: CtlContext, _args: dict[str, object]) -> dict[str, object]:
"""The daemon's state, start time, pid and when a restart is next allowed."""
"""The daemon's state, start time, pid, next restart and fast installs.
``fast_installs`` are the steam-game-installer runs the daemon started,
as of the enforce loop's last read of their log: this thread never reads
the file. It rides on ``status`` rather than a file of its own because the
web server runs as the desktop user and a root-owned file is no use to it.
"""
return {
"state": "restarting" if ctx.restart_event.is_set() else "running",
"started_at": ctx.started_at.isoformat(),
"pid": os.getpid(),
"restart_available_at": restart_available_at(),
"fast_installs": fast_install_status(time.monotonic()),
}

View File

@ -9,6 +9,21 @@ from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class FastInstallInfo:
"""A steam-game-installer run the daemon started, as far as its log tells.
``percent`` is the current depot's (``None`` until one is known); ``phase``
names the depot, e.g. ``downloading depot 3/8``.
"""
app_id: int
name: str
phase: str
percent: float | None
elapsed_seconds: int
@dataclass(frozen=True)
class DaemonInfo:
"""The daemon's own account of itself (``status``)."""
@ -17,6 +32,8 @@ class DaemonInfo:
started_at: str
pid: int
restart_available_at: str | None
# Empty from a daemon too old to report them.
fast_installs: tuple[FastInstallInfo, ...] = ()
@dataclass(frozen=True)

View File

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

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
else None
)
active = active_manual_picks(state)
return {
"current_app_id": state.current_app_id,
"current_game_name": state.current_game_name or None,
@ -153,6 +154,9 @@ def status_payload(state: State) -> dict[str, Any]:
**pick,
"age_days": manual_pick_age_days(state, pick["app_id"]),
}
for pick in active_manual_picks(state)
for pick in active
],
# Released, expired or parked by 'pick': still allowed and installed,
# but holding no lock; 'abandon-pick' removes them.
"kept_picks": [{**p} for p in state.manual_picks if p not in active],
}

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
returns a preview of what confirming would do. Call again with
``confirm=True`` to apply. Applying overrides the current assignment and
``confirm=True`` to apply. Applying makes it the current assignment
(the games already allowed stay, up to the cap) and
locks all other commands for ``MANUAL_LOCK_DAYS`` days (or until one new
achievement is earned in it). Unlike the CLI's ``pick-manual``, this
mutates **state only** — it never uninstalls, installs, or hides games.
@ -61,7 +62,8 @@ def pick_manual(app_id: int, *, confirm: bool = False) -> dict[str, Any]:
"app_id": app_id,
"game_name": game_name,
"effect": (
"Overrides the current assignment and locks all other commands "
"Makes it the current assignment (the games already allowed "
"stay, up to the cap) and locks all other commands "
f"for {MANUAL_LOCK_DAYS} days or until you earn one new "
"achievement in it."
),

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,
prompt_own_pick,
)
from steam_backlog_enforcer._pick_keep import keep_outgoing_assignment
from steam_backlog_enforcer._prompter import PromptOption, current_prompter
from steam_backlog_enforcer._scanning_candidates import (
_pick_next_shortest_candidate,
@ -80,13 +81,24 @@ def _assign_chosen_game(
games: list[GameInfo],
state: State,
config: Config,
*,
keep: bool = False,
) -> None:
"""Save assignment, announce it, and handle install/uninstall.
Accepting a new game is the explicit choice that lets enforcement remove
released picks, so they are dropped here.
Replacing (the default, and what ``done``/``check`` do) is the explicit
choice that lets enforcement remove released picks, so they are dropped
here. With ``keep`` (plain ``pick``) nothing is dropped and the outgoing
assignment is parked as a released pick, so it stays allowed and the
uninstall below spares it.
"""
drop_inactive_picks(state)
if keep:
if chosen.app_id in allowed_app_ids(state):
_echo(f"\n{chosen.name} (AppID={chosen.app_id}) is already allowed.")
return
keep_outgoing_assignment(state)
else:
drop_inactive_picks(state)
record_assignment(state, chosen.app_id, chosen.name)
state.save()
hours_str = (

View File

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

View File

@ -44,6 +44,10 @@ def status_view(_request: Request) -> Reply:
payload = status_payload(State.load())
payload["assigned_game_installed"] = bool(payload["assigned_game_installed"])
payload["manual_picks"] = [_manual_pick(p) for p in payload["manual_picks"]]
payload["kept_picks"] = [
{"app_id": p["app_id"], "name": p.get("game_name") or str(p["app_id"])}
for p in payload.get("kept_picks", [])
]
return ok(payload)

View File

@ -79,11 +79,21 @@ past its own 14-day deadline.
so freeing a slot cannot hand you an unrequested extra game.
- Releasing never evicts. A released, finished or expired pick stays in
`State.manual_picks`, and therefore in `allowed_app_ids`, installed and
playable for as long as the user wants. Only an explicit choice of another
game — a new `pick-manual`, or accepting the next assignment in
`done`/`check`/`pick`/`scan` — calls `drop_inactive_picks`, after which
playable for as long as the user wants. Only an explicit replacement —
`pick --replace`, or accepting the next assignment in
`done`/`check`/`scan` — calls `drop_inactive_picks`, after which
enforcement may remove them. `abandon-pick` works on them too. When no
replacement is accepted, the current game stays assigned.
- Plain `pick` and `pick-manual` keep them (changed 2026-10-23): the
outgoing assignment is parked as a released pick too
(`_pick_keep.keep_outgoing_assignment`), so both commands only ever add a
game. Both share one cap, `_pick_keep.allowed_cap_refusal`: at most
`max_manual_picks + 1` games allowed, refused (naming every game and its
`abandon-pick <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
and `done`/`check` cannot both record the same completion.

View File

@ -95,6 +95,15 @@ def _add_exception(
return {"app_id": params["app_id"]}
def _pick(
params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter
) -> JobResult:
"""``pick [--replace]``: the web checkbox becomes the CLI flag."""
state = State.load()
cmd_pick(Config.load(), state, ["--replace"] if params.get("replace") else [])
return _assignment(state)
def _enforce_demo(
params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter
) -> JobResult:
@ -145,7 +154,7 @@ HANDLERS: dict[str, JobHandler] = {
"check": _cli(do_check),
"scan": _cli(do_scan),
"done": _cli(cmd_done),
"pick": _cli(cmd_pick),
"pick": _pick,
"pick-manual": _with_args(cmd_pick_manual, "app_id"),
"abandon-pick": _with_args(cmd_abandon_pick, "app_id"),
"install": _cli(cmd_install),

View File

@ -187,6 +187,9 @@ def _dispatch_extra_command(command: str, config: Config, state: State) -> bool:
"unblock": lambda: cmd_unblock(config, state, args),
"block-gaming": lambda: cmd_block_gaming(args),
"serve": lambda: cmd_serve(args),
# Also in COMMANDS (help order, direct callers), but only here does
# it see argv, so --replace is honoured.
"pick": lambda: cmd_pick(config, state, args),
"pick-manual": lambda: cmd_pick_manual(config, state, args),
"abandon-pick": lambda: cmd_abandon_pick(config, state, args),
}

View File

@ -3,14 +3,13 @@
from __future__ import annotations
import sys
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from steam_backlog_enforcer._actions import (
ABANDON_COOLDOWN_DAYS as _ABANDON_COOLDOWN_DAYS,
)
from steam_backlog_enforcer._actions import (
active_manual_picks,
find_manual_pick,
)
from steam_backlog_enforcer._manual_pick_lifecycle import abandon_manual_pick
from steam_backlog_enforcer._prompter import confirm_phrase
@ -54,21 +53,28 @@ def _abandon_pick_target(state: State, args: list[str]) -> int | None:
_echo(f"Error: app_id must be a number, got '{args[0]}'.")
return None
picks = active_manual_picks(state)
# Every stored pick, not only active ones: a released pick, or a game
# 'pick' kept, is still installed and is what 'pick' points users at.
picks = state.manual_picks
if not picks:
_echo("No manual pick is active — nothing to abandon.")
_echo("No manual pick or kept game — nothing to abandon.")
return None
if find_manual_pick(state, app_id) is None:
if _stored_pick(state, app_id) is None:
listed = ", ".join(f"{p['game_name']} (AppID={p['app_id']})" for p in picks)
_echo(
f"Error: AppID={app_id} is not one of your manual picks.\nActive: {listed}."
f"Error: AppID={app_id} is not one of your manual picks.\nYours: {listed}."
)
return None
return app_id
def _stored_pick(state: State, app_id: int) -> dict[str, Any] | None:
"""Return the stored pick for *app_id*, active, released or kept."""
return next((p for p in state.manual_picks if p.get("app_id") == app_id), None)
def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None:
"""Back out of a manual pick, whatever its age.
@ -81,7 +87,7 @@ def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None:
if app_id is None:
sys.exit(1)
pick = find_manual_pick(state, app_id)
pick = _stored_pick(state, app_id)
game_name = str(pick["game_name"]) if pick else ""
others = [p for p in active_manual_picks(state) if p["app_id"] != app_id]
@ -96,6 +102,9 @@ def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None:
if others:
kept = ", ".join(f"{p['game_name']} (AppID={p['app_id']})" for p in others)
_echo(f"\n Your other pick(s) stay locked in: {kept}")
elif state.current_app_id not in (None, app_id):
# A kept game is not the assignment, so abandoning it leaves one.
_echo(f"\n - {state.current_game_name} stays assigned")
else:
_echo("\n - Leaves you with no assigned game (run 'scan' to get one)")
_echo()

View File

@ -20,6 +20,11 @@ from steam_backlog_enforcer._pick_completion import (
report_completion,
warn_stale_assignment,
)
from steam_backlog_enforcer._pick_keep import (
allowed_cap_refusal,
keep_refusal,
kept_alongside,
)
from steam_backlog_enforcer._progress import tracked
from steam_backlog_enforcer._prompter import confirm_phrase, current_prompter
from steam_backlog_enforcer._snapshot import load_snapshot, snapshot_game_name
@ -42,8 +47,28 @@ if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config, State
def cmd_pick(config: Config, state: State) -> None:
"""Manually pick a new game from the shortest-first candidate list."""
def cmd_pick(config: Config, state: State, args: list[str] | None = None) -> None:
"""Pick another game from the shortest-first candidate list.
The games already allowed stay installed and playable next to the new
one, up to the cap (refused before the list is shown). ``--replace``
swaps them all for the new game and uninstalls them instead.
Args:
config: Enforcer configuration.
state: Current enforcer state.
args: Remaining CLI args: nothing, or ``--replace``.
"""
unknown = [a for a in args or [] if a != "--replace"]
if unknown:
_echo(f"Unknown argument(s): {' '.join(unknown)}\nUsage: pick [--replace]")
sys.exit(1)
keep = "--replace" not in (args or [])
refusal = keep_refusal(config, state) if keep else None
if refusal is not None:
_echo(f"Error: {refusal}")
sys.exit(1)
snapshot_data = load_snapshot()
if not snapshot_data:
_echo("No snapshot found. Run 'scan' first.")
@ -55,7 +80,7 @@ def cmd_pick(config: Config, state: State) -> None:
if game.app_id in hltb_cache:
game.completionist_hours = hltb_cache[game.app_id]
pick_next_game(games, state, config)
pick_next_game(games, state, config, keep=keep)
if state.current_app_id is not None:
hide_others_and_report(config, state)
@ -84,12 +109,15 @@ def _resolve_game_name(config: Config, app_id: int) -> str | None:
return None
def _report_pick_slots(config: Config, state: State) -> list[dict[str, object]]:
"""Show which manual picks are already locked in; exit if the cap is full.
def _report_pick_slots(
config: Config, state: State, app_id: int
) -> list[dict[str, object]]:
"""Show which manual picks are already locked in; exit if a cap is full.
Args:
config: Enforcer configuration (for ``max_manual_picks``).
state: Current enforcer state.
app_id: The game about to be picked (an allowed one adds nothing).
Returns:
The currently-active manual picks.
@ -107,7 +135,10 @@ def _report_pick_slots(config: Config, state: State) -> list[dict[str, object]]:
f"\ncan be abandoned at any time."
)
sys.exit(1)
refusal = allowed_cap_refusal(state, config.max_manual_picks, adding=app_id)
if refusal is not None:
_echo(f"\nError: {refusal}")
sys.exit(1)
return existing
@ -146,6 +177,7 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None:
"""Manually pick a game by Steam app_id, locking the enforcer.
The lock lasts 2 weeks, or until one new achievement is earned in it.
Already-allowed games stay installed, up to the cap (checked pre-prompt).
Args:
config: Enforcer configuration.
@ -173,7 +205,7 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None:
retired = report_completion(config, state)
existing = _report_pick_slots(config, state)
existing = _report_pick_slots(config, state, app_id)
_echo(
f"\nWARNING: Picking this game will:"
@ -185,12 +217,9 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None:
f"\n {', '.join(sorted(_MANUAL_LOCK_EXEMPT_COMMANDS))}"
f"\n - Stay undoable at any time via 'abandon-pick {app_id}'"
)
active = active_manual_picks(state)
for old_pick in (p for p in state.manual_picks if p not in active):
_echo(
f" - Replace {old_pick['game_name']} (released or expired; it stays"
" playable until you confirm)"
)
kept = kept_alongside(state, app_id)
if kept:
_echo(f" - Keep these allowed, they stay installed: {', '.join(kept)}")
_echo()
if not confirm_phrase(
"pick-manual",

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("\n[MANUAL PICK LOCK is active — most commands are blocked]")
# Released/expired picks and games 'pick' kept: allowed, but no lock.
kept = [p for p in state.manual_picks if p not in picks]
if kept:
_echo(f"\nKept games, no lock ({len(kept)}):")
for pick in kept:
_echo(
f" {pick.get('game_name', '')} (AppID={pick['app_id']})"
f" — 'abandon-pick {pick['app_id']}' removes it"
)
def cmd_list(_config: Config, state: State) -> None:
"""List games from the last snapshot."""

View File

@ -6,6 +6,7 @@ import logging
import time
from typing import TYPE_CHECKING
from steam_backlog_enforcer._actions import allowed_app_ids
from steam_backlog_enforcer._assignment_progress import (
release_game,
release_verdict,
@ -153,6 +154,7 @@ def pick_next_game(
config: Config,
*,
on_select: Callable[[GameInfo], bool | GameInfo] | None = None,
keep: bool = False,
) -> None:
"""Present a ranked list of eligible games and let the user pick one.
@ -165,12 +167,20 @@ def pick_next_game(
bypassed: the function instead presents the shortest playable
candidate to ``on_select`` (typically a yes/no prompt) and, if the
callback rejects it, records a 7-day skip and re-evaluates.
``keep`` (plain ``pick``) adds the chosen game next to the ones already
allowed instead of replacing them, so those are not offered again. The
sequential path (``done``/``check``) always replaces.
"""
if on_select is not None:
_pick_next_game_sequential(games, state, config, on_select)
return
candidates = _open_candidates(games, state)
# Offering an allowed game in keep mode would be a no-op "pick"; the
# ranked list and the own-game search both draw from this pool.
allowed = allowed_app_ids(state) if keep else set()
pool = [g for g in games if g.app_id not in allowed]
candidates = _open_candidates(pool, state)
if not candidates:
_clear_assignment(state, _NO_CONF_MSG)
return
@ -182,8 +192,8 @@ def pick_next_game(
_clear_assignment(state, _no_pick_message(confidence_skipped, linux_skipped))
return
chosen = _prompt_user_pick(qualified, games, state)
_assign_chosen_game(chosen, games, state, config)
chosen = _prompt_user_pick(qualified, pool, state)
_assign_chosen_game(chosen, games, state, config, keep=keep)
# ──────────────────────────────────────────────────────────────

View File

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

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 "already one of your manual picks" in refused
def test_prunes_finished_entries(self) -> None:
"""Test prunes finished entries."""
def test_keeps_finished_entries(self) -> None:
"""A finished pick stays allowed: a new pick no longer drops it."""
state = State(manual_picks=[_pick(440, "TF2")], finished_app_ids=[440])
assert apply_manual_pick(state, 620, "Portal 2", max_picks=2) is None
assert [p["app_id"] for p in state.manual_picks] == [620]
assert [p["app_id"] for p in state.manual_picks] == [440, 620]
def test_slots_left(self) -> None:
"""Test slots left."""

View File

@ -22,6 +22,7 @@ from steam_backlog_enforcer._ctl_protocol import (
from steam_backlog_enforcer._ctl_types import (
BlockResult,
DaemonInfo,
FastInstallInfo,
PendingReset,
ResetResult,
StoreWindow,
@ -125,7 +126,24 @@ class TestTypedWrappers:
def test_status_no_restart_time(self) -> None:
data = {"state": "running", "started_at": "t", "pid": 4}
with self._call(data):
assert _ctl_client.status().restart_available_at is None
info = _ctl_client.status()
assert info.restart_available_at is None
assert info.fast_installs == () # an older daemon sends none
def test_status_fast_installs(self) -> None:
run = {
"app_id": 7,
"name": "G",
"phase": "downloading depot 1/8",
"percent": 46.9,
"elapsed_seconds": 60,
}
data = {"state": "running", "started_at": "t", "pid": 4, "fast_installs": [run]}
with self._call(data):
info = _ctl_client.status()
assert info.fast_installs == (
FastInstallInfo(7, "G", "downloading depot 1/8", 46.9, 60),
)
def test_journal_tail(self) -> None:
with self._call({"lines": ["a", 2]}) as call:

View File

@ -13,7 +13,7 @@ from unittest.mock import MagicMock, patch
import pytest
from steam_backlog_enforcer import _ctl_ops
from steam_backlog_enforcer import _ctl_ops, _fast_install_progress
from steam_backlog_enforcer._ctl_protocol import CtlError
from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx
@ -35,6 +35,24 @@ class TestSimpleOps:
assert data["restart_available_at"] is None
assert isinstance(data["pid"], int)
def test_status_reports_fast_installs(self, tmp_path: Path) -> None:
log = tmp_path / "log"
log.write_bytes(b"Shutting down the Steam client...\r\n")
_fast_install_progress.track(7, "G", log, offset=0, now=0.0)
_fast_install_progress.poll_trackers({7})
with patch.object(_ctl_ops, "restart_available_at", return_value=None):
data = _ctl_ops.op_status(make_ctx(), {})
runs = data["fast_installs"]
assert isinstance(runs, list)
(run,) = runs
assert run["name"] == "G"
assert run["phase"] == "shutting Steam down"
assert run["percent"] is None
def test_status_without_fast_installs(self) -> None:
with patch.object(_ctl_ops, "restart_available_at", return_value=None):
assert _ctl_ops.op_status(make_ctx(), {})["fast_installs"] == []
def test_status_restarting(self) -> None:
ctx = make_ctx()
ctx.restart_event.set()

View File

@ -12,7 +12,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer import _fast_install, config
from steam_backlog_enforcer import _fast_install, _fast_install_progress, config
from steam_backlog_enforcer._progress import use_progress
if TYPE_CHECKING:
from pathlib import Path
@ -26,15 +27,21 @@ def _ready(tmp_path: Path) -> Path:
return installer
def _fake_spawn(*returncodes: int | None) -> MagicMock:
"""A ``_spawn_detached`` that registers a run answering *returncodes*."""
def _fake_spawn(*returncodes: int | None, log_text: bytes | None = None) -> MagicMock:
"""A ``_spawn_detached`` that registers a run answering *returncodes*.
def spawn(_cmd: list[str], app_id: int) -> None:
With *log_text*, the run also gets a log holding it, tracked from byte 0.
"""
def spawn(_cmd: list[str], app_id: int, label: str = "") -> None:
proc = MagicMock(pid=4321)
proc.poll.side_effect = list(returncodes)
_fast_install._RUNS.append(
_fast_install._DetachedRun(app_id, proc, config.CONFIG_DIR / "x.log")
)
log = config.CONFIG_DIR / "x.log"
if log_text is not None:
log.parent.mkdir(parents=True, exist_ok=True)
log.write_bytes(log_text)
_fast_install_progress.track(app_id, label, log, offset=0, now=0.0)
_fast_install._RUNS.append(_fast_install._DetachedRun(app_id, proc, log))
return MagicMock(side_effect=spawn)
@ -62,6 +69,18 @@ class TestJobWaitsForInstaller:
assert self._install(tmp_path, _fake_spawn(None, 3), 1000) is False
assert {7} == _fast_install._FAILED
def test_progress_is_reported_while_waiting(self, tmp_path: Path) -> None:
text = b"Shutting down the Steam client...\r\nDownloading via depot\r\n"
text += b"Processing depot 1\r\nDownloading depot 1\r\n"
text += b" 46.90% /x/a.pak (657.13 Mbit/s)\r\n"
progress = MagicMock()
with use_progress(progress):
assert self._install(tmp_path, _fake_spawn(None, 0, log_text=text), 1000)
progress.phase.assert_called_with(
"Fast-installing G: downloading depot 1/1", 100
)
progress.advance.assert_called_with("G", step=46)
def test_daemon_returns_at_once(self, tmp_path: Path) -> None:
assert self._install(tmp_path, _fake_spawn(), 0) is True
assert _fast_install.is_fast_installing(7)
@ -82,3 +101,17 @@ class TestLogIsPerUser:
_fast_install._spawn_detached(["run.sh"], 9)
popen.assert_called_once()
assert _fast_install._RUNS[0].log_path != daemon_log
def test_progress_starts_at_the_current_end(self) -> None:
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
log = config.CONFIG_DIR / "fast_install-1000.log"
log.write_bytes(b"Restarting the Steam client...\r\n") # the last run
with (
patch(f"{PKG}.os.geteuid", return_value=1000),
patch(f"{PKG}.subprocess.Popen"),
):
_fast_install._spawn_detached(["run.sh"], 9, "G")
_fast_install_progress.poll_trackers({9})
progress = _fast_install_progress.progress_of(9, now=0.0)
assert progress is not None
assert progress.phase == _fast_install_progress.STARTING

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:
"""A pick retired by the sweep is announced before the typed-phrase prompt."""
def test_warns_about_uninstall_and_stale_assignment(self) -> None:
def test_lists_kept_games_and_stale_assignment(self) -> None:
state = locked_state(app_id=100)
state.current_app_id = 200
state.current_game_name = "Finished"
@ -146,7 +146,8 @@ class TestPickManualRetirementNotices:
):
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
output = " ".join(str(c) for c in mock_echo.call_args_list)
assert "Replace Finished (released or expired" in output
# The retired game is kept now, not replaced: it stays installed.
assert "they stay installed: Finished, TestGame" in output
assert "Aborted." in output
# Abort leaves the finished game as the assignment, so say so.
note = " ".join(str(c) for c in mock_note.call_args_list)

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")
state = State()
def capture_pick(games: list[GameInfo], *_args: object) -> None:
def capture_pick(
games: list[GameInfo], *_args: object, **_kwargs: object
) -> None:
captured_games.append(list(games))
with (

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 state.manual_picks] == [3]
def test_new_manual_pick_replaces_released_ones(self) -> None:
def test_new_manual_pick_keeps_released_ones(self) -> None:
state = State(current_app_id=2, manual_picks=[pick(2, released_at=STARTED_AT)])
with patch.object(State, "save"):
assert apply_manual_pick(state, 4, "G4", max_picks=1) is None
assert allowed_app_ids(state) == {4}
assert state.current_assigned_at == state.manual_picks[0]["started_at"]
assert allowed_app_ids(state) == {2, 4}
assert state.current_assigned_at == state.manual_picks[-1]["started_at"]
def test_released_pick_can_be_abandoned(self) -> None:
state = State(

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._ctl_protocol import CtlError, DaemonUnreachableError
from steam_backlog_enforcer._ctl_types import DaemonInfo, PendingReset, ResetResult
from steam_backlog_enforcer._ctl_types import (
DaemonInfo,
FastInstallInfo,
PendingReset,
ResetResult,
)
from steam_backlog_enforcer._web_errors import ApiError
from steam_backlog_enforcer.jobs._view import list_jobs
from steam_backlog_enforcer.tests._web_request import make_request, payload_of
@ -45,6 +50,7 @@ class TestDaemonView:
with patch(f"{_CLIENT}.status", side_effect=DaemonUnreachableError):
reply = _web_daemon.daemon_view(make_request())
assert payload_of(reply)["state"] == "unreachable"
assert payload_of(reply)["fast_installs"] == []
def test_status_refusal_is_an_error(self) -> None:
with (
@ -67,8 +73,27 @@ class TestDaemonView:
"pid": 77,
"journal_tail": ["a", "b"],
"restart_available_at": None,
"fast_installs": [],
}
def test_fast_installs_pass_through(self) -> None:
run = FastInstallInfo(7, "G", "downloading depot 3/8", 46.9, 60)
info = DaemonInfo("running", "t", 77, None, fast_installs=(run,))
with (
patch(f"{_CLIENT}.status", return_value=info),
patch(f"{_CLIENT}.journal_tail", return_value=[]),
):
reply = _web_daemon.daemon_view(make_request())
assert payload_of(reply)["fast_installs"] == [
{
"app_id": 7,
"name": "G",
"phase": "downloading depot 3/8",
"percent": 46.9,
"elapsed_seconds": 60,
}
]
@pytest.mark.parametrize(
"failure", [DaemonUnreachableError(), CtlError("busy", "m")]
)

View File

@ -39,7 +39,13 @@ export const BASE: Base[] = [
spec('check', 'Check assigned game for a new achievement', 'backlog', 'job', { mutating: false }),
spec('scan', 'Scan library & assign a game', 'backlog', 'job', { cancellable: true }),
spec('done', 'Move on after a new achievement, pick next', 'backlog', 'job'),
spec('pick', 'Manually pick your next game from candidates', 'backlog', 'job', { cancellable: true }),
spec('pick', 'Pick another game from candidates (keeps current; --replace swaps)', 'backlog', 'job', {
cancellable: true,
params: [{
name: 'replace', label: 'Replace current games (uninstalls them)', type: 'bool', required: false, default: 0,
help: 'Leave unchecked to keep playing them alongside the new pick.',
}],
}),
spec('pick-manual', 'Pick a game by app_id, lock enforcer for 14 days', 'backlog', 'job', {
params: [APP_ID], friction: phrase('lock in {game_name}'),
}),

View File

@ -60,6 +60,7 @@ function checkParam(p: ParamSpec, raw: unknown): number | string | undefined {
}
if (p.type === 'string') return String(raw)
const n = Number(raw)
if (p.type === 'bool' && n !== 0 && n !== 1) throw new HttpError(400, 'invalid_params', `${p.label} must be true or false.`)
if (!Number.isInteger(n)) throw new HttpError(400, 'invalid_params', `${p.label} must be a whole number.`)
if (p.min !== undefined && n < p.min) throw new HttpError(400, 'invalid_params', `${p.label} must be at least ${p.min}.`)
if (p.max !== undefined && n > p.max) throw new HttpError(400, 'invalid_params', `${p.label} must be at most ${p.max}.`)

View File

@ -76,6 +76,12 @@ const pick: Script = async (ctx) => {
}
ctx.progress(2, 2, 'Assigning', gameName(Number(chosen)))
await ctx.sleep(700)
// Mirrors _pick_keep: unchecked parks the outgoing game; replace drops all.
const old = world.currentAppId
if (Number(ctx.params.replace)) world.keptPicks = []
else if (old !== null && !world.keptPicks.includes(old) && !world.manualPicks.some((p) => p.app_id === old)) {
world.keptPicks.push(old)
}
world.currentAppId = Number(chosen)
return { summary: `Assigned ${gameName(Number(chosen))}.` }
}
@ -177,6 +183,7 @@ export const SCRIPTS: Partial<Record<CommandName, Script>> = {
'abandon-pick': async (ctx) => {
await ctx.sleep(800)
world.manualPicks = world.manualPicks.filter((p) => p.app_id !== Number(ctx.params.app_id))
world.keptPicks = world.keptPicks.filter((id) => id !== Number(ctx.params.app_id))
return { summary: `Abandoned ${gameName(Number(ctx.params.app_id))}.` }
},
'add-exception': async (ctx) => {

View File

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

302
web/package-lock.json generated
View File

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

View File

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

View File

@ -47,7 +47,8 @@ export type CommandCategory = 'backlog' | 'library' | 'gaming' | 'store' | 'syst
export interface ParamSpec {
name: string
label: string
type: 'int' | 'string' | 'app_id'
/** `bool` is a checkbox, sent as 0/1. */
type: 'int' | 'string' | 'app_id' | 'bool'
required: boolean
min?: number
max?: number
@ -101,6 +102,13 @@ export interface StatusPayload {
total_block: { active: boolean; days_remaining: number; until: string | null }
manual_pick_locked: boolean
manual_picks: ManualPickInfo[]
/** Released/expired picks and games `pick` kept: allowed, no lock. */
kept_picks: KeptPickInfo[]
}
export interface KeptPickInfo {
app_id: number
name: string
}
export interface StatsPayload {
@ -156,6 +164,17 @@ export interface SetupRequest {
export type DaemonState = 'running' | 'restarting' | 'unreachable'
/** A steam-game-installer run the root daemon started, read from its log. */
export interface FastInstallRun {
app_id: number
name: string
/** e.g. `shutting Steam down`, `downloading depot 3/8`, `finishing manifest`. */
phase: string
/** The current depot's percent; null until depotdownloader reports one. */
percent: number | null
elapsed_seconds: number
}
export interface DaemonStatus {
state: DaemonState
started_at: string | null
@ -163,6 +182,11 @@ export interface DaemonStatus {
journal_tail: string[]
/** ISO time before which `restart` is rate-limited, or null. */
restart_available_at: string | null
/**
* Empty when none is running or the daemon is unreachable; absent from a
* web server older than this UI (the window the `stale` health flag covers).
*/
fast_installs?: FastInstallRun[]
}
export interface ServerHealth {

View File

@ -56,4 +56,20 @@ describe('ParamsForm', () => {
render(<Harness disabled />)
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) => {
const id = `${base}-${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 game = p.type === 'app_id' ? games.find((g) => g.app_id === Number(raw)) : undefined
return (

View File

@ -172,7 +172,7 @@ function Runner({ spec, fixed, preset, onClose, onArmed }: RunnerProps) {
</ErrorNotice>
)}
<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}`}
</button>
<button type="button" className="btn btn-ghost" onClick={onClose}>

View File

@ -14,6 +14,10 @@ describe('paramError', () => {
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', () => {
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 })
})
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', () => {
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. */
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()
if (v === '') return p.required && p.default === undefined ? `${p.label} is required.` : 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 { 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> = {}) => ({
'GET /api/status': makeStatus(),
'GET /api/daemon': daemon(),
'GET /api/budget': makeBudget(),
'GET /api/jobs': [],
'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', () => {
it('shows time left and what is billing', async () => {
show()

View File

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
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 { CommandButton } from '../commands/CommandCard'
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() {
const { data, error } = useQuery(q.budget)
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>}
<BudgetGlance />
</div>
<FastInstalls />
<RecentJobs />
</>
)

View File

@ -7,6 +7,7 @@ import { Actions, Card, PageHead } from '../ui/Page'
/** Everything that changes which game is assigned. */
export function Picks() {
const { data, error } = useQuery(q.status)
const none = data !== undefined && data.manual_picks.length === 0 && data.kept_picks.length === 0
return (
<>
<PageHead
@ -23,7 +24,7 @@ export function Picks() {
<Card title="Manual picks">
{error && <ErrorNotice error={error} />}
{!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>
)}
<ul className="rows">
@ -35,8 +36,17 @@ export function Picks() {
<CommandButton name="abandon-pick" preset={{ app_id: p.app_id }} label="Abandon…" tone="danger" />
</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>
{data && data.manual_picks.length === 0 && (
{none && (
<div className="actions">
<CommandButton name="abandon-pick" label="Abandon a pick by app id…" />
</div>

View File

@ -37,6 +37,21 @@ describe('Picks', () => {
expect(screen.getByText('Hollow Knight', { selector: 'strong' })).toBeInTheDocument()
})
it('lists kept games under the active picks, each abandonable', async () => {
stubApi({
'GET /api/commands': SPECS,
'GET /api/status': makeStatus({
manual_picks: [{ app_id: 5, name: 'Five', age_days: 3 }],
kept_picks: [{ app_id: 7, name: 'Seven' }],
}),
})
renderRouted(<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 () => {
stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': makeStatus({ current_game_name: null }) })
renderRouted(<Picks />)

View File

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