web control UI: all 24 commands in React, ctl.sock daemon, jobs, library browser; earned_time 0.7.0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-10 16:07:42 +02:00
parent fcca4e946b
commit 341bc784ac
305 changed files with 27658 additions and 1347 deletions

View File

@ -110,7 +110,7 @@ repos:
- --disable-error-code=list-item - --disable-error-code=list-item
additional_dependencies: additional_dependencies:
- types-requests - types-requests
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.1#subdirectory=earned_time - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.7.0#subdirectory=earned_time
# =========================================================================== # ===========================================================================
# PYLINT # PYLINT
@ -130,7 +130,7 @@ repos:
# (steam_backlog_enforcer._mcp_server). # (steam_backlog_enforcer._mcp_server).
additional_dependencies: additional_dependencies:
- aiohttp==3.14.4 - aiohttp==3.14.4
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.1#subdirectory=earned_time - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.7.0#subdirectory=earned_time
- howlongtobeatpy==1.0.24 - howlongtobeatpy==1.0.24
- mcp==2.3.0 - mcp==2.3.0
- pytest - pytest

75
DOCS-ctl-socket.md Normal file
View File

@ -0,0 +1,75 @@
# Control socket: implementation notes
How the root daemon's `ctl.sock` (contract: `DOCS-web-control-api.md`, "Control
socket") is built. Code: `steam_backlog_enforcer/_ctl_*.py`; client for the web
server: `_ctl_client.py` (results in `_ctl_types.py`, errors in `_ctl_protocol.py`).
## Trust
- Root (uid 0) is admitted besides the desktop user: root can already do all of
this directly, so refusing it protects nothing and it keeps recovery/tests on
one interface. Any other uid is closed unanswered (client: `DaemonUnreachableError`).
- A second `enforce` (manual run, `--demo`) never takes the socket from a live
daemon; `--demo` never serves.
- The daemon re-checks the CLI's locks with the same tables
(`_command_locks.py`): under a total block only `gaming_unblock` works; under
the manual-pick lock `store_unblock` and `block_gaming` are `locked`.
- Ops run on connection threads and take the enforce loop's `tick_lock`, so they
never interleave with a pass; the loop reloads state under that lock.
## Differences from the contract
- Daemon-only error codes: `op_failed` (the privileged action ran and failed, or
an unexpected exception) and `unsupported` (restart asked of a daemon systemd
does not supervise: the exit would be a stop). Not in `ApiErrorCode`; the web
server should map unknown daemon codes to a 502.
- The two-phase reply uses `pending_id` (the contract's `PendingAction.id`) and an
extra `lapse_after_seconds`; the server maps it.
- `block_gaming` is capped at 1..365 days and refused while a block is active.
- `gaming_reset_arm` while one is armed returns the existing pending action
(same `pending_id`, same `ready_at`): re-arming can neither shorten nor extend
the wait. Pending resets live in daemon memory, so a daemon restart drops them
(fail closed: arm again).
- Test-only: `SBE_CTL_TEST_COUNTDOWN_SECONDS` (1..299) in the unit's environment
shortens the countdown for VM runs. It cannot lengthen it; unset means 300 s.
## Gaming reset
`_gaming_reset.reset_today`, shared with the CLI: zeroes billed time and per-game
attribution, re-arms warnings, releases the mounts, keeps earned time (`carry`,
`budget_seconds`; the old CLI path dropped both), and writes a `manual_adjustment`
record to `/var/log/steam-backlog-enforcer/budget.jsonl` (billed seconds and
per-game breakdown before). That record is the auditable trail; the per-day
history mirrors current billed time, so it shows the day as quiet.
## What a restart does and does not lose
`restart` sets a flag; the enforce loop finishes its pass, runs one more
accounting tick (billing the time since the last tick), leaves an exit-time
marker (`_ctl_gap.py`), and exits 0; `Restart=always` brings it back after
`RestartSec=5`. The new daemon spends the marker on its first tick, billing the
whole downtime. Everything else already lives on disk after every tick:
| State | Survives? | Why |
|---|---|---|
| Billed seconds, per-game attribution | yes | `playtime_state.json` saved every tick; the final tick bills up to the exit, the first tick of the new daemon bills the gap |
| Cutoff / kill-grace timing | yes | `blocked_at` is persisted; `_sustain_block` measures from it |
| Playtime mounts (the block itself) | yes | kernel mount table; `reconcile` is mountinfo-driven. `RuntimeDirectoryPreserve=yes` keeps the stub file |
| Fired warnings, carry ledger | yes | in `PlaytimeState` |
| Open store window | yes (blip) | deadline in `state.json`; startup re-blocks the store once, the first tick re-opens it |
| Total block | yes | lock file, guard-lib |
| Armed gaming reset | no | memory only: arm again (fails closed) |
| Audit-journal "last verdict", history throttle | no | cosmetic: one extra `verdict_change` line, one extra history write |
Why the gap is billed explicitly: the budget clamps each tick's delta to
`2 x ENFORCE_INTERVAL` = 6 s so a suspend cannot inflate it, which would forgive
the restart gap (`RestartSec` + interpreter start + startup pass, seconds to tens
of seconds) and make a restart repeatable free play. The marker is deleted
before use, expires after 300 s and only a requested restart writes one, so a
crash or a long outage still goes through the normal clamp. Downtime nothing
enforces: an unauthorised game started in the gap is killed on the first tick;
the budget cutoff and its mounts persist throughout.
Rate limit: 1 per 10 minutes, persisted in
`/var/lib/steam-backlog-enforcer/ctl_restart.json` (root, 0600) so restarting
the daemon does not reset it.

206
DOCS-web-control-api.md Normal file
View File

@ -0,0 +1,206 @@
# Web control API (contract)
The web UI replaces `run.sh` as the primary way to drive the enforcer. Every
CLI command is reachable from the UI; the CLI stays as the backend, the
systemd entry point and the recovery path. This file is the frozen contract
between the Python server, the root daemon and the React app. The TypeScript
mirror is `web/src/api/contract.ts`; change both together or neither.
## Trust model
- The web server is an unprivileged **user** unit on `127.0.0.1:8000`.
- Privileged work goes to the **root daemon** over `/run/steam-backlog-enforcer/ctl.sock`
(see "Control socket"). The web server never gets root.
- **Friction lives at the trust boundary, never only in the browser.** Typed
phrases, countdowns, the unblock cap and the 24h exception lock are checked
by the server (unprivileged ops) or the daemon (privileged ops). The test:
if `curl` can do it without the UI, it is not gated.
- **There is no way to stop or disable the enforcer** — not in the UI, not
in the CLI, not on the socket. Restart is allowed (it comes back anyway,
`Restart=always`), rate-limited, and only after the daemon flushed state.
## Web server auth
- `Host` must be `127.0.0.1:<port>` or `localhost:<port>` (DNS rebinding).
- Every non-GET request must carry `Origin` equal to the served origin.
- A per-launch token (32 random bytes, hex) is written to
`$XDG_RUNTIME_DIR/steam-backlog-enforcer/web-<port>.token` (mode 0600; one
file per port, so a second server never clobbers the live one's) and
injected into `index.html` as `<meta name="sbe-token" content="…">`.
Mutating requests send it as `X-SBE-Token`; `EventSource` sends it as
`?token=`. Missing or wrong token → `403 bad_token`; the UI then reloads
the page once (at most once per 30 s) to pick up a restarted server's token.
- Dev (`npm run dev`): Vite proxies `/api` to `127.0.0.1:8000` (`SBE_PORT`
overrides) with `Host` and `Origin` rewritten to that origin, and injects
the token meta from that port's token file (serve only, never in a build).
- Secrets (`steam_api_key`) are write-only: accepted by `POST /api/setup`,
never returned by any endpoint.
## Endpoints
| Method | Path | Body / query | Returns |
|---|---|---|---|
| GET | `/api/dataset` | – | `WebDataset` (exists) |
| GET | `/api/budget` | `?demo=1` | `BudgetSnapshot` (exists) |
| GET | `/api/status` | – | `StatusPayload` (`status_payload()`) |
| GET | `/api/stats` | – | `StatsPayload` (as MCP `get_stats`) |
| GET | `/api/installed` | – | `InstalledPayload` |
| GET | `/api/library` | – | `LibraryPayload` (every owned game, see below) |
| GET | `/api/art/{app_id}` | – | `image/jpeg` portrait cover, or `404 not_found` |
| 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/server` | – | `ServerHealth` (stale?, started_at, version) |
| POST | `/api/server/restart` | – | `202`; the user unit restarts the process |
| GET | `/api/backups` | – | `StateBackup[]` |
| POST | `/api/backups/{id}/restore` | `{confirm_phrase}` | `Job` |
| GET | `/api/jobs` | – | `Job[]` newest first, last 50 |
| POST | `/api/jobs` | `JobRequest` (`confirm_phrase` for every friction command, countdown ones included) | `Job` (`201`) or `PendingAction` (`202`) |
| GET | `/api/jobs/{id}` | – | `Job` |
| GET | `/api/jobs/{id}/events` | `?token=&after=<seq>` | SSE stream of `JobEvent` |
| POST | `/api/jobs/{id}/answer` | `PromptAnswer` | `204` |
| POST | `/api/jobs/{id}/cancel` | – | `204` (only if `CommandSpec.cancellable`) |
| POST | `/api/pending/{id}/heartbeat` | – | `PendingAction` |
| POST | `/api/pending/{id}/commit` | `{confirm_phrase}` | `Job` |
| DELETE | `/api/pending/{id}` | – | `204` |
Errors are `{"error": "<code>", "message": "<human text>"}` with a 4xx/5xx
status. Codes: `bad_token`, `bad_origin`, `bad_host`, `unknown_command`,
`invalid_params`, `not_found` (404: unknown job, backup or endpoint),
`wrong_phrase`, `locked` (manual-pick / total-block lock),
`busy` (another mutating job is running), `countdown_running`,
`pending_lapsed` (also for an unknown pending id: the daemon cannot tell
one from a lapsed one), `daemon_unreachable`, `rate_limited`,
`not_cancellable`, `op_failed` (502: a daemon op raised, or a job process
could not be started — that job is then already `failed`), `server_stale`
(503 on every `/api/*` but `/api/server` while the server retires on
outdated code), `unsupported`. A locked command is refused as `locked` even
while another job is running (the lock is checked before `busy`).
`block-gaming` days are capped at 1–365 by the daemon.
### Library (`/api/library`, `/api/art/{app_id}`)
`LibraryPayload` is `{games: LibraryGame[]}`, one row per owned game (the
owned-games records joined with the achievement snapshot, so games without
achievements are listed too), in no particular order — the UI sorts:
| Field | Meaning |
|---|---|
| `app_id`, `name` | Steam app id; name from the owned records, else the snapshot |
| `achievements_total`, `achievements_unlocked` | `0`/`0` when the scan has no data for it |
| `hltb_hours` | completionist hours (HLTB cache, else snapshot), `null` when unknown |
| `playtime_minutes` | total playtime |
| `last_played` | Unix seconds of the last session, `null` when never played |
| `installed` | an `appmanifest` exists in a Steam library folder |
| `assigned` | the assignment or an active manual pick |
| `ineligible_reason` | why "pick my own game" refuses it, `null` when pickable: `No achievements` / `Not scanned yet` (not in the snapshot; the owned record's `has_stats` tells which), `No achievement data` (not in the snapshot and the cached records predate `has_stats`), `Already 100% complete` (100 % or in `finished_app_ids`), `Skipped until <date>` / `Skipped for now`. Same rule (`_own_pick.ineligible_reason`) the job enforces on the answer |
The owned records (`games` in `owned_app_ids_cache.json`) are stored by
every owned-list refresh and read at any age. Reads are local-only, except
once: when no refresh has stored records for this account yet, the view asks
Steam (`GetOwnedGames`) a single time and caches the result.
`GET /api/art/{app_id}` serves a portrait cover, first source wins:
1. Steam's librarycache, `~/.local/share/Steam/appcache/librarycache/<id>/`
(`library_600x900.jpg` / `library_capsule.jpg`, top level or in a hash
subdir; never the 32×32 icons or the landscape `header.jpg`).
2. This server's disk cache, `~/.cache/steam_backlog_enforcer/art/<id>.jpg`.
3. Steam's CDN, `https://shared.steamstatic.com/store_item_assets/steam/apps/<id>/library_600x900.jpg`,
stored atomically into (2).
It is the only view that may use the network, so it is fenced: owned app
ids only (anything else is `404`; the id is an int in a fixed URL, never a
proxy), at most 6 CDN fetches at a time, `(3.05, 6)` s timeout, `image/*`
bodies ≤ 2 MiB only. A failed fetch leaves `<id>.miss` (retry-after time):
a CDN `404`/`403` or oversized body for a week, a network error or other
status for 10 minutes (the total block lists `steamstatic.com`, so art
appears once it lifts). No cover → `404 not_found`, and the UI draws a title
tile. Hits carry `Cache-Control: private, max-age=86400`. Like every GET it
needs no token, so a plain `<img src>` works.
## Jobs
Every command except pure views runs as a **job**: a subprocess
`python -m steam_backlog_enforcer.jobs run <job_dir>` that goes through the
same lock gate as the CLI (`_enforce_total_block_lock`,
`_enforce_manual_pick_lock`), then calls the command's non-interactive core
with explicit params, a `Progress` reporter and a `Prompter`.
- Job dir: `~/.local/state/steam-backlog-enforcer/jobs/<id>/` holding
`request.json`, `events.jsonl` (append-only, one `JobEvent` per line),
`answers.jsonl` (written by the server from `POST …/answer`) and
`result.json`. The server only tails files, so jobs survive both page
reloads and server restarts (including `_serve_stale` retirement).
- At most **one mutating job** at a time (`busy` otherwise). Read-only jobs
(`check`, `stats`, `list`, …) may run alongside.
- History: last 50 job dirs kept, older ones pruned at job start. Job ids
are `<UTC yyyymmddThhmmss + microseconds>Z-<6 hex>`, so sorting by id is
creation order even within one second.
- A job whose process cannot be started (e.g. `systemd-run --scope` failing)
is marked `failed` at once, with the launcher's output in its log, and
`POST /api/jobs` answers `op_failed`.
### JobEvent (one JSON object per line)
```jsonc
{ "seq": 7, "ts": "2026-10-10T12:00:00Z", "type": "progress",
"step": 3, "total": 12, "label": "Fetching HLTB times", "item": "Hades",
"eta_seconds": 41 }
```
`type` is one of:
- `state` — `{state}` job lifecycle transitions (`JobState`).
- `log` — `{level, message}` the human-readable line the CLI would print.
- `progress` — `{step, total, label, item?, eta_seconds?}`; `total` may be
`null` for indeterminate phases. Any operation over 2 s must emit
determinate progress.
- `prompt` — `{prompt_id, kind, message, options?, phrase?, default?,
app_ids?}`; `kind` is `choice` (pick one of `options`), `confirm`
(yes/no), `phrase` (type `phrase` exactly), `text` or `game`. The job
blocks until the answer arrives. Phrase answers are re-checked by the job,
not the UI.
- `game` ("Pick my own game…" in `pick`/`check`/`done`): the UI shows the
library browser in pick mode; `app_ids` (sorted) are the only games the
job accepts — the event carries ids only, the rows come from
`/api/library`. Answer the app id as a decimal string, or `""` to go back
to the `choice` prompt that offered it. The job re-validates: any other
answer logs a `warning` and re-asks with a new `prompt_id`. The CLI keeps
its text search for the same step.
- `result` — `{ok, summary, data?}` exactly once at the end.
## Friction (server/daemon-enforced)
| Command | Phrase (exact) | Extra |
|---|---|---|
| `gaming-reset` | `reset today's gaming budget` | daemon two-phase: arm (phrase required: `POST /api/jobs` with `confirm_phrase`) → 300 s with heartbeats every ≤10 s → commit (phrase required again: `POST /api/pending/{id}/commit`); lapses if a heartbeat is missed by >15 s; logged to the ledger |
| `block-gaming` | `block all gaming for {days} days` | daemon; no undo |
| `unblock` / `buy-dlc` | `unblock the store for {minutes} minutes` | daemon; 1–30 min cap |
| `gaming-unblock` | `force release playtime mounts` | daemon; if unreachable the UI shows the `sudo ./run.sh gaming-unblock` fallback |
| `abandon-pick` | `abandon {game_name}` | – |
| `pick-manual` | `lock in {game_name}` | – |
| `add-exception` | `request exception for {game_name}` | existing 24h lock |
| `uninstall` | `uninstall {count} games` | – |
| `reset` | `wipe all enforcer state` | automatic timestamped backup first |
| backup restore | `restore backup {id}` | – |
| `enforce` restart | – | rate limit: 1 per 10 min, state flushed first |
`CommandSpec.friction` carries the phrase template so the UI never
hard-codes it.
## Control socket (root daemon)
- Path `/run/steam-backlog-enforcer/ctl.sock`, created by the root daemon,
mode `0660`, group = the desktop user's primary group.
- Every connection is checked with `SO_PEERCRED`: uid must equal the
configured desktop user's uid, else the connection is closed unanswered.
- Protocol: one JSON request line → one JSON response line, then close.
Request `{"op": "<op>", "args": {…}}`, response `{"ok": true, "data": …}`
or `{"ok": false, "error": "<code>", "message": "…"}`.
- Allowlisted ops only: `ping`, `status`, `journal_tail{lines}`,
`store_unblock{minutes, phrase}`, `block_gaming{days, phrase}`,
`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`.

99
TODO-web-control-ui.md Normal file
View File

@ -0,0 +1,99 @@
# Move every run.sh command into the React web UI
REMOVE ME AFTER FINISH
Spec agreed with the user on 2026-10-10 (grilling rounds in session
`session_01M3QPamsDnLQyQ6g4rresQf`). Contract: `DOCS-web-control-api.md` +
`web/src/api/contract.ts`.
## what
A production-quality React/TS UI that covers ALL 24 CLI commands (views,
jobs with live progress, setup wizard, daemon + server health), so nobody
has to type `./run.sh <cmd>`. The CLI stays as backend/recovery path; bare
`./run.sh` opens the UI.
## must
- Privileged ops go through the root daemon's `ctl.sock` (SO_PEERCRED);
the web server never gets root.
- Friction enforced server/daemon-side (phrases, gaming-reset 300 s
heartbeat countdown, unblock cap, 24h exception lock). More friction, not
less.
- must not: any way to stop/disable the enforcer (UI, CLI or socket).
Restart only, rate-limited, after a state flush.
- Long ops: profile on scratch state, fix top hotspot each, determinate
progress (step N/M, item, ETA) for anything >2 s.
- Stack: React 19 + React Compiler + TanStack Router/Query, newest stable,
exact pins; tokens from `~/src/utils/unified-design-system`; keyboard
operability per its `DOCS-operability.md`.
- `.desktop` entry opening a Chrome `--app` window (never port 9222).
## done
- Every command has a working UI control, driven in headless chromium
against a scratch state dir, with a screenshot each; one live pass of
status + done.
- Before/after timing table for long ops, none slower.
- vitest 100 % on `web/src`, pytest 100 % on new Python modules,
pre-commit + dependency-freshness green.
- Daemon socket: passed in vmbox, then deployed (`sudo ./install.sh`,
daemon restarted) and verified live.
## status
- [x] contract frozen
- [x] A: Python job runner + Progress/Prompter refactor of input() sites
(applied to main tree, uncommitted; CLI "Type YES" → friction phrases)
- [x] B: root daemon ctl.sock + two-phase privileged ops — passed in vmbox,
NOT deployed to host yet (needs `sudo ./install.sh` + daemon restart)
- [x] C: frontend shell + all screens against mocked backend (landed;
contract split into contract.ts + jobContract.ts for the 250 cap;
`npm run dev:mock` runs it against web/mock)
- [x] D: server write endpoints, auth, SSE, views, catalog; wire A+B
- [x] D2: integration fixes (arm phrase, not_found/server_stale, dev proxy,
token reload, per-port token, job ordering/locked-vs-busy/spawn fail,
save_credentials keeps settings)
- [x] user decided (Q16): `reset` never unblocks the store, CLI or web —
`release_store` flag and web `_reset` wrapper removed; verified on
scratch HOME (tests in test_main_misc* will need updating)
- [x] E: profile + optimise long ops — check ~4x (tampering re-fetch 20 in
flight + early stop), scan −23% after the no-achievements skip cache
warms (`_achievement_skip.py`), scan cancel 71 s → 1 s, stats −1.7 s
(HLTB search-URL memo). Coordinator fix: skip cache only records
Steam's explicit 400 "no stats", never timeouts/5xx (verified).
- [x] user request: "Pick my own game…" in list/done prompts (`_own_pick.py`),
cancel any job waiting on a prompt (cancel.request marker) — verified
- [x] G: VERIFIED 2026-10-10 — scratch HOME (grid 1369, "wicher" →
Witcher 1/2/3, scroll 0 dropped frames p95 16.8 ms, filters, pick-mode
re-ask/back/cancel, mock mode) + vmbox guest `sbe-g` (own-pick →
Witcher 2 assigned in a real pick job, API + browser). Screenshots
/tmp/sbe-g/. Pick submits only via the "Pick …" button (no
double-click assign). Owned-cache `has_stats` + records survive only
once the daemon runs G code (H deploy). Awaiting user confirmation.
- [x] G (spec): Steam-like library browser (user spec Q21–Q26, 2026-10-10, all as
recommended): new Library page grid of ALL owned games with cover art,
same component replaces the own-pick text prompt (new prompt kind
`game`; CLI keeps text search); ineligible games greyed with reason;
art = Steam librarycache (`~/.local/share/Steam/appcache/librarycache/
<appid>/<hash>/library_capsule.jpg`, 558 games) → server-side CDN fetch
+ disk cache (`shared.cloudflare.steamstatic.com/store_item_assets/
steam/apps/<id>/library_600x900.jpg`, not store-blocked) → title tile;
lazy images; fuzzy typo-tolerant search + top-8 suggestion dropdown,
Enter/arrows, roman↔arabic numerals; card: cover, title, ach %, HLTB h,
playtime, installed/assigned badges; sort name/recent/completion/HLTB;
filters hide-completed/installed/pickable. BUILT 2026-10-10 (uncommitted):
`_web_library.py`, `_web_art.py`, owned-cache `games` records,
`ineligible_reason` + `GamePicker`/`JobPrompter.game`, web/src/library/*,
jobs/GamePrompt.tsx, Library.tsx toggle, styles/library.css. Scratch
verification + mock + DOCS update delegated; own-pick end-to-end runs
in a vmbox copy of the enforcer (user, 2026-10-10: never live). DONE: grid lists all ~1369
owned games with smooth scroll, "wicher" → Witcher 1/2/3 in suggestions,
own-pick from the browser in a live Check-now job assigns the game.
- [ ] H: deploy DONE 2026-10-10 15:23 (`sudo install.sh` + daemon restart,
ctl.sock live; gaming-reset arm → heartbeat → cancel verified live,
204/410). STILL TODO: every-screen screenshot pass (real server,
scratch HOME) + one live status + done pass.
- [x] tests: pytest 2707 pass, 100 % line+branch; vitest 523 pass, 100 %
(React Compiler off under vitest only: its memo-cache branches are
unreachable for coverage)
- [x] F: run.sh bare → UI (`scripts/open_ui.sh`), .desktop entry (install.sh
`install_desktop_entry`) — opened live, WM class verified
- [ ] F2: live verification of the finished UI
- [x] user confirmation → tests, coverage, pre-commit, commit (2026-10-10)

View File

@ -64,6 +64,25 @@ install_web_user_unit() {
install_web_user_unit install_web_user_unit
# The desktop entry is how the UI gets opened day to day (./run.sh with no
# arguments does the same), so it is installed with the unit that serves it.
install_desktop_entry() {
local target_user home_dir dst
target_user="${SUDO_USER:-$USER}"
home_dir="$(getent passwd "$target_user" | cut -d: -f6)"
[[ -n $home_dir ]] || return 0
dst="$home_dir/.local/share/applications/steam-backlog-enforcer.desktop"
mkdir -p "$(dirname "$dst")"
sed "s|__REPO_DIR__|$SCRIPT_DIR|g" \
"$SCRIPT_DIR/steam-backlog-enforcer.desktop" > "$dst"
if [[ $EUID -eq 0 ]]; then
chown "$target_user" "$dst"
fi
echo "Installed desktop entry: $dst"
}
install_desktop_entry
# Install systemd service (system-level, runs as root). # Install systemd service (system-level, runs as root).
# #
# Non-interactive callers (install_core_system.sh, CI, `vm run`) have no stdin, # Non-interactive callers (install_core_system.sh, CI, `vm run`) have no stdin,
@ -113,6 +132,10 @@ if [[ "${ans,,}" == "y" ]]; then
systemctl daemon-reload systemctl daemon-reload
systemctl enable steam-backlog-enforcer systemctl enable steam-backlog-enforcer
echo "Service installed and enabled." echo "Service installed and enabled."
# The unit's RuntimeDirectory/StateDirectory (control socket, restart rate
# limit) are created when the service STARTS; a daemon that was already
# running keeps serving without ctl.sock until it is restarted.
echo " Web-UI control socket: /run/steam-backlog-enforcer/ctl.sock (created on start)"
echo " Start now: sudo systemctl start steam-backlog-enforcer" echo " Start now: sudo systemctl start steam-backlog-enforcer"
echo " Check: sudo systemctl status steam-backlog-enforcer" echo " Check: sudo systemctl status steam-backlog-enforcer"
echo " Logs: sudo journalctl -u steam-backlog-enforcer -f" echo " Logs: sudo journalctl -u steam-backlog-enforcer -f"

View File

@ -16,7 +16,7 @@ dependencies = [
"websockets==17.2", "websockets==17.2",
# The earner registry (gaming budget terms); stdlib-only, so the root # The earner registry (gaming budget terms); stdlib-only, so the root
# daemon imports it from the desktop user's site-packages. # daemon imports it from the desktop user's site-packages.
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.1#subdirectory=earned_time", "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.7.0#subdirectory=earned_time",
] ]
# Optional extras. The `mcp` group is ONLY needed by the MCP server # Optional extras. The `mcp` group is ONLY needed by the MCP server

View File

@ -5,7 +5,7 @@ aiohttp==3.14.4
bandit==1.9.4 bandit==1.9.4
codespell==2.4.3 codespell==2.4.3
coverage==7.16.2 coverage==7.16.2
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.1#subdirectory=earned_time earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.7.0#subdirectory=earned_time
howlongtobeatpy==1.0.24 howlongtobeatpy==1.0.24
mcp==2.3.0 mcp==2.3.0
mypy==2.4.0 mypy==2.4.0

5
run.sh
View File

@ -1,11 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Launcher for the Steam Backlog Enforcer. # Launcher for the Steam Backlog Enforcer.
# Usage: ./run.sh [command] (defaults to "done" if no command given) # Usage: ./run.sh open the web UI (the primary interface)
# ./run.sh <command> run a CLI command (backend / recovery path)
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
if [[ $# -eq 0 ]]; then if [[ $# -eq 0 ]]; then
exec python -m steam_backlog_enforcer.main "done" exec scripts/open_ui.sh
else else
exec python -m steam_backlog_enforcer.main "$@" exec python -m steam_backlog_enforcer.main "$@"
fi fi

61
scripts/open_ui.sh Executable file
View File

@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Open the Steam Backlog Enforcer web UI in its own Chromium app window.
#
# The server is the steam-backlog-enforcer-web user unit; this only makes
# sure it is up and points an --app window at it. A dedicated profile keeps
# the window free of the main browser's tabs and extensions and gives it its
# own WM class, so i3 can treat it like a native app.
set -euo pipefail
readonly UNIT="steam-backlog-enforcer-web"
readonly URL="${SBE_UI_URL:-http://127.0.0.1:8000/}"
readonly PROFILE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/steam-backlog-enforcer/ui-profile"
readonly WM_CLASS="steam-backlog-enforcer"
readonly WAIT_SECONDS=15
find_browser() {
local candidate
for candidate in chromium google-chrome-stable; do
if command -v "$candidate" >/dev/null 2>&1; then
command -v "$candidate"
return 0
fi
done
echo "Error: no chromium or google-chrome-stable on PATH." >&2
return 1
}
# Start the unit if it is not running; Restart=always keeps it up after.
ensure_server() {
if ! systemctl --user is-active --quiet "$UNIT"; then
systemctl --user start "$UNIT"
fi
local waited=0
until curl --silent --fail --max-time 1 --output /dev/null "$URL"; do
if ((waited >= WAIT_SECONDS)); then
echo "Error: $URL did not answer within ${WAIT_SECONDS}s." >&2
echo "Check: journalctl --user -u $UNIT -n 50" >&2
return 1
fi
sleep 1
waited=$((waited + 1))
done
}
main() {
local browser
browser="$(find_browser)"
ensure_server
mkdir -p "$PROFILE_DIR"
# setsid + nohup: the window must outlive the terminal that ran run.sh.
setsid nohup "$browser" \
--app="$URL" \
--class="$WM_CLASS" \
--user-data-dir="$PROFILE_DIR" \
--no-first-run \
--no-default-browser-check \
>/dev/null 2>&1 &
echo "Opened $URL"
}
main "$@"

View File

@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Steam Backlog Enforcer
Comment=Backlog, gaming budget and enforcer controls
Exec=__REPO_DIR__/scripts/open_ui.sh
Icon=applications-games
Terminal=false
Categories=Game;
StartupWMClass=steam-backlog-enforcer

View File

@ -16,6 +16,19 @@ Environment=STEAM_ENFORCER_DESKTOP_USER=__DESKTOP_USER__
Environment=DISPLAY=:0 Environment=DISPLAY=:0
# Hardening: enforcer must not be easily killed. # Hardening: enforcer must not be easily killed.
OOMScoreAdjust=-900 OOMScoreAdjust=-900
# /run/steam-backlog-enforcer: the control socket for the web UI (ctl.sock) and
# the playtime refusal stub (gaming-blocked, bind-mounted over /usr/bin/steam
# while the budget is spent). Mode 0755 so the stub stays executable by
# everyone; the socket file carries its own 0660 + SO_PEERCRED check.
# Preserve=yes: with the default (no) systemd deletes the directory whenever the
# unit stops, which would delete the stub out from under a live block.
RuntimeDirectory=steam-backlog-enforcer
RuntimeDirectoryMode=0755
RuntimeDirectoryPreserve=yes
# /var/lib/steam-backlog-enforcer: root-only state of the control socket (the
# persisted restart rate limit).
StateDirectory=steam-backlog-enforcer
StateDirectoryMode=0700
# Journal backstop. Steam's srt-logger runs in this unit's cgroup and mirrors # Journal backstop. Steam's srt-logger runs in this unit's cgroup and mirrors
# Steam's console into journald; 2026-09-12 it wrote 481k "joystick FFB" # Steam's console into journald; 2026-09-12 it wrote 481k "joystick FFB"
# lines in one boot (99% of the journal) and evicted two weeks of history. # lines in one boot (99% of the journal) and evicted two weeks of history.

View File

@ -0,0 +1,138 @@
"""Owned games known to have no achievements, so a library scan can skip them.
A scan is bound by the Steam request rate (``SteamAPIClient._max_rps``, one
request per owned game), and about a quarter of a typical library has no
achievements at all. A game is skipped only while all three hold:
- Steam does not flag it ``has_community_visible_stats``;
- it is not in the last snapshot (a game can have achievements without the
flag, so the flag alone is not trusted);
- a scan less than :data:`RECHECK_AFTER_SECONDS` ago found no achievements.
So a game that later gains achievements without gaining the flag is still
picked up, at the latest that long after.
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING, Any, Final
from steam_backlog_enforcer import config
from steam_backlog_enforcer._snapshot import load_snapshot
from steam_backlog_enforcer.config import _atomic_write
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
RECHECK_AFTER_SECONDS: Final = 30 * 86_400
_FILE_NAME: Final = "no_achievements_cache.json"
_NO_STATS_STATUS: Final = 400
def _cache_file() -> Path:
"""Resolved per call, so a redirected ``CONFIG_DIR`` is honoured."""
return config.CONFIG_DIR / _FILE_NAME
def _load_checked(steam_id: str) -> dict[int, float]:
"""Map app id -> when a scan last found it without achievements.
Returns:
An empty map when the file is missing, corrupt or for another account.
"""
try:
data = json.loads(_cache_file().read_text(encoding="utf-8"))
except OSError, ValueError:
return {}
if not isinstance(data, dict) or data.get("steam_id") != steam_id:
return {}
checked: dict[int, float] = {}
for key, when in dict(data.get("checked", {})).items():
try:
checked[int(key)] = float(when)
except TypeError, ValueError:
continue
return checked
def is_no_stats_answer(exc: Exception) -> bool:
"""Whether a failed achievements request was Steam saying "no stats".
``GetPlayerAchievements`` answers a game without stats with HTTP 400 and
``{"playerstats": {"error": "Requested app has no stats", "success":
false}}``. That is a real answer; any other failure (timeout, 5xx, 429)
says nothing about the game.
"""
response = getattr(exc.__cause__, "response", None)
if response is None or response.status_code != _NO_STATS_STATUS:
return False
try:
stats = response.json().get("playerstats", {})
except ValueError, AttributeError:
return False
return isinstance(stats, dict) and stats.get("success") is False
def _may_have_achievements(game: dict[str, Any]) -> bool:
"""Whether Steam's owned-games entry says the game has stats."""
return bool(game.get("has_community_visible_stats"))
def skippable_app_ids(
steam_id: str, owned: list[dict[str, Any]], now: float
) -> set[int]:
"""Return the owned games a scan may skip right now (see module doc).
Args:
steam_id: The account the cache must belong to.
owned: ``GetOwnedGames`` entries.
now: Current epoch seconds.
"""
checked = _load_checked(steam_id)
if not checked:
return set()
in_snapshot = {entry.get("app_id") for entry in load_snapshot() or []}
return {
game["appid"]
for game in owned
if not _may_have_achievements(game)
and game["appid"] not in in_snapshot
and now - checked.get(game["appid"], float("-inf")) < RECHECK_AFTER_SECONDS
}
def record_scan(
steam_id: str,
fetched: list[dict[str, Any]],
with_achievements: set[int],
now: float,
) -> None:
"""Remember which fetched games came back without achievements.
Never raises: losing the cache only costs the next scan its shortcut.
Args:
steam_id: The account the cache belongs to.
fetched: The owned-games entries this scan asked Steam about.
with_achievements: App ids that came back with achievements.
now: Current epoch seconds.
"""
checked = _load_checked(steam_id)
for game in fetched:
app_id = game["appid"]
if app_id in with_achievements or _may_have_achievements(game):
checked.pop(app_id, None)
else:
checked[app_id] = now
payload = {
"steam_id": steam_id,
"checked": {str(app_id): when for app_id, when in sorted(checked.items())},
}
try:
_atomic_write(_cache_file(), json.dumps(payload, indent=2) + "\n")
except OSError:
logger.warning("Could not save %s", _cache_file(), exc_info=True)

View File

@ -0,0 +1,143 @@
"""Timestamped backups of ``state.json``, taken before destructive commands.
``reset`` wipes the assignment, finished list and pick history; a backup is
taken automatically first so a mistyped command costs nothing. Restoring is
itself destructive (it replaces the current state), so the current state is
backed up before every restore as well.
Only ``state.json`` is copied. The lock and exception files
(``total_block_lock.json``, ``approved_exceptions.json``) are deliberately
left out: restoring an old copy of either would be a way to lift a lock.
For the same reason the restore command is gated like ``reset`` — no restore
while a manual-pick or total-block lock is active.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
import json
from pathlib import Path
import re
import secrets
import shutil
from steam_backlog_enforcer import config as config_mod
BACKUPS_DIR = Path.home() / ".local" / "state" / "steam-backlog-enforcer" / "backups"
_STATE_NAME = "state.json"
_META_NAME = "meta.json"
_ID_PATTERN = re.compile(r"^\d{8}T\d{6}Z-[0-9a-f]{6}$")
@dataclass(frozen=True)
class StateBackup:
"""One backup, shaped like the TS ``StateBackup`` contract type.
Attributes:
id: Sortable id: UTC timestamp plus a random suffix.
created_at: ISO-8601 UTC creation time.
reason: Why it was taken (``"before reset"``, ...).
size_bytes: Size of the backed-up ``state.json``.
"""
id: str
created_at: str
reason: str
size_bytes: int
def to_json(self) -> dict[str, object]:
"""Return the JSON object the web API serves."""
return asdict(self)
class BackupError(Exception):
"""A backup could not be found, read or restored."""
def _backup_dir(backup_id: str) -> Path:
"""Resolve a backup id to its directory, refusing anything path-like."""
if not _ID_PATTERN.fullmatch(backup_id):
msg = f"Not a backup id: {backup_id!r}"
raise BackupError(msg)
return BACKUPS_DIR / backup_id
def create_backup(reason: str) -> StateBackup | None:
"""Copy the current ``state.json`` into a new backup.
Args:
reason: Short human text stored with the backup.
Returns:
The new backup, or ``None`` when there is no state file to back up.
"""
source = config_mod.STATE_FILE
if not source.exists():
return None
now = datetime.now(UTC)
backup_id = f"{now.strftime('%Y%m%dT%H%M%SZ')}-{secrets.token_hex(3)}"
target = BACKUPS_DIR / backup_id
target.mkdir(parents=True, mode=0o700)
shutil.copy2(source, target / _STATE_NAME)
meta = {"created_at": now.isoformat(), "reason": reason}
(target / _META_NAME).write_text(json.dumps(meta) + "\n", encoding="utf-8")
return _read_backup(target)
def _read_backup(path: Path) -> StateBackup | None:
"""Load one backup directory, or ``None`` if it is incomplete."""
try:
meta = json.loads((path / _META_NAME).read_text(encoding="utf-8"))
size = (path / _STATE_NAME).stat().st_size
except OSError, ValueError:
return None
return StateBackup(
id=path.name,
created_at=str(meta.get("created_at", "")),
reason=str(meta.get("reason", "")),
size_bytes=size,
)
def list_backups() -> list[StateBackup]:
"""Return every readable backup, newest first."""
if not BACKUPS_DIR.is_dir():
return []
found = (
_read_backup(path)
for path in BACKUPS_DIR.iterdir()
if _ID_PATTERN.fullmatch(path.name)
)
return sorted(
(backup for backup in found if backup is not None),
key=lambda backup: backup.id,
reverse=True,
)
def restore_backup(backup_id: str) -> StateBackup | None:
"""Replace ``state.json`` with a backup's copy.
The state being replaced is backed up first, so a restore can be undone
by restoring that newer backup.
Args:
backup_id: Id from :func:`list_backups`.
Returns:
The backup of the state that was replaced, if there was one.
Raises:
BackupError: If the id is malformed or the backup is unreadable.
"""
source = _backup_dir(backup_id) / _STATE_NAME
try:
data = source.read_text(encoding="utf-8")
json.loads(data)
except (OSError, ValueError) as exc:
msg = f"Backup {backup_id} is unreadable: {exc}"
raise BackupError(msg) from exc
previous = create_backup(f"before restoring {backup_id}")
config_mod.atomic_write(config_mod.STATE_FILE, data)
return previous

View File

@ -23,7 +23,10 @@ import json
import logging import logging
from typing import Any, Final from typing import Any, Final
from steam_backlog_enforcer._playtime_log import budget_log_path from steam_backlog_enforcer._playtime_log import (
EVENT_MANUAL_ADJUSTMENT,
budget_log_path,
)
from steam_backlog_enforcer._playtime_procs import process_name from steam_backlog_enforcer._playtime_procs import process_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -79,7 +82,10 @@ def _last_record(*, demo: bool) -> dict[str, Any] | None:
record = json.loads(line) record = json.loads(line)
except ValueError: except ValueError:
continue continue
if isinstance(record, dict): # A manual adjustment (e.g. a gaming reset) is an audit note, not a
# tick verdict: returning it would blank the live status until the
# next verdict change or heartbeat.
if isinstance(record, dict) and record.get("event") != EVENT_MANUAL_ADJUSTMENT:
return record return record
return None return None

View File

@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import sys
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from steam_backlog_enforcer._hltb_cached import fetch_hltb_times_cached from steam_backlog_enforcer._hltb_cached import fetch_hltb_times_cached
@ -13,6 +12,12 @@ from steam_backlog_enforcer._hltb_confidence import (
from steam_backlog_enforcer._hltb_types import ( from steam_backlog_enforcer._hltb_types import (
load_hltb_polls_cache, load_hltb_polls_cache,
) )
from steam_backlog_enforcer._own_pick import (
OWN_PICK_OPTION,
OWN_PICK_VALUE,
prompt_own_pick,
)
from steam_backlog_enforcer._prompter import PromptOption, current_prompter
from steam_backlog_enforcer._snapshot import load_snapshot from steam_backlog_enforcer._snapshot import load_snapshot
from steam_backlog_enforcer.game_install import ( from steam_backlog_enforcer.game_install import (
_echo, _echo,
@ -24,38 +29,42 @@ if TYPE_CHECKING:
_REASSIGN_REFRESH_LIMIT = 50 _REASSIGN_REFRESH_LIMIT = 50
_SKIP_DAYS = 7 _SKIP_DAYS = 7
_KEEP = "keep"
_SKIP = "skip"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _prompt_keep_or_skip(game: GameInfo) -> bool: def _prompt_keep_or_skip(
game: GameInfo, games: list[GameInfo], state: State
) -> bool | GameInfo:
"""Ask the user whether to keep the freshly-picked ``game``. """Ask the user whether to keep the freshly-picked ``game``.
Returns ``True`` to accept the pick, ``False`` to skip it (which the Returns ``True`` to accept the pick, ``False`` to skip it (which the
caller will translate into a 7-day skip entry on ``State``). When caller will translate into a 7-day skip entry on ``State``), or the game
stdin is not a TTY (e.g. background daemon, piped invocation), the the user searched for when they pick their own instead. When no human
pick is accepted silently to preserve the legacy non-interactive can answer (a CLI whose stdin is not a TTY: background daemon, piped
behaviour. invocation), the pick is accepted silently to preserve the legacy
non-interactive behaviour. A web job always asks.
""" """
if not sys.stdin.isatty(): prompter = current_prompter()
if not prompter.interactive:
return True return True
hours_str = "" hours_str = ""
if game.completionist_hours > 0: if game.completionist_hours > 0:
hours_str = f" (~{game.completionist_hours:.1f}h leisure+dlc)" hours_str = f" (~{game.completionist_hours:.1f}h leisure+dlc)"
_echo(f"\n Next pick: {game.name} (AppID={game.app_id}){hours_str}") _echo(f"\n Next pick: {game.name} (AppID={game.app_id}){hours_str}")
options = [
PromptOption(value=_KEEP, label=f"Keep {game.name}"),
PromptOption(value=_SKIP, label=f"Skip it for {_SKIP_DAYS} days"),
OWN_PICK_OPTION,
]
while True: while True:
try: answer = prompter.choice(" Keep this game?", options)
answer = ( if answer != OWN_PICK_VALUE:
input(f" Keep this game? [Y/n] (n = skip for {_SKIP_DAYS} days): ") return answer == _KEEP
.strip() own = prompt_own_pick(games, state)
.lower() if own is not None:
) return own
except EOFError:
return True
if answer in {"", "y", "yes"}:
return True
if answer in {"n", "no"}:
return False
_echo(" Please answer 'y' or 'n'.")
def _backfill_polls_for_finished( def _backfill_polls_for_finished(

View File

@ -22,6 +22,7 @@ from steam_backlog_enforcer._cmd_done import (
) )
from steam_backlog_enforcer._hltb_cached import fetch_hltb_times_cached from steam_backlog_enforcer._hltb_cached import fetch_hltb_times_cached
from steam_backlog_enforcer._hltb_types import load_hltb_cache from steam_backlog_enforcer._hltb_types import load_hltb_cache
from steam_backlog_enforcer._progress import current_progress
from steam_backlog_enforcer._snapshot import load_snapshot from steam_backlog_enforcer._snapshot import load_snapshot
from steam_backlog_enforcer._steam_state import is_game_fully_installed from steam_backlog_enforcer._steam_state import is_game_fully_installed
from steam_backlog_enforcer.enforcer import ( from steam_backlog_enforcer.enforcer import (
@ -70,7 +71,12 @@ def _finalize_completion(
skip = set(state.finished_app_ids) | state.active_skipped_ids() skip = set(state.finished_app_ids) | state.active_skipped_ids()
_refresh_uncached_shortlist_hours(games, hltb_cache, skip) _refresh_uncached_shortlist_hours(games, hltb_cache, skip)
_apply_cached_hours_to_games(games, hltb_cache) _apply_cached_hours_to_games(games, hltb_cache)
pick_next_game(games, state, config, on_select=_prompt_keep_or_skip) pick_next_game(
games,
state,
config,
on_select=lambda g: _prompt_keep_or_skip(g, games, state),
)
if state.current_app_id in {None, game.app_id}: if state.current_app_id in {None, game.app_id}:
# Nothing new was accepted, so the released game stays assigned. # Nothing new was accepted, so the released game stays assigned.
@ -160,7 +166,10 @@ def cmd_done(config: Config, state: State) -> None:
app_id = state.current_app_id app_id = state.current_app_id
_echo(f"Checking {game_name} (AppID={app_id})...") _echo(f"Checking {game_name} (AppID={app_id})...")
progress = current_progress()
progress.phase("Fetching achievements from Steam", 1)
game = client.refresh_single_game(app_id, game_name) game = client.refresh_single_game(app_id, game_name)
progress.advance(game_name)
if game is None: if game is None:
_echo(" Could not fetch achievement data from Steam.") _echo(" Could not fetch achievement data from Steam.")
return return

View File

@ -1,6 +1,6 @@
"""CLI handlers for the daily gaming budget. """CLI handlers for the daily gaming budget.
Every ``_echo`` / ``input`` / ``sys.exit`` for this feature lives here rather Every ``_echo`` / prompt / ``sys.exit`` for this feature lives here rather
than in :mod:`steam_backlog_enforcer._playtime` or than in :mod:`steam_backlog_enforcer._playtime` or
:mod:`steam_backlog_enforcer._playtime_block`, so the MCP server can import :mod:`steam_backlog_enforcer._playtime_block`, so the MCP server can import
those modules' leaf helpers without any risk of writing to stdout — which is those modules' leaf helpers without any risk of writing to stdout — which is
@ -9,24 +9,22 @@ the JSON-RPC channel.
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
import os import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from steam_backlog_enforcer._enforce_loop import do_enforce from steam_backlog_enforcer._enforce_loop import do_enforce
from steam_backlog_enforcer._gaming_days import gaming_day_key from steam_backlog_enforcer._gaming_reset import reset_today
from steam_backlog_enforcer._playtime_block import ( from steam_backlog_enforcer._playtime_block import (
block_targets, block_targets,
mounted_targets, mounted_targets,
release_block, release_block,
) )
from steam_backlog_enforcer._playtime_state import ( from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
load_state, load_state,
rules_for, rules_for,
save_state,
state_path, state_path,
) )
from steam_backlog_enforcer._prompter import confirm_phrase
from steam_backlog_enforcer.game_install import _echo from steam_backlog_enforcer.game_install import _echo
if TYPE_CHECKING: if TYPE_CHECKING:
@ -92,11 +90,11 @@ def cmd_gaming_status(config: Config, _state: State) -> None:
_echo(f" {target}") _echo(f" {target}")
def cmd_gaming_reset(config: Config, _state: State) -> int: def cmd_gaming_reset(_config: Config, _state: State) -> int:
"""Zero today's gaming counter and lift the block. """Zero today's gaming counter and lift the block.
Args: Args:
config: Enforcer configuration. _config: Unused; required by the command dispatch signature.
_state: Unused; required by the command dispatch signature. _state: Unused; required by the command dispatch signature.
Returns: Returns:
@ -107,17 +105,12 @@ def cmd_gaming_reset(config: Config, _state: State) -> int:
return 1 return 1
_echo("This resets today's gaming counter and lifts the block.") _echo("This resets today's gaming counter and lifts the block.")
if input("Type YES to confirm: ").strip() != "YES": if not confirm_phrase("gaming-reset", "Reset today's gaming budget"):
_echo("Aborted.") _echo("Aborted.")
return 1 return 1
released = release_block() outcome = reset_today(source="cli")
now = datetime.now(UTC).astimezone() _echo(f"Gaming counter reset. Released {len(outcome.released)} mount(s).")
save_state(
PlaytimeState(day_key=gaming_day_key(now), last_tick_at=now.timestamp()),
demo=rules_for(config, demo=False).demo,
)
_echo(f"Gaming counter reset. Released {len(released)} mount(s).")
return 0 return 0

View File

@ -0,0 +1,141 @@
"""The server-driven command catalog: ``GET /api/commands``.
One ``CommandSpec`` (``web/src/api/contract.ts``) per CLI command, so the UI
renders every control from data instead of hard-coding commands, phrases or
lock rules. Descriptions come from the CLI's own help table
(:mod:`steam_backlog_enforcer._command_help`), friction from
:data:`steam_backlog_enforcer._friction.FRICTION`, params from
:data:`steam_backlog_enforcer._command_params.PARAMS`, job flags from the job
registry and ``locked_reason`` from the CLI's lock gate — nothing here is a
second copy of a rule that lives elsewhere.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, Literal
from steam_backlog_enforcer._command_gate import lock_reason
from steam_backlog_enforcer._command_help import ALL_COMMAND_DESCRIPTIONS
from steam_backlog_enforcer._command_params import PARAMS
from steam_backlog_enforcer._friction import FRICTION
from steam_backlog_enforcer.config import Config, State
from steam_backlog_enforcer.jobs._specs import JOB_FLAGS
if TYPE_CHECKING:
from steam_backlog_enforcer._friction import Friction
Category = Literal["backlog", "library", "gaming", "store", "system"]
Kind = Literal["view", "job", "screen"]
@dataclass(frozen=True)
class _Entry:
"""How the UI surfaces one command (the parts not derived elsewhere).
``mutating`` / ``cancellable`` / ``privileged`` here apply to views and
screens only; for ``job`` commands they come from the job registry.
"""
category: Category
kind: Kind
view_endpoint: str | None = None
mutating: bool = False
cancellable: bool = False
privileged: bool = False
def _view(category: Category, endpoint: str) -> _Entry:
"""A read-only command rendered from a GET endpoint."""
return _Entry(category, "view", view_endpoint=endpoint)
def _job(category: Category) -> _Entry:
"""A command that runs as a job (flags from the registry)."""
return _Entry(category, "job")
# Order is the UI's default order: everyday backlog flow first.
CATALOG: Final[dict[str, _Entry]] = {
"status": _view("backlog", "/api/status"),
"list": _view("backlog", "/api/dataset"),
"stats": _view("backlog", "/api/stats"),
"check": _job("backlog"),
"done": _job("backlog"),
"scan": _job("backlog"),
"pick": _job("backlog"),
"pick-manual": _job("backlog"),
"abandon-pick": _job("backlog"),
"installed": _view("library", "/api/installed"),
"install": _job("library"),
"uninstall": _job("library"),
"hide": _job("library"),
"unhide": _job("library"),
"add-exception": _job("library"),
"gaming-status": _view("gaming", "/api/budget"),
"gaming-reset": _job("gaming"),
"gaming-unblock": _job("gaming"),
"block-gaming": _job("gaming"),
"buy-dlc": _job("store"),
"unblock": _job("store"),
"reset": _job("system"),
"setup": _Entry("system", "screen", mutating=True),
# The daemon screen. Its only action is a rate-limited restart through
# the daemon; the 60-second demo runs as a job (params: demo=1).
"enforce": _Entry("system", "screen", mutating=True, privileged=True),
"serve": _Entry("system", "screen"),
}
def _friction_json(friction: Friction | None) -> dict[str, object] | None:
"""The contract's ``FrictionSpec``, or ``None``."""
if friction is None:
return None
out: dict[str, object] = {"phrase_template": friction.phrase_template}
if friction.countdown_seconds:
out["countdown_seconds"] = friction.countdown_seconds
return out
def _spec(
name: str, entry: _Entry, description: str, locked: str | None
) -> dict[str, object]:
"""One ``CommandSpec`` object."""
job = JOB_FLAGS.get(name) if entry.kind == "job" else None
spec: dict[str, object] = {
"name": name,
"description": description,
"category": entry.category,
"kind": entry.kind,
"params": [p.to_json() for p in PARAMS.get(name, ())],
"friction": _friction_json(FRICTION.get(name)),
"privileged": job.privileged if job else entry.privileged,
"mutating": job.mutating if job else entry.mutating,
"cancellable": job.cancellable if job else entry.cancellable,
"locked_reason": locked,
}
if entry.view_endpoint is not None:
spec["view_endpoint"] = entry.view_endpoint
return spec
def command_specs(
config: Config | None = None, state: State | None = None
) -> list[dict[str, object]]:
"""Build the ``CommandSpec[]`` payload, locks evaluated right now.
Args:
config: Loaded configuration (loaded here when omitted).
state: Loaded state (loaded here when omitted).
"""
config = config or Config.load()
state = state or State.load()
return [
_spec(
name,
entry,
ALL_COMMAND_DESCRIPTIONS[name],
lock_reason(name, config, state),
)
for name, entry in CATALOG.items()
]

View File

@ -0,0 +1,64 @@
"""Why a command may not run right now: the lock rules, side-effect free.
The pure half of the gate in :mod:`steam_backlog_enforcer.main._gate`: the
not-configured check, then the total gaming block, then the manual pick lock
— the order :func:`steam_backlog_enforcer.main.main` always used. It lives
outside ``main`` so the web server, which ``main`` itself loads (``main`` →
``misc`` → ``_web_server``), can ask it at import time without a cycle.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._actions import is_manual_pick_locked
from steam_backlog_enforcer._allowed_games import active_manual_picks
from steam_backlog_enforcer._command_locks import (
_MANUAL_LOCK_EXEMPT_COMMANDS,
_TOTAL_BLOCK_EXEMPT_COMMANDS,
)
from steam_backlog_enforcer._total_block import (
get_total_block_status,
is_total_block_active,
)
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config, State
# Usable before an API key exists: setup creates one, add-exception never
# talks to Steam.
UNCONFIGURED_OK: Final = frozenset({"setup", "add-exception"})
NOT_CONFIGURED_MSG: Final = "Not configured. Run 'setup' first."
def lock_reason(command: str, config: Config, state: State | None) -> str | None:
"""Say why *command* may not run right now, without printing anything.
Args:
command: Canonical command name (never raw argv).
config: Loaded configuration.
state: Loaded state; ``None`` skips the manual-pick check, for callers
that have no state yet.
Returns:
A one-line reason, or ``None`` when the command may run.
"""
if command not in UNCONFIGURED_OK and not config.steam_api_key:
return NOT_CONFIGURED_MSG
if is_total_block_active() and command not in _TOTAL_BLOCK_EXEMPT_COMMANDS:
days = get_total_block_status().days_remaining
allowed = ", ".join(sorted(_TOTAL_BLOCK_EXEMPT_COMMANDS))
return (
f"Total gaming block active ({days:.1f} day(s) left). Allowed: {allowed}."
)
if (
state is not None
and is_manual_pick_locked(state)
and command not in _MANUAL_LOCK_EXEMPT_COMMANDS
):
names = ", ".join(str(p["game_name"]) for p in active_manual_picks(state))
return (
f"Manual pick lock active ({names}): earn a new achievement first. "
f"Allowed: {', '.join(sorted(_MANUAL_LOCK_EXEMPT_COMMANDS))}."
)
return None

View File

@ -0,0 +1,50 @@
"""One-line descriptions of every CLI command, in help order.
The CLI's usage text and the web command catalog (``GET /api/commands``)
both read these. They live outside ``main`` so the catalog, which the web
server loads from inside ``main`` (``main`` → ``misc`` → ``_web_server``),
can import them at the top without a cycle.
"""
from __future__ import annotations
from typing import Final
from steam_backlog_enforcer._allowed_games import MANUAL_LOCK_DAYS
# Commands dispatched through ``main.COMMANDS`` (handler takes config, state).
COMMAND_DESCRIPTIONS: Final[dict[str, str]] = {
"scan": "Scan library & assign a game",
"check": "Check assigned game for a new achievement",
"status": "Show current status",
"list": "List games from snapshot",
"install": "Install the assigned game",
"hide": "Hide all non-assigned games in library",
"unhide": "Unhide all games in library",
"buy-dlc": "Unblock the store for 15 min to buy a game/DLC",
"reset": "Reset all state",
"installed": "List installed games",
"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",
"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)",
}
# Commands with non-standard arg handling (shown in help but not in COMMANDS).
EXTRA_COMMAND_DESCRIPTIONS: Final[dict[str, str]] = {
"add-exception": "Request 24h-locked whitelist exception (use --reason)",
"unblock": "Unblock the store for [minutes] (default 15, max 30)",
"serve": "Start the web UI (--port N; replaces a stale server)",
"pick-manual": f"Pick a game by app_id, lock enforcer for {MANUAL_LOCK_DAYS} days",
"abandon-pick": "Undo a manual pick at any time (needs app_id)",
"block-gaming": "Block ALL gaming for <days> days, no in-app undo",
"enforce": "Run enforcer: block, uninstall, kill, hide (--demo for a 60s budget)",
"gaming-unblock": "Force-release playtime bind mounts (root; recovery hatch)",
}
ALL_COMMAND_DESCRIPTIONS: Final[dict[str, str]] = (
COMMAND_DESCRIPTIONS | EXTRA_COMMAND_DESCRIPTIONS
)

View File

@ -0,0 +1,45 @@
"""Which commands stay usable under each lock.
A leaf module (no imports) so the CLI dispatcher in ``main._shared`` and the
root daemon's control socket consult ONE table: a lock the daemon enforced
differently from the CLI would be a way around it for anyone who can reach
the socket. ``main._shared`` cannot be imported by the daemon directly --
``main`` imports the enforce loop, which starts the control socket.
"""
from __future__ import annotations
# Commands that remain usable while the manual pick lock is active.
# Principle: only what is needed to release the lock (done/check) or
# that cannot change the game assignment (status, enforce, setup, serve).
_MANUAL_LOCK_EXEMPT_COMMANDS = frozenset(
{
"done",
"check",
"status",
"enforce",
"setup",
"serve",
"abandon-pick",
# Allowed so a second game can be locked in alongside the first; the
# cap inside cmd_pick_manual is what stops this being a way out.
"pick-manual",
# The daily gaming budget is orthogonal to which game is assigned, and
# gaming-unblock is a recovery hatch for a stuck bind mount - locking
# it behind a manual pick would leave Steam masked with no way back.
"gaming-status",
"gaming-unblock",
"gaming-reset",
}
)
# Commands that remain usable while a total gaming block is active. Far
# stricter than _MANUAL_LOCK_EXEMPT_COMMANDS: no done/pick/reset/
# add-exception - there is no in-app way to shorten a total block.
#
# gaming-unblock is included because a playtime bind mount makes the total
# block's own `pacman -R steam` fail EBUSY - it must stay reachable exactly
# when the two collide. gaming-reset is NOT included: it shortens enforcement.
_TOTAL_BLOCK_EXEMPT_COMMANDS = frozenset(
{"status", "enforce", "gaming-status", "gaming-unblock"}
)

View File

@ -0,0 +1,149 @@
"""Typed parameters of the commands the web UI can run.
The CLI parses ``sys.argv`` per command; the web sends a flat JSON object.
This table is the one description of that object, used twice: the command
catalog serves it to the UI as ``ParamSpec[]`` (so forms are server-driven),
and the job store validates every request against it before a job exists.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, Literal
from steam_backlog_enforcer._store_window import (
DEFAULT_WINDOW_MINUTES,
MAX_WINDOW_MINUTES,
)
if TYPE_CHECKING:
from collections.abc import Mapping
ParamType = Literal["int", "string", "app_id"]
ParamValue = int | str
@dataclass(frozen=True)
class ParamSpec:
"""One parameter, shaped like the TS ``ParamSpec`` contract type."""
name: str
label: str
type: ParamType
required: bool = True
min: int | None = None
max: int | None = None
default: ParamValue | None = None
help: str | None = None
def to_json(self) -> dict[str, object]:
"""Return the contract object, omitting unset optional keys."""
out: dict[str, object] = {
"name": self.name,
"label": self.label,
"type": self.type,
"required": self.required,
}
for key in ("min", "max", "default", "help"):
value = getattr(self, key)
if value is not None:
out[key] = value
return out
_APP_ID = ParamSpec("app_id", "Steam app id", "app_id", min=1)
PARAMS: Final[dict[str, tuple[ParamSpec, ...]]] = {
"pick-manual": (_APP_ID,),
"abandon-pick": (_APP_ID,),
"add-exception": (
_APP_ID,
ParamSpec(
"reason",
"Reason",
"string",
help="A genuine justification of at least 5 words.",
),
),
"block-gaming": (ParamSpec("days", "Days", "int", min=1),),
"unblock": (
ParamSpec(
"minutes",
"Minutes",
"int",
required=False,
min=1,
max=MAX_WINDOW_MINUTES,
default=DEFAULT_WINDOW_MINUTES,
),
),
# Only the 60-second demo budget runs as a job; restarting the real
# enforcer is the daemon's job.
"enforce": (
ParamSpec(
"demo",
"Demo mode (1 = 60-second budget)",
"int",
required=False,
min=0,
max=1,
default=0,
),
),
# Not a CLI command: POST /api/backups/{id}/restore runs it as a job.
"restore-backup": (ParamSpec("backup_id", "Backup id", "string"),),
}
class InvalidParamsError(ValueError):
"""A request's params do not match the command's :class:`ParamSpec` list."""
def _coerce(spec: ParamSpec, raw: object) -> ParamValue:
"""Convert one raw JSON value to the spec's type, enforcing its bounds."""
if spec.type == "string":
if not isinstance(raw, str) or not raw.strip():
msg = f"{spec.name} must be a non-empty string"
raise InvalidParamsError(msg)
return raw.strip()
# 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"
raise InvalidParamsError(msg)
try:
value = int(raw)
except ValueError as exc:
msg = f"{spec.name} must be a whole number, got {raw!r}"
raise InvalidParamsError(msg) from exc
if spec.min is not None and value < spec.min:
msg = f"{spec.name} must be at least {spec.min}"
raise InvalidParamsError(msg)
if spec.max is not None and value > spec.max:
msg = f"{spec.name} must be at most {spec.max}"
raise InvalidParamsError(msg)
return value
def validate_params(
command: str, params: Mapping[str, object]
) -> dict[str, ParamValue]:
"""Return *params* coerced to their declared types, defaults filled in.
Raises:
InvalidParamsError: On an unknown, missing or out-of-range param.
"""
specs = PARAMS.get(command, ())
unknown = set(params) - {spec.name for spec in specs}
if unknown:
msg = f"Unknown param(s) for {command}: {', '.join(sorted(unknown))}"
raise InvalidParamsError(msg)
clean: dict[str, ParamValue] = {}
for spec in specs:
if spec.name in params:
clean[spec.name] = _coerce(spec, params[spec.name])
elif spec.default is not None:
clean[spec.name] = spec.default
elif spec.required:
msg = f"Missing required param: {spec.name}"
raise InvalidParamsError(msg)
return clean

View File

@ -8,26 +8,44 @@ already treated it as its own unit.
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
import json
import sys import sys
from steam_backlog_enforcer import config as config_mod from steam_backlog_enforcer import config as config_mod
from steam_backlog_enforcer._prompter import current_prompter
from steam_backlog_enforcer.config import Config from steam_backlog_enforcer.config import Config
def interactive_setup() -> Config: def interactive_setup() -> Config:
"""Run first-time interactive setup.""" """Run first-time interactive setup."""
api_key = input("Enter your Steam Web API key: ").strip() prompter = current_prompter()
api_key = prompter.text("Enter your Steam Web API key")
if not api_key: if not api_key:
sys.exit(1) sys.exit(1)
steam_id = input("Enter your Steam64 ID: ").strip() steam_id = prompter.text("Enter your Steam64 ID")
if not steam_id: if not steam_id:
sys.exit(1) sys.exit(1)
config = Config(steam_api_key=api_key, steam_id=steam_id) return save_credentials(api_key, steam_id)
config.save()
def save_credentials(api_key: str, steam_id: str) -> Config:
"""Store *api_key* and *steam_id* in the config, owner-only.
The non-interactive half of setup, so the web setup screen stores the
credentials exactly the way the CLI does. Every other setting already in
the config is kept: re-entering a key must not reset the user's choices,
including keys this version does not model (hand-added or legacy ones).
"""
config = replace(Config.load(), steam_api_key=api_key, steam_id=steam_id)
# Read through the module, not an import-time binding: the path is # Read through the module, not an import-time binding: the path is
# redirected in tests, and the API key lives here — it must not become # redirected in tests, and the API key lives here — it must not become
# world-readable, nor be written to the real config during a test run. # world-readable, nor be written to the real config during a test run.
config_mod.CONFIG_FILE.chmod(0o600) path = config_mod.CONFIG_FILE
on_disk = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
merged = on_disk | config.__dict__
config_mod.atomic_write(path, json.dumps(merged, indent=2) + "\n")
path.chmod(0o600)
return config return config

View File

@ -0,0 +1,214 @@
"""Client for the root daemon's control socket (used by the web server).
One typed function per allowlisted op. A socket that is missing, refuses the
connection, times out or is closed without an answer (which is how the daemon
turns away a uid it does not know) raises :class:`DaemonUnreachableError`; a request
the daemon understood but refused raises :class:`CtlError` carrying the
contract's error code.
Imports nothing from the enforcer except the wire module, so the unprivileged
server can load it without pulling in the daemon's side effects.
"""
from __future__ import annotations
import socket
from typing import TYPE_CHECKING, Final, cast
from steam_backlog_enforcer._ctl_protocol import (
MAX_RESPONSE_BYTES,
SOCKET_PATH,
CtlError,
DaemonUnreachableError,
decode_line,
encode_line,
)
from steam_backlog_enforcer._ctl_types import (
BlockResult,
DaemonInfo,
PendingReset,
ResetResult,
StoreWindow,
)
if TYPE_CHECKING:
from pathlib import Path
# Most ops answer in well under a second; the ones that run privileged
# commands (iptables, pacman, guardctl) are given room, and every one may also
# wait up to a minute for the enforce loop's current pass to finish.
_DEFAULT_TIMEOUT: Final = 15.0
_SLOW_TIMEOUT: Final = 150.0
_BLOCK_TIMEOUT: Final = 600.0
def call(
op: str,
args: dict[str, object] | None = None,
*,
timeout: float = _DEFAULT_TIMEOUT,
path: Path = SOCKET_PATH,
) -> dict[str, object]:
"""Send one request and return the response's ``data``.
Args:
op: Op name.
args: Op arguments.
timeout: Seconds to wait for connect and for the reply.
path: Socket path (overridable for tests).
Returns:
The ``data`` object of a successful response.
Raises:
DaemonUnreachableError: No daemon answered on the socket.
CtlError: The daemon refused or failed the op.
"""
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
conn.settimeout(timeout)
try:
conn.connect(str(path))
conn.sendall(encode_line({"op": op, "args": args or {}}))
raw = _read_reply(conn)
except OSError as exc: # missing, refused, permission denied, timeout, reset
msg = f"control socket {path}: {exc}"
raise DaemonUnreachableError(msg) from exc
finally:
conn.close()
if not raw.strip():
msg = f"control socket {path} closed without answering (uid not permitted?)"
raise DaemonUnreachableError(msg)
return _unwrap(raw)
def _read_reply(conn: socket.socket) -> bytes:
"""Read until the daemon closes the connection, within the size cap."""
data = b""
while chunk := conn.recv(65536):
data += chunk
if len(data) > MAX_RESPONSE_BYTES:
msg = "control socket reply too large"
raise OSError(msg)
return data
def _unwrap(raw: bytes) -> dict[str, object]:
"""Decode a response line into its data, or raise its error."""
try:
reply = decode_line(raw.split(b"\n", 1)[0])
except ValueError as exc:
msg = "control socket sent a malformed reply"
raise DaemonUnreachableError(msg) from exc
if not isinstance(reply, dict):
msg = "control socket sent a malformed reply"
raise DaemonUnreachableError(msg)
if reply.get("ok") is True:
data = reply.get("data")
return cast("dict[str, object]", data) if isinstance(data, dict) else {}
extra = reply.get("data")
raise CtlError(
str(reply.get("error", "op_failed")),
str(reply.get("message", "")),
cast("dict[str, object]", extra) if isinstance(extra, dict) else None,
)
def _pending(data: dict[str, object]) -> PendingReset:
"""Build a :class:`PendingReset` from an arm/heartbeat reply."""
return PendingReset(
pending_id=str(data["pending_id"]),
phrase=str(data["phrase"]),
armed_at=str(data["armed_at"]),
ready_at=str(data["ready_at"]),
heartbeat_interval_seconds=int(cast("int", data["heartbeat_interval_seconds"])),
)
def ping() -> None:
"""Check the daemon answers. Raises :class:`DaemonUnreachableError` if not."""
call("ping")
def status() -> DaemonInfo:
"""The daemon's state, start time, pid and next-restart time."""
data = call("status")
until = data.get("restart_available_at")
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,
)
def journal_tail(lines: int = 50) -> list[str]:
"""The last *lines* (1-200) journal lines of the enforcer unit."""
data = call("journal_tail", {"lines": lines})
return [str(line) for line in cast("list[object]", data["lines"])]
def store_unblock(minutes: int, phrase: str) -> StoreWindow:
"""Open the store for *minutes* (1-30); *phrase* is checked by the daemon."""
data = call(
"store_unblock",
{"minutes": minutes, "phrase": phrase},
timeout=_SLOW_TIMEOUT,
)
return StoreWindow(
minutes=int(cast("int", data["minutes"])), until=str(data["until"])
)
def block_gaming(days: int, phrase: str) -> BlockResult:
"""Start a total gaming block for *days* days. Irreversible from the app."""
data = call(
"block_gaming", {"days": days, "phrase": phrase}, timeout=_BLOCK_TIMEOUT
)
until = data.get("until")
return BlockResult(
days=int(cast("int", data["days"])), until=str(until) if until else None
)
def gaming_unblock(phrase: str) -> list[str]:
"""Release every playtime mount; returns the targets released."""
data = call("gaming_unblock", {"phrase": phrase}, timeout=_SLOW_TIMEOUT)
return [str(path) for path in cast("list[object]", data["released"])]
def gaming_reset_arm(phrase: str) -> PendingReset:
"""Start the gaming-reset countdown (or join the one already running)."""
return _pending(call("gaming_reset_arm", {"phrase": phrase}))
def gaming_reset_heartbeat(pending_id: str) -> PendingReset:
"""Keep an armed reset alive; call at least every 10 s."""
return _pending(call("gaming_reset_heartbeat", {"pending_id": pending_id}))
def gaming_reset_commit(pending_id: str, phrase: str) -> ResetResult:
"""Run the reset once the countdown has finished."""
data = call(
"gaming_reset_commit",
{"pending_id": pending_id, "phrase": phrase},
timeout=_SLOW_TIMEOUT,
)
return ResetResult(
day_key=str(data["day_key"]),
seconds_before=float(cast("float", data["seconds_before"])),
was_blocked=bool(data["was_blocked"]),
released=[str(path) for path in cast("list[object]", data["released"])],
)
def gaming_reset_cancel(pending_id: str) -> None:
"""Abandon an armed reset."""
call("gaming_reset_cancel", {"pending_id": pending_id})
def restart() -> str | None:
"""Restart the daemon (rate limited); returns when the next one is allowed."""
data = call("restart")
until = data.get("restart_available_at")
return str(until) if until else None

View File

@ -0,0 +1,142 @@
"""What every control-socket op shares: the daemon's state and the guards.
The ops run on connection threads, the enforce loop on the main thread. The
``tick_lock`` is how they stay out of each other's way: the loop holds it for
one whole iteration and a mutating op holds it for its whole body, so an op
never sees (or clobbers) a half-finished tick. The loop only holds it while
working, never while sleeping between ticks, so an op waits at most one tick.
"""
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._actions import is_manual_pick_locked
from steam_backlog_enforcer._command_locks import (
_MANUAL_LOCK_EXEMPT_COMMANDS,
_TOTAL_BLOCK_EXEMPT_COMMANDS,
)
from steam_backlog_enforcer._ctl_pending import PendingResets
from steam_backlog_enforcer._ctl_protocol import (
BUSY,
INVALID_PARAMS,
LOCKED,
WRONG_PHRASE,
CtlError,
)
from steam_backlog_enforcer._friction import expected_phrase, phrase_matches
from steam_backlog_enforcer._total_block import is_total_block_active
from steam_backlog_enforcer.config import State
if TYPE_CHECKING:
from collections.abc import Iterator
from datetime import datetime
import threading
from steam_backlog_enforcer.config import Config
# Longer than any single enforce iteration should take; an op that cannot get
# the lock by then reports ``busy`` instead of hanging its connection.
LOCK_WAIT_SECONDS: Final = 60.0
@dataclass
class CtlContext:
"""Shared state for the control socket's ops.
Attributes:
config: The daemon's loaded configuration.
tick_lock: Held by the enforce loop for each iteration.
restart_event: Set by the ``restart`` op; the loop exits once it sees it.
started_at: When the daemon process started (UTC).
supervised: Whether systemd will bring the daemon back after an exit.
pending: Armed two-phase gaming resets.
"""
config: Config
tick_lock: threading.Lock
restart_event: threading.Event
started_at: datetime
supervised: bool
pending: PendingResets = field(default_factory=PendingResets)
@contextmanager
def hold_tick(ctx: CtlContext) -> Iterator[None]:
"""Hold the loop's lock for the body of a mutating op.
Raises:
CtlError: ``busy`` if the loop does not release the lock in time.
"""
if not ctx.tick_lock.acquire(timeout=LOCK_WAIT_SECONDS):
raise CtlError(BUSY, "the enforcer is mid-pass; try again in a moment")
try:
yield
finally:
ctx.tick_lock.release()
def require_phrase(command: str, typed: object, **fields: object) -> None:
"""Check the typed confirmation phrase for *command*.
Raises:
CtlError: ``invalid_params`` if *typed* is not text, ``wrong_phrase``
if it differs from the one ``_friction`` demands.
"""
if not isinstance(typed, str):
raise CtlError(INVALID_PARAMS, "phrase must be a string")
expected = expected_phrase(command, **fields)
if expected is None or not phrase_matches(expected, typed):
raise CtlError(WRONG_PHRASE, "the typed phrase does not match")
def int_arg(args: dict[str, object], name: str, low: int, high: int) -> int:
"""Read an integer argument within ``[low, high]``.
``bool`` is rejected explicitly: ``True`` is an ``int`` in Python and would
otherwise pass as ``1``.
Raises:
CtlError: ``invalid_params`` if absent, not an integer, or out of range.
"""
value = args.get(name)
if isinstance(value, bool) or not isinstance(value, int):
raise CtlError(INVALID_PARAMS, f"{name} must be an integer")
if not low <= value <= high:
raise CtlError(INVALID_PARAMS, f"{name} must be between {low} and {high}")
return value
def str_arg(args: dict[str, object], name: str) -> str:
"""Read a non-empty string argument.
Raises:
CtlError: ``invalid_params`` if absent or not a non-empty string.
"""
value = args.get(name)
if not isinstance(value, str) or not value:
raise CtlError(INVALID_PARAMS, f"{name} must be a non-empty string")
return value
def check_locks(command: str) -> None:
"""Refuse *command* while a lock the CLI would honour is in force.
Mirrors ``main._shared._enforce_total_block_lock`` and
``_enforce_manual_pick_lock`` using the same tables, because the socket
would otherwise be the one door those locks do not cover.
Args:
command: The CLI command name the op corresponds to.
Raises:
CtlError: ``locked`` if a total block or the manual-pick lock forbids it.
"""
if is_total_block_active() and command not in _TOTAL_BLOCK_EXEMPT_COMMANDS:
raise CtlError(LOCKED, "a total gaming block is active")
if command not in _MANUAL_LOCK_EXEMPT_COMMANDS and is_manual_pick_locked(
State.load()
):
raise CtlError(LOCKED, "a manual pick lock is active")

View File

@ -0,0 +1,117 @@
"""The enforce loop's handle on the control socket.
``start_control`` is the only thing the loop calls. It never raises and never
blocks the enforcer: if the socket cannot be served (not root, demo run, no
desktop user, bind failure) the loop gets an inert handle and carries on
exactly as before.
"""
from __future__ import annotations
from datetime import UTC, datetime
import logging
import os
import pwd
import threading
from typing import TYPE_CHECKING
from steam_backlog_enforcer._ctl_context import CtlContext
from steam_backlog_enforcer._ctl_gap import record_exit
from steam_backlog_enforcer._ctl_server import CtlServer
from steam_backlog_enforcer._desktop_env import resolve_desktop_user
from steam_backlog_enforcer._playtime import playtime_tick
if TYPE_CHECKING:
from steam_backlog_enforcer._playtime_session import PlaytimeSession
from steam_backlog_enforcer.config import Config
logger = logging.getLogger(__name__)
class DaemonControl:
"""Lock + restart flag shared with the control socket (inert if none)."""
def __init__(self, ctx: CtlContext, server: CtlServer | None) -> None:
"""Wrap the shared context and the (optional) running server.
Args:
ctx: Shared op context; owns the tick lock and restart event.
server: The running socket server, or ``None`` when inert.
"""
self.ctx = ctx
self._server = server
@property
def tick_lock(self) -> threading.Lock:
"""Held by the loop for each iteration so ops never interleave with one."""
return self.ctx.tick_lock
@property
def restart_requested(self) -> bool:
"""Whether the ``restart`` op has been accepted."""
return self.ctx.restart_event.is_set()
def flush_for_restart(
self, config: Config, session: PlaytimeSession, *, interval: float, demo: bool
) -> bool:
"""Bill the time up to now before the process exits for a restart.
Everything else the enforcer relies on is already on disk after each
tick (the budget state, the cutoff time, fired warnings, the store
window deadline); the only thing a restart can lose is the seconds
since the last tick, so one more accounting tick closes that gap. Call
with the tick lock held.
Returns:
Whether the loop should exit now.
"""
if not self.restart_requested:
return False
playtime_tick(config, interval=interval, session=session, demo=demo)
record_exit()
logger.warning("State flushed; exiting so systemd restarts the enforcer.")
return True
def close(self) -> None:
"""Stop serving and remove the socket file."""
if self._server is not None:
self._server.stop()
def _is_supervised() -> bool:
"""Whether systemd started this process (it sets ``INVOCATION_ID``)."""
return bool(os.environ.get("INVOCATION_ID"))
def start_control(config: Config, *, demo: bool) -> DaemonControl:
"""Start the control socket if this process is the real root daemon.
Args:
config: The daemon's configuration.
demo: Whether this is a ``--demo`` run (never serves: it could take the
socket from the real daemon).
Returns:
A handle; inert when the socket is not being served.
"""
ctx = CtlContext(
config=config,
tick_lock=threading.Lock(),
restart_event=threading.Event(),
started_at=datetime.now(UTC),
supervised=_is_supervised(),
)
if demo or os.geteuid() != 0:
return DaemonControl(ctx, None)
user = resolve_desktop_user()
try:
entry = pwd.getpwnam(user) if user else None
except KeyError:
entry = None
if entry is None or entry.pw_uid == 0:
# Fail closed: without a known unprivileged owner there is nobody the
# socket could safely be for.
logger.error("Control socket disabled: no desktop user (%r)", user)
return DaemonControl(ctx, None)
server = CtlServer(ctx, desktop_uid=entry.pw_uid, desktop_gid=entry.pw_gid)
return DaemonControl(ctx, server if server.start() else None)

View File

@ -0,0 +1,89 @@
"""Bill the downtime of a requested restart, so restarting is not free play.
The budget measures wall-clock deltas between ticks and clamps each one to two
tick intervals (so a suspend cannot inflate the count). A restart is the one
gap that clamp would wrongly forgive: ``RestartSec`` plus interpreter start plus
the startup pass can be tens of seconds, and the first tick would bill only 6 s
of it -- repeatable free play every ten minutes.
So the exiting daemon leaves a root-only marker with its exit time, and the
next daemon spends it on its first tick by widening that one tick's interval to
cover the gap. The marker is deleted before it is used, expires after
``MAX_GAP_SECONDS`` and only a *requested* restart writes one, so a crash or a
long outage never over-bills and the clamp still protects against suspends.
A tick with no qualifying process bills nothing whatever its interval.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
import time
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._playtime import playtime_tick
from steam_backlog_enforcer.config import _atomic_write
if TYPE_CHECKING:
from steam_backlog_enforcer._playtime_session import PlaytimeSession
from steam_backlog_enforcer.config import Config
logger = logging.getLogger(__name__)
# Beside the restart rate limit (StateDirectory=, root only).
RESTART_EXIT_FILE: Final = Path("/var/lib/steam-backlog-enforcer/ctl_restart_exit.json")
MAX_GAP_SECONDS: Final = 300.0
_MARKER_MODE: Final = 0o600
def record_exit() -> None:
"""Leave the exit-time marker for the next daemon. Never raises."""
try:
_atomic_write(
RESTART_EXIT_FILE,
json.dumps({"exited_at": time.time()}) + "\n",
mode=_MARKER_MODE,
)
except OSError:
logger.warning("Cannot record the restart exit time; the gap goes unbilled")
def _take_exit_time() -> float | None:
"""Read and delete the marker; ``None`` if absent or unusable."""
try:
raw = json.loads(RESTART_EXIT_FILE.read_text(encoding="utf-8"))
exited = raw["exited_at"]
except OSError, ValueError, KeyError, TypeError:
return None
finally:
RESTART_EXIT_FILE.unlink(missing_ok=True)
return float(exited) if isinstance(exited, int | float) else None
def settle_restart_gap(
config: Config,
session: PlaytimeSession,
*,
base_interval: float,
demo: bool,
) -> None:
"""Bill the time since a requested restart's exit, once.
Args:
config: Enforcer configuration.
session: The new daemon's logging session.
base_interval: The normal tick interval (lower bound for the widened one).
demo: Whether this is a demo run (never settles).
"""
exited = _take_exit_time()
if demo or exited is None:
return
gap = time.time() - exited
if not 0 < gap <= MAX_GAP_SECONDS:
return
logger.info("Billing %.0f s of restart downtime.", gap)
# accumulate() clamps a delta to 2 x interval; this admits exactly the gap.
playtime_tick(
config, interval=max(base_interval, gap / 2 + 1), session=session, demo=demo
)

View File

@ -0,0 +1,161 @@
"""The allowlisted ops of the control socket.
``OPS`` is the whole attack surface: a request naming anything else is
refused as an unknown command. There is no stop, no disable and no generic
command execution -- every op does one named, bounded thing. Each privileged
op re-checks its own typed phrase, argument bounds and the same locks the CLI
honours, because the web server in front of it is not trusted to have done so.
"""
from __future__ import annotations
import contextlib
import logging
import os
import subprocess
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._ctl_context import (
check_locks,
hold_tick,
int_arg,
require_phrase,
)
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._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 (
get_total_block_status,
start_total_block,
)
from steam_backlog_enforcer.config import CONFIG_DIR, STATE_FILE, State
if TYPE_CHECKING:
from collections.abc import Callable
from steam_backlog_enforcer._ctl_context import CtlContext
logger = logging.getLogger(__name__)
UNIT_NAME: Final = "steam-backlog-enforcer.service"
DEFAULT_JOURNAL_LINES: Final = 50
MAX_JOURNAL_LINES: Final = 200
_MAX_LINE_CHARS: Final = 500
_JOURNAL_TIMEOUT_SECONDS: Final = 5
_JOURNALCTL: Final = "/usr/bin/journalctl"
# A block is irreversible from the app; a typo like 3650 must not be one
# confirmation away. The phrase repeats the number, which is the other half.
MAX_BLOCK_DAYS: Final = 365
def op_ping(_ctx: CtlContext, _args: dict[str, object]) -> dict[str, object]:
"""Liveness probe."""
return {"pong": True}
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."""
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(),
}
def op_journal_tail(_ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""The last *lines* journal lines of the enforcer unit (capped)."""
lines = (
int_arg(args, "lines", 1, MAX_JOURNAL_LINES)
if "lines" in args
else DEFAULT_JOURNAL_LINES
)
try:
result = subprocess.run(
[
_JOURNALCTL,
"-u",
UNIT_NAME,
"-n",
str(lines),
"--no-pager",
"-q",
"-o",
"short-iso",
],
capture_output=True,
text=True,
errors="replace",
timeout=_JOURNAL_TIMEOUT_SECONDS,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
raise CtlError(OP_FAILED, f"journalctl failed: {exc}") from exc
return {
"lines": [line[:_MAX_LINE_CHARS] for line in result.stdout.splitlines()],
}
def _hand_back_state_file() -> None:
"""Give state.json back to the desktop user if root just created it.
``State.save`` keeps an existing file's owner, but a file that did not
exist yet comes out root-owned and would lock the unprivileged CLI (and
web server) out of its own state.
"""
with contextlib.suppress(OSError):
owner = CONFIG_DIR.stat()
if STATE_FILE.stat().st_uid != owner.st_uid:
os.chown(STATE_FILE, owner.st_uid, owner.st_gid)
def op_store_unblock(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Open the timed store window (1 to 30 minutes), as ``run.sh unblock`` does."""
minutes = int_arg(args, "minutes", 1, MAX_WINDOW_MINUTES)
require_phrase("unblock", args.get("phrase"), minutes=minutes)
with hold_tick(ctx):
check_locks("unblock")
try:
until = open_store_window(State.load(), minutes)
except RuntimeError as exc:
raise CtlError(OP_FAILED, str(exc)) from exc
finally:
_hand_back_state_file()
logger.warning("Store window opened for %d min (control socket)", minutes)
return {"minutes": minutes, "until": until.isoformat()}
def op_block_gaming(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Start a total gaming block of *days* days. There is no undo."""
days = int_arg(args, "days", 1, MAX_BLOCK_DAYS)
require_phrase("block-gaming", args.get("phrase"), days=days)
with hold_tick(ctx):
check_locks("block-gaming")
logger.warning("Total gaming block for %d day(s) requested (socket)", days)
if not start_total_block(days):
raise CtlError(OP_FAILED, "failed to engage the block (see the log)")
until = get_total_block_status().until
return {"days": days, "until": until.isoformat() if until else None}
def op_gaming_unblock(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Release every playtime mount (the recovery hatch)."""
require_phrase("gaming-unblock", args.get("phrase"))
with hold_tick(ctx):
check_locks("gaming-unblock")
released = release_block()
return {"released": [str(path) for path in released]}
OPS: Final[dict[str, Callable[[CtlContext, dict[str, object]], dict[str, object]]]] = {
"ping": op_ping,
"status": op_status,
"journal_tail": op_journal_tail,
"store_unblock": op_store_unblock,
"block_gaming": op_block_gaming,
"gaming_unblock": op_gaming_unblock,
**RESET_OPS,
**RESTART_OPS,
}

View File

@ -0,0 +1,182 @@
"""The two-phase gaming reset: arm, keep alive for 300 s, then commit.
The friction a typed phrase cannot give is *time*. Arming starts a countdown;
the caller must prove it is still there by heartbeating at least every
``HEARTBEAT_INTERVAL_SECONDS`` (the daemon tolerates ``LAPSE_AFTER_SECONDS``
of silence); only after ``ready_at`` may the reset commit. A closed tab, a dead
server or a crashed browser therefore cancels the reset instead of leaving it
armed for whoever shows up next.
Held in daemon memory on purpose: a daemon restart drops every pending reset,
which fails closed -- the caller just arms again and waits the full countdown.
Intervals use the monotonic clock so a wall-clock step cannot shorten the wait.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import os
import secrets
import threading
import time
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._ctl_protocol import (
COUNTDOWN_RUNNING,
PENDING_LAPSED,
CtlError,
)
from steam_backlog_enforcer._friction import GAMING_RESET_COUNTDOWN_SECONDS
if TYPE_CHECKING:
from collections.abc import Callable
HEARTBEAT_INTERVAL_SECONDS: Final = 10
LAPSE_AFTER_SECONDS: Final = 15
# TEST ONLY. Lets the disposable-VM verification run the flow in seconds. It can
# only SHORTEN the countdown (1..299 s), never lengthen it, and an unset or
# invalid value means the real 300 s. It lives in the root-owned unit's
# environment; the unprivileged caller cannot set it.
TEST_COUNTDOWN_ENV: Final = "SBE_CTL_TEST_COUNTDOWN_SECONDS"
def countdown_seconds() -> int:
"""The countdown to enforce: 300 s unless the VM-only override shortens it."""
raw = os.environ.get(TEST_COUNTDOWN_ENV, "")
if raw.isdecimal() and 0 < int(raw) < GAMING_RESET_COUNTDOWN_SECONDS:
return int(raw)
return GAMING_RESET_COUNTDOWN_SECONDS
def _same(left: str, right: str) -> bool:
"""Constant-time id comparison (bytes, so a non-ASCII id cannot raise)."""
return secrets.compare_digest(left.encode(), right.encode())
@dataclass(frozen=True)
class Pending:
"""One armed reset.
Attributes:
pending_id: Unguessable handle the caller presents on every later call.
armed_at: Wall-clock arm time, for display.
ready_at: Wall-clock time the commit becomes allowed, for display.
ready_mono: Monotonic deadline the commit is actually checked against.
last_beat_mono: Monotonic time of the latest heartbeat (arm counts).
"""
pending_id: str
armed_at: datetime
ready_at: datetime
ready_mono: float
last_beat_mono: float
class PendingResets:
"""At most one armed reset, with its countdown and heartbeat bookkeeping."""
def __init__(
self,
clock: Callable[[], float] = time.monotonic,
wall: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> None:
"""Start with nothing armed.
Args:
clock: Monotonic seconds source (injectable for tests).
wall: Wall-clock source for the display timestamps.
"""
self._clock = clock
self._wall = wall
self._lock = threading.Lock()
self._current: Pending | None = None
def arm(self) -> Pending:
"""Arm a reset, or return the live one without restarting its countdown.
Returning the existing one (rather than refusing or restarting) means a
second arm -- a reloaded page, a retry -- can neither shorten nor
extend the wait.
"""
with self._lock:
live = self._live()
if live is not None:
return live
now = self._clock()
wall = self._wall()
wait = countdown_seconds()
self._current = Pending(
pending_id=secrets.token_hex(16),
armed_at=wall,
ready_at=wall + timedelta(seconds=wait),
ready_mono=now + wait,
last_beat_mono=now,
)
return self._current
def beat(self, pending_id: str) -> Pending:
"""Record a heartbeat.
Raises:
CtlError: ``pending_lapsed`` if the id is unknown or already lapsed.
"""
with self._lock:
current = self._require(pending_id)
self._current = Pending(
current.pending_id,
current.armed_at,
current.ready_at,
current.ready_mono,
self._clock(),
)
return self._current
def check_ready(self, pending_id: str) -> Pending:
"""Return the pending reset if it may commit now.
Raises:
CtlError: ``pending_lapsed`` if unknown or lapsed, or
``countdown_running`` (with ``retry_after_seconds``) if early.
"""
with self._lock:
current = self._require(pending_id)
remaining = current.ready_mono - self._clock()
if remaining > 0:
raise CtlError(
COUNTDOWN_RUNNING,
f"the countdown has {remaining:.0f} s left",
{"retry_after_seconds": round(remaining, 1)},
)
return current
def finish(self, pending_id: str) -> None:
"""Drop the pending reset after it committed or was cancelled.
Idempotent: an id that is not (or no longer) armed is not an error.
"""
with self._lock:
current = self._current
if current is not None and _same(current.pending_id, pending_id):
self._current = None
def _live(self) -> Pending | None:
"""The armed reset, or ``None`` -- dropping it first if it has lapsed."""
current = self._current
if current is not None and self._clock() - current.last_beat_mono > (
LAPSE_AFTER_SECONDS
):
self._current = None
return None
return current
def _require(self, pending_id: str) -> Pending:
"""The live reset for *pending_id*; the lock must be held."""
current = self._live()
if current is None or not _same(current.pending_id, pending_id):
raise CtlError(
PENDING_LAPSED,
"no such pending reset, or it lapsed: arm it again",
)
return current

View File

@ -0,0 +1,95 @@
"""Wire format shared by the daemon's control socket and its client.
One JSON object per line in, one per line out, then the connection closes.
Kept free of every enforcer import so the unprivileged web server can load the
client without dragging the root daemon's modules (and their side effects) in.
Error codes are the contract's (``DOCS-web-control-api.md``) plus two that only
the daemon can raise: ``op_failed`` (the privileged action ran and failed) and
``unsupported`` (the daemon is not supervised, so a restart would be a stop).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Final
SOCKET_PATH: Final = Path("/run/steam-backlog-enforcer/ctl.sock")
# A request is a few short fields; anything bigger is a mistake or an attack.
MAX_REQUEST_BYTES: Final = 4096
# The longest legitimate response is journal_tail at its cap.
MAX_RESPONSE_BYTES: Final = 1024 * 1024
# Error codes.
WRONG_PHRASE: Final = "wrong_phrase"
INVALID_PARAMS: Final = "invalid_params"
UNKNOWN_OP: Final = "unknown_command"
LOCKED: Final = "locked"
BUSY: Final = "busy"
COUNTDOWN_RUNNING: Final = "countdown_running"
PENDING_LAPSED: Final = "pending_lapsed"
RATE_LIMITED: Final = "rate_limited"
OP_FAILED: Final = "op_failed"
UNSUPPORTED: Final = "unsupported"
class CtlError(Exception):
"""A request the daemon refused or could not carry out.
Attributes:
code: Machine-readable error code (see the constants above).
message: Human-readable text, safe to show in the UI.
data: Extra fields for the caller (e.g. ``retry_after_seconds``).
"""
def __init__(
self, code: str, message: str, data: dict[str, object] | None = None
) -> None:
"""Build the error.
Args:
code: Error code.
message: Human-readable text.
data: Optional extra fields carried alongside the error.
"""
super().__init__(f"{code}: {message}")
self.code = code
self.message = message
self.data = data or {}
class DaemonUnreachableError(Exception):
"""The control socket is missing, refused us, or closed without answering."""
def encode_line(obj: dict[str, object]) -> bytes:
"""Serialise *obj* as one newline-terminated JSON line."""
return json.dumps(obj, separators=(",", ":")).encode("utf-8") + b"\n"
def decode_line(raw: bytes) -> object:
"""Parse one JSON line.
Raises:
ValueError: If *raw* is not valid UTF-8 JSON.
"""
return json.loads(raw.decode("utf-8"))
def ok_response(data: dict[str, object]) -> bytes:
"""The success line for *data*."""
return encode_line({"ok": True, "data": data})
def error_response(error: CtlError) -> bytes:
"""The failure line for *error*."""
body: dict[str, object] = {
"ok": False,
"error": error.code,
"message": error.message,
}
if error.data:
body["data"] = error.data
return encode_line(body)

View File

@ -0,0 +1,104 @@
"""The two-phase gaming reset as control-socket ops.
``gaming_reset_arm`` -> (``gaming_reset_heartbeat`` every <= 10 s for the whole
countdown) -> ``gaming_reset_commit``. The phrase is checked at arm *and*
again at commit, the countdown and heartbeat bookkeeping live in
:mod:`steam_backlog_enforcer._ctl_pending`, and the reset itself is the same
core the CLI runs (:func:`steam_backlog_enforcer._gaming_reset.reset_today`).
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._ctl_context import (
check_locks,
hold_tick,
require_phrase,
str_arg,
)
from steam_backlog_enforcer._ctl_pending import (
HEARTBEAT_INTERVAL_SECONDS,
LAPSE_AFTER_SECONDS,
)
from steam_backlog_enforcer._ctl_protocol import OP_FAILED, CtlError
from steam_backlog_enforcer._friction import expected_phrase
from steam_backlog_enforcer._gaming_reset import reset_today
if TYPE_CHECKING:
from steam_backlog_enforcer._ctl_context import CtlContext
from steam_backlog_enforcer._ctl_pending import Pending
logger = logging.getLogger(__name__)
_COMMAND: Final = "gaming-reset"
def _view(pending: Pending) -> dict[str, object]:
"""The wire form of a pending reset (the contract's ``PendingAction``)."""
return {
"pending_id": pending.pending_id,
"phrase": expected_phrase(_COMMAND),
"armed_at": pending.armed_at.isoformat(),
"ready_at": pending.ready_at.isoformat(),
"heartbeat_interval_seconds": HEARTBEAT_INTERVAL_SECONDS,
"lapse_after_seconds": LAPSE_AFTER_SECONDS,
}
def op_arm(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Start the countdown (or return the one already running)."""
require_phrase(_COMMAND, args.get("phrase"))
check_locks(_COMMAND)
pending = ctx.pending.arm()
logger.warning("Gaming reset armed; ready at %s", pending.ready_at.isoformat())
return _view(pending)
def op_heartbeat(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Keep the armed reset alive."""
return _view(ctx.pending.beat(str_arg(args, "pending_id")))
def op_commit(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Run the reset, if the countdown finished and the phrase still matches."""
pending_id = str_arg(args, "pending_id")
ctx.pending.check_ready(pending_id)
require_phrase(_COMMAND, args.get("phrase"))
with hold_tick(ctx):
# Authoritative re-check under the lock: of two racing commits only
# the first finds the pending reset still there.
ctx.pending.check_ready(pending_id)
check_locks(_COMMAND)
try:
outcome = reset_today(source="ctl")
except OSError as exc:
logger.exception("Gaming reset failed")
raise CtlError(OP_FAILED, f"the reset failed: {exc}") from exc
ctx.pending.finish(pending_id)
logger.warning(
"Gaming reset committed: %.0f s billed before, %d mount(s) released",
outcome.seconds_before,
len(outcome.released),
)
return {
"day_key": outcome.day_key,
"seconds_before": round(outcome.seconds_before, 3),
"was_blocked": outcome.was_blocked,
"released": [str(path) for path in outcome.released],
}
def op_cancel(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Abandon the armed reset (idempotent)."""
ctx.pending.finish(str_arg(args, "pending_id"))
return {"cancelled": True}
RESET_OPS: Final = {
"gaming_reset_arm": op_arm,
"gaming_reset_heartbeat": op_heartbeat,
"gaming_reset_commit": op_commit,
"gaming_reset_cancel": op_cancel,
}

View File

@ -0,0 +1,109 @@
"""The ``restart`` op: the only way the socket ever ends the daemon process.
There is deliberately no stop. A restart asks the enforce loop to finish the
pass it is in, bill the time up to now, and exit; ``Restart=always`` in the
unit brings it straight back. Because that makes a restart cost a few seconds
of downtime (and a fresh startup pass), it is rate limited to one per ten
minutes, and the limit is persisted in a root-owned file so restarting the
daemon cannot reset it.
Safety analysis (what a restart does and does not lose, and how the downtime is
billed) is in ``DOCS-ctl-socket.md``.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import json
import logging
from pathlib import Path
import threading
import time
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._ctl_protocol import (
OP_FAILED,
RATE_LIMITED,
UNSUPPORTED,
CtlError,
)
from steam_backlog_enforcer.config import _atomic_write
if TYPE_CHECKING:
from steam_backlog_enforcer._ctl_context import CtlContext
logger = logging.getLogger(__name__)
RESTART_INTERVAL_SECONDS: Final = 600
# Created by StateDirectory=steam-backlog-enforcer in the unit.
RESTART_STATE_FILE: Final = Path("/var/lib/steam-backlog-enforcer/ctl_restart.json")
_STATE_MODE: Final = 0o600
# Serialises concurrent restart requests so two cannot both pass the check.
_restart_lock = threading.Lock()
def _last_restart() -> float:
"""Epoch seconds of the last accepted restart; ``0.0`` if none or unreadable."""
try:
raw = json.loads(RESTART_STATE_FILE.read_text(encoding="utf-8"))
value = raw["last_restart_at"]
except OSError, ValueError, KeyError, TypeError:
return 0.0
return float(value) if isinstance(value, int | float) else 0.0
def seconds_until_available() -> float:
"""Seconds before another restart is allowed; ``0`` if one is allowed now.
A recorded time in the future (clock stepped backwards) counts as "now", so
the wait can never exceed the interval.
"""
elapsed = max(0.0, time.time() - _last_restart())
return max(0.0, RESTART_INTERVAL_SECONDS - elapsed)
def restart_available_at() -> str | None:
"""ISO time a restart becomes allowed again, or ``None`` if allowed now."""
wait = seconds_until_available()
if wait <= 0:
return None
return (datetime.now(UTC) + timedelta(seconds=wait)).isoformat()
def op_restart(ctx: CtlContext, _args: dict[str, object]) -> dict[str, object]:
"""Ask the daemon to restart itself (flush, exit, systemd brings it back).
Raises:
CtlError: ``unsupported`` when systemd is not supervising the process
(the exit would be a stop), ``rate_limited`` inside the interval,
``op_failed`` if the limit could not be persisted (fail closed).
"""
if not ctx.supervised:
raise CtlError(
UNSUPPORTED,
"the daemon is not running under systemd; a restart would be a stop",
)
with _restart_lock:
wait = seconds_until_available()
if wait > 0:
raise CtlError(
RATE_LIMITED,
f"restart allowed again in {wait:.0f} s",
{"retry_after_seconds": round(wait, 1)},
)
try:
_atomic_write(
RESTART_STATE_FILE,
json.dumps({"last_restart_at": time.time()}) + "\n",
mode=_STATE_MODE,
)
except OSError as exc:
logger.exception("Cannot persist the restart rate limit")
raise CtlError(OP_FAILED, "cannot record the restart; refusing") from exc
logger.warning("Restart requested over the control socket.")
ctx.restart_event.set()
return {"restarting": True, "restart_available_at": restart_available_at()}
RESTART_OPS: Final = {"restart": op_restart}

View File

@ -0,0 +1,204 @@
"""The control socket server: who may talk to the daemon, and how.
A Unix socket at ``/run/steam-backlog-enforcer/ctl.sock``, mode 0660, group =
the desktop user's primary group. Filesystem permissions are the first gate;
``SO_PEERCRED`` is the second and the one that counts: the kernel reports the
connecting process's real uid, and anything but the desktop user is closed
without a byte of reply.
Root is admitted too. Root can already do everything the ops do (``sudo
./run.sh gaming-reset``, ``guardctl``), so refusing it would protect nothing;
admitting it lets recovery and tests use the same interface.
The server must never be the thing that takes the enforcer down or wedges it:
every connection has a read deadline and a size cap, concurrency is bounded,
each connection runs on its own thread, and no exception escapes a handler.
"""
from __future__ import annotations
import logging
import os
import socket
import threading
import time
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._ctl_ops import OPS
from steam_backlog_enforcer._ctl_protocol import (
INVALID_PARAMS,
MAX_REQUEST_BYTES,
OP_FAILED,
SOCKET_PATH,
UNKNOWN_OP,
CtlError,
error_response,
ok_response,
)
from steam_backlog_enforcer._ctl_wire import (
parse_request,
peer_uid,
read_line,
socket_is_live,
)
if TYPE_CHECKING:
from pathlib import Path
from steam_backlog_enforcer._ctl_context import CtlContext
logger = logging.getLogger(__name__)
SOCKET_MODE: Final = 0o660
WRITE_TIMEOUT_SECONDS: Final = 5.0
MAX_CONNECTIONS: Final = 8
_BACKLOG: Final = 8
_ACCEPT_POLL_SECONDS: Final = 1.0
_ROOT_UID: Final = 0
class CtlServer:
"""Accepts, authenticates and dispatches control-socket connections."""
def __init__(
self,
ctx: CtlContext,
*,
desktop_uid: int,
desktop_gid: int,
path: Path = SOCKET_PATH,
) -> None:
"""Prepare a server; nothing is bound until :meth:`start`.
Args:
ctx: Shared op context.
desktop_uid: The one non-root uid allowed to connect.
desktop_gid: Group that owns the socket file.
path: Socket path.
"""
self._ctx = ctx
self._allowed = frozenset({desktop_uid, _ROOT_UID})
self._gid = desktop_gid
self._path = path
self._stop = threading.Event()
self._slots = threading.BoundedSemaphore(MAX_CONNECTIONS)
self._sock: socket.socket | None = None
def start(self) -> bool:
"""Bind the socket and start accepting. Never raises.
Returns:
Whether the server is now listening.
"""
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
if socket_is_live(self._path):
logger.error(
"Control socket %s is already served; not taking over", self._path
)
return False
self._path.unlink(missing_ok=True)
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(str(self._path))
# Between bind() and here the file is root-owned 0755: nobody else
# has write permission, so nobody else can connect yet.
os.chown(self._path, 0, self._gid)
self._path.chmod(SOCKET_MODE)
sock.listen(_BACKLOG)
sock.settimeout(_ACCEPT_POLL_SECONDS)
except OSError:
logger.exception("Cannot start the control socket at %s", self._path)
return False
self._sock = sock
threading.Thread(target=self._serve, name="ctl-accept", daemon=True).start()
logger.info("Control socket listening on %s", self._path)
return True
def stop(self) -> None:
"""Stop accepting and remove the socket file."""
self._stop.set()
if self._sock is not None:
self._sock.close()
self._sock = None
self._path.unlink(missing_ok=True)
def _serve(self) -> None:
"""Accept loop; survives anything but being told to stop."""
while not self._stop.is_set():
sock = self._sock
if sock is None:
return
try:
conn, _ = sock.accept()
except TimeoutError:
continue
except OSError:
if self._stop.is_set():
return
logger.exception("Control socket accept failed")
time.sleep(_ACCEPT_POLL_SECONDS)
continue
self._admit(conn)
def _admit(self, conn: socket.socket) -> None:
"""Authenticate *conn* and hand it to a worker, or close it silently."""
try:
uid = peer_uid(conn)
except OSError:
conn.close()
return
if uid not in self._allowed:
logger.warning("Control socket: refused a connection from uid %d", uid)
conn.close()
return
if not self._slots.acquire(blocking=False):
logger.warning("Control socket: too many connections, dropping one")
conn.close()
return
threading.Thread(
target=self._handle, args=(conn, uid), name="ctl-conn", daemon=True
).start()
def _handle(self, conn: socket.socket, uid: int) -> None:
"""Serve one connection, always releasing its slot and socket."""
try:
line = read_line(conn)
if line is not None:
self._write(conn, self._dispatch(line, uid))
except Exception:
logger.debug("Control connection dropped", exc_info=True)
finally:
conn.close()
self._slots.release()
@staticmethod
def _write(conn: socket.socket, payload: bytes) -> None:
"""Send the response within the write deadline."""
conn.settimeout(WRITE_TIMEOUT_SECONDS)
conn.sendall(payload)
def _dispatch(self, line: bytes, uid: int) -> bytes:
"""Turn a request line into a response line. Never raises."""
try:
return ok_response(self._run(line, uid))
except CtlError as exc:
logger.info("Control socket: refused (%s)", exc.code)
return error_response(exc)
except Exception:
logger.exception("Control socket op crashed")
return error_response(CtlError(OP_FAILED, "internal error"))
def _run(self, line: bytes, uid: int) -> dict[str, object]:
"""Validate a request line and run its op.
Raises:
CtlError: For a malformed request, an unknown op, or a refused op.
"""
if len(line) > MAX_REQUEST_BYTES:
raise CtlError(INVALID_PARAMS, "request too large")
op, args = parse_request(line)
handler = OPS.get(op)
if handler is None:
raise CtlError(UNKNOWN_OP, f"unknown op {op!r}")
logger.info("Control socket: op=%s uid=%d", op, uid)
return handler(self._ctx, args)

View File

@ -0,0 +1,56 @@
"""Result types returned by :mod:`steam_backlog_enforcer._ctl_client`.
Plain frozen dataclasses so the web server maps them onto its contract types
without ever handling the daemon's raw JSON.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class DaemonInfo:
"""The daemon's own account of itself (``status``)."""
state: str
started_at: str
pid: int
restart_available_at: str | None
@dataclass(frozen=True)
class StoreWindow:
"""An opened store window."""
minutes: int
until: str
@dataclass(frozen=True)
class BlockResult:
"""A started total gaming block."""
days: int
until: str | None
@dataclass(frozen=True)
class PendingReset:
"""An armed two-phase gaming reset (maps onto the contract's PendingAction)."""
pending_id: str
phrase: str
armed_at: str
ready_at: str
heartbeat_interval_seconds: int
@dataclass(frozen=True)
class ResetResult:
"""A committed gaming reset."""
day_key: str
seconds_before: float
was_blocked: bool
released: list[str]

View File

@ -0,0 +1,89 @@
"""Server-side wire helpers for the control socket.
Everything that touches the raw connection before an op runs: who is on the
other end, whether anything already serves the path, and reading/validating one
request line under a deadline and a size cap.
"""
from __future__ import annotations
import socket
import struct
import time
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._ctl_protocol import (
INVALID_PARAMS,
MAX_REQUEST_BYTES,
CtlError,
decode_line,
)
if TYPE_CHECKING:
from pathlib import Path
READ_TIMEOUT_SECONDS: Final = 5.0
_PEERCRED: Final = struct.Struct("3i") # struct ucred: pid, uid, gid
def peer_uid(conn: socket.socket) -> int:
"""The kernel-reported uid of the process on the other end of *conn*."""
raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, _PEERCRED.size)
return int(_PEERCRED.unpack(raw)[1])
def socket_is_live(path: Path) -> bool:
"""Whether something is already accepting connections on *path*."""
probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
probe.settimeout(1.0)
try:
probe.connect(str(path))
except OSError:
return False
finally:
probe.close()
return True
def read_line(conn: socket.socket) -> bytes | None:
"""Read one request line within the deadline and size cap.
Returns:
The line, or ``None`` if the client sent nothing or stalled.
"""
deadline = time.monotonic() + READ_TIMEOUT_SECONDS
data = b""
while b"\n" not in data:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
conn.settimeout(remaining)
try:
chunk = conn.recv(1024)
except TimeoutError:
return None
if not chunk:
break
data += chunk
if len(data) > MAX_REQUEST_BYTES:
return data[: MAX_REQUEST_BYTES + 1]
return data.split(b"\n", 1)[0] if data else None
def parse_request(line: bytes) -> tuple[str, dict[str, object]]:
"""Decode a request into ``(op, args)``.
Raises:
CtlError: ``invalid_params`` for anything that is not
``{"op": str, "args": object?}``.
"""
try:
request = decode_line(line)
except ValueError as exc:
raise CtlError(INVALID_PARAMS, "request is not valid JSON") from exc
if not isinstance(request, dict) or not isinstance(request.get("op"), str):
raise CtlError(INVALID_PARAMS, 'request must be {"op": "<name>", ...}')
args = request.get("args", {})
if not isinstance(args, dict):
raise CtlError(INVALID_PARAMS, "args must be an object")
return request["op"], args

View File

@ -1,26 +1,55 @@
"""User-facing CLI output. """User-facing CLI output.
A one-function leaf module so every command can print without importing the A leaf module so every command can print without importing the install
install machinery. Split out of :mod:`steam_backlog_enforcer.game_install` to machinery. Split out of :mod:`steam_backlog_enforcer.game_install` to keep
keep that module under the 250-line cap. that module under the 250-line cap.
Output normally goes to stdout. A web job installs a sink with
:func:`routed_echo`, and every ``_echo`` inside it becomes a ``log`` event
instead: the commands keep one way of talking, whoever is listening.
""" """
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
import sys import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
# Receives the raw text (message + end) when set; stdout otherwise.
_SINK: ContextVar[Callable[[str], None] | None] = ContextVar("echo_sink", default=None)
def _echo(msg: str = "", *, end: str = "\n", flush: bool = False) -> None: def _echo(msg: str = "", *, end: str = "\n", flush: bool = False) -> None:
"""Write user-facing CLI output to stdout. """Write user-facing CLI output to stdout, or to the active sink.
Args: Args:
msg: Text to output. msg: Text to output.
end: String appended after the message. end: String appended after the message.
flush: Whether to flush stdout immediately. flush: Whether to flush stdout immediately.
""" """
sink = _SINK.get()
if sink is not None:
sink(msg + end)
return
sys.stdout.write(msg + end) sys.stdout.write(msg + end)
if flush: if flush:
sys.stdout.flush() sys.stdout.flush()
# Steam infrastructure app IDs that should NEVER be uninstalled. @contextmanager
def routed_echo(sink: Callable[[str], None]) -> Iterator[None]:
"""Send every ``_echo`` in this context to *sink* instead of stdout.
Args:
sink: Called with the raw text, ``end`` included, so it can treat
carriage returns and newlines the way a terminal would.
"""
token = _SINK.set(sink)
try:
yield
finally:
_SINK.reset(token)

View File

@ -0,0 +1,68 @@
"""Per-pass guards of the enforce loop: who may stay installed, and who is named.
Split out of :mod:`steam_backlog_enforcer._enforce_loop` to keep it under the
250-line cap once it also had to drive the control socket.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from steam_backlog_enforcer._actions import allowed_games
from steam_backlog_enforcer.enforcer import send_notification
from steam_backlog_enforcer.game_uninstall import (
get_installed_games,
is_protected_app,
uninstall_game,
)
if TYPE_CHECKING:
from steam_backlog_enforcer.config import State
logger = logging.getLogger(__name__)
def allowed_names(state: State) -> str:
"""Return a human-readable list of the games the user may play.
Args:
state: Current enforcer state.
Returns:
Comma-separated game names, or "your assigned game" when none is set.
"""
names = [name for _, name in allowed_games(state) if name]
return ", ".join(names) if names else "your assigned game"
def guard_installed_games(allowed: set[int]) -> int:
"""Remove any unauthorized game manifests + files. Runs every loop.
Args:
allowed: Every app id that may stay installed — the assignment plus
any concurrent manual picks.
Returns number of games removed this pass.
"""
if not allowed:
return 0
installed = get_installed_games()
count = 0
for app_id, name in installed:
if app_id in allowed:
continue
if is_protected_app(app_id):
continue
logger.warning(
"Unauthorized game detected — removing: %s (AppID=%d)", name, app_id
)
if uninstall_game(app_id, name):
count += 1
send_notification(
"Game Removed!",
f"Uninstalled {name} (AppID={app_id}). "
f"Only your assigned game(s) are allowed.",
)
return count

View File

@ -5,8 +5,11 @@ from __future__ import annotations
import json import json
import logging import logging
from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games from steam_backlog_enforcer._actions import allowed_app_ids
from steam_backlog_enforcer._ctl_control import start_control
from steam_backlog_enforcer._ctl_gap import settle_restart_gap
from steam_backlog_enforcer._echo import _echo from steam_backlog_enforcer._echo import _echo
from steam_backlog_enforcer._enforce_guards import allowed_names, guard_installed_games
from steam_backlog_enforcer._enforce_steps import ( from steam_backlog_enforcer._enforce_steps import (
_enforce_setup, _enforce_setup,
_reinstall_missing_allowed, _reinstall_missing_allowed,
@ -40,11 +43,6 @@ from steam_backlog_enforcer.enforcer import (
enforce_allowed_game, enforce_allowed_game,
send_notification, send_notification,
) )
from steam_backlog_enforcer.game_uninstall import (
get_installed_games,
is_protected_app,
uninstall_game,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -58,49 +56,32 @@ __all__ = [
] ]
def _allowed_names(state: State) -> str: def _reload_state(state: State) -> bool:
"""Return a human-readable list of the games the user may play. """Refresh *state* in place from disk, so CLI changes take effect at once.
Covers e.g. a new game assignment via ``done`` / ``scan`` without needing
to restart the daemon.
Args: Args:
state: Current enforcer state. state: The daemon's state object, updated in place.
Returns: Returns:
Comma-separated game names, or "your assigned game" when none is set. False (after logging) if the file could not be read; *state* is then
left as it was.
""" """
names = [name for _, name in allowed_games(state) if name] try:
return ", ".join(names) if names else "your assigned game" fresh = State.load()
except (json.JSONDecodeError, OSError, ValueError) as exc:
logger.warning("Failed to reload state: %s", exc)
def _guard_installed_games(allowed: set[int]) -> int: return False
"""Remove any unauthorized game manifests + files. Runs every loop. # Every field, not a hand-picked few: the daemon's pick sweep saves this
# object, and any field left stale here (cooldowns, pick release times,
Args: # assignment times) would overwrite what the CLI or the MCP server wrote.
allowed: Every app id that may stay installed — the assignment plus # The MCP pick_manual tool also adds a *second* pick without touching
any concurrent manual picks. # current_app_id, which a daemon that never reloaded manual_picks would
# uninstall.
Returns number of games removed this pass. vars(state).update(vars(fresh))
""" return True
if not allowed:
return 0
installed = get_installed_games()
count = 0
for app_id, name in installed:
if app_id in allowed:
continue
if is_protected_app(app_id):
continue
logger.warning(
"Unauthorized game detected — removing: %s (AppID=%d)", name, app_id
)
if uninstall_game(app_id, name):
count += 1
send_notification(
"Game Removed!",
f"Uninstalled {name} (AppID={app_id}). "
f"Only your assigned game(s) are allowed.",
)
return count
def _enforce_loop_iteration( def _enforce_loop_iteration(
@ -164,12 +145,12 @@ def _enforce_loop_iteration(
send_notification( send_notification(
"Game Blocked!", "Game Blocked!",
f"Killed unauthorized game (AppID={app_id}). " f"Killed unauthorized game (AppID={app_id}). "
f"Focus on {_allowed_names(state)}!", f"Focus on {allowed_names(state)}!",
) )
# B) Remove any newly-installed unauthorized games. # B) Remove any newly-installed unauthorized games.
if config.uninstall_other_games: if config.uninstall_other_games:
removed = _guard_installed_games(allowed) removed = guard_installed_games(allowed)
if removed > 0: if removed > 0:
_echo(f" Guard removed {removed} unauthorized game(s)") _echo(f" Guard removed {removed} unauthorized game(s)")
@ -225,26 +206,28 @@ def do_enforce(config: Config, state: State, *, demo: bool = False) -> None:
session = new_session(demo=demo) session = new_session(demo=demo)
# A credited workout wakes the wait, so the budget moves within the tick. # A credited workout wakes the wait, so the budget moves within the tick.
wake = open_file_wake([workout_log_path(config)]) wake = open_file_wake([workout_log_path(config)])
# Root-only control socket for the web UI. Its ops take tick_lock, so they
# never interleave with a pass; inert (no socket) when not the real daemon.
control = start_control(config, demo=demo)
with control.tick_lock:
settle_restart_gap(config, session, base_interval=ENFORCE_INTERVAL, demo=demo)
try: try:
while True: while True:
# Reload state from disk so CLI changes (e.g. new game with control.tick_lock:
# assignment via ``done`` / ``scan``) take effect immediately # Reloaded under the lock: a control-socket op that saved
# without needing to restart the daemon. # state a moment ago must not be overwritten by a copy read
try: # before it ran.
fresh = State.load() if _reload_state(state):
except (json.JSONDecodeError, OSError, ValueError) as exc:
logger.warning("Failed to reload state: %s", exc)
wake.wait(ENFORCE_INTERVAL)
continue
# Every field, not a hand-picked few: the daemon's pick sweep saves
# this object, and any field left stale here (cooldowns, pick
# release times, assignment times) would overwrite what the CLI
# or the MCP server wrote. The MCP pick_manual tool also adds a
# *second* pick without touching current_app_id, which a daemon
# that never reloaded manual_picks would uninstall.
vars(state).update(vars(fresh))
_enforce_loop_iteration(config, state, session=session, demo=demo) _enforce_loop_iteration(config, state, session=session, demo=demo)
leave = control.flush_for_restart(
config, session, interval=ENFORCE_INTERVAL, demo=demo
)
if leave:
# Exit cleanly: Restart=always brings the unit straight back.
_echo("Restart requested: state flushed, exiting.")
return
wake.wait(ENFORCE_INTERVAL) wake.wait(ENFORCE_INTERVAL)
except KeyboardInterrupt: except KeyboardInterrupt:
_echo("\nEnforcer stopped.") _echo("\nEnforcer stopped.")
finally:
control.close()

View File

@ -0,0 +1,68 @@
"""Typed-phrase friction shared by the CLI, the web job runner and the daemon.
One table, so a phrase cannot be strict in one front end and lax in another.
The phrase is checked wherever the action is actually carried out (the job
subprocess or the root daemon), never only in the browser: a check that
``curl`` can skip is decoration, not friction.
Templates use ``str.format`` fields; the caller supplies every field the
template names (``days``, ``minutes``, ``game_name``, ``count``, ``backup_id``).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
@dataclass(frozen=True)
class Friction:
"""The friction attached to one command.
Attributes:
phrase_template: Text the user must type, with ``{field}`` slots.
countdown_seconds: Armed wait with heartbeats before commit (0 = none).
"""
phrase_template: str
countdown_seconds: int = 0
GAMING_RESET_COUNTDOWN_SECONDS: Final = 300
FRICTION: Final[dict[str, Friction]] = {
"gaming-reset": Friction(
"reset today's gaming budget", GAMING_RESET_COUNTDOWN_SECONDS
),
"block-gaming": Friction("block all gaming for {days} days"),
"unblock": Friction("unblock the store for {minutes} minutes"),
"buy-dlc": Friction("unblock the store for {minutes} minutes"),
"gaming-unblock": Friction("force release playtime mounts"),
"abandon-pick": Friction("abandon {game_name}"),
"pick-manual": Friction("lock in {game_name}"),
"add-exception": Friction("request exception for {game_name}"),
"uninstall": Friction("uninstall {count} games"),
"reset": Friction("wipe all enforcer state"),
"restore-backup": Friction("restore backup {backup_id}"),
}
def expected_phrase(command: str, **fields: object) -> str | None:
"""Return the exact phrase *command* requires, or ``None`` if it has none.
Raises:
KeyError: If the template names a field missing from *fields*.
"""
friction = FRICTION.get(command)
if friction is None:
return None
return friction.phrase_template.format(**fields)
def phrase_matches(expected: str, typed: str) -> bool:
"""Compare a typed phrase to the expected one.
Only surrounding whitespace is forgiven. Case and inner spacing must
match: retyping the sentence exactly is the point.
"""
return typed.strip() == expected

View File

@ -0,0 +1,123 @@
"""The gaming-reset core: zero today's counter, lift the block, leave a record.
Shared by the CLI (``sudo ./run.sh gaming-reset``, the direct recovery path)
and the daemon's two-phase control-socket op, so both do exactly the same
thing and both leave the same trail. The caller owns the confirmation
(``YES`` on the CLI, a phrase plus a heartbeat countdown on the socket).
Needs root: it releases bind mounts and writes the immutable state file.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import UTC, datetime
import logging
from typing import TYPE_CHECKING
from steam_backlog_enforcer._gaming_days import gaming_day_key
from steam_backlog_enforcer._playtime_block import release_block
from steam_backlog_enforcer._playtime_budget import roll_over
from steam_backlog_enforcer._playtime_history import record_day
from steam_backlog_enforcer._playtime_log import (
EVENT_MANUAL_ADJUSTMENT,
BudgetLog,
budget_log_path,
)
from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
load_state,
save_state,
)
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ResetOutcome:
"""What a gaming reset changed.
Attributes:
day_key: The gaming day that was reset.
seconds_before: Seconds billed before the reset.
was_blocked: Whether the cutoff had engaged.
released: Launcher mounts that were released.
"""
day_key: str
seconds_before: float
was_blocked: bool
released: list[Path]
def reset_today(*, source: str) -> ResetOutcome:
"""Zero today's billed time, drop the block and record the adjustment.
The earned-time bookkeeping (``carry`` from earlier free days, the day's
``budget_seconds`` high-water mark) is kept: resetting spent time must not
also revoke time already earned. Warnings re-arm because ``seconds`` is
back at zero.
The reset is written to the budget audit log as a ``manual_adjustment``
(with what was billed before, per game); that record is the auditable
trail. The per-day history is also set to 0 -- it mirrors *current* billed
time, so it shows the day as quiet and the audit log is what says why.
Args:
source: Who asked (``"cli"`` or ``"ctl"``), stored in the audit record.
Returns:
What changed.
"""
now = datetime.now(UTC).astimezone()
day_key = gaming_day_key(now)
stored = load_state(demo=False)
before = roll_over(stored, day_key=day_key) if stored else PlaytimeState()
released = release_block()
save_state(
replace(
before,
day_key=day_key,
seconds=0.0,
blocked_at=0.0,
last_tick_at=now.timestamp(),
per_game={},
last_credited_key="",
warned_seconds=[],
),
demo=False,
)
_record(source, day_key, before, released)
return ResetOutcome(
day_key=day_key,
seconds_before=before.seconds,
was_blocked=before.is_blocked(),
released=released,
)
def _record(
source: str, day_key: str, before: PlaytimeState, released: list[Path]
) -> None:
"""Write the audit record and the history point; never raises."""
log = BudgetLog(path=budget_log_path(demo=False))
log.record(
EVENT_MANUAL_ADJUSTMENT,
kind="gaming_reset",
source=source,
day_key=day_key,
billed_seconds_before=round(before.seconds, 3),
billed_seconds_after=0.0,
per_game_before=before.per_game,
was_blocked=before.is_blocked(),
released=[str(path) for path in released],
)
log.close()
try:
record_day(day_key, 0.0, {})
except OSError as exc:
# A history point is cosmetic; the reset itself already happened.
logger.warning("Could not record the reset in the history: %s", exc)

View File

@ -18,11 +18,36 @@ from steam_backlog_enforcer._hltb_types import (
load_hltb_rush_cache, load_hltb_rush_cache,
save_hltb_cache, save_hltb_cache,
) )
from steam_backlog_enforcer._progress import current_progress
from steam_backlog_enforcer.hltb import fetch_hltb_times_timed, games_per_second from steam_backlog_enforcer.hltb import fetch_hltb_times_timed, games_per_second
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def reporting_progress_cb(
label: str, total: int, inner: ProgressCb | None
) -> ProgressCb:
"""Start a progress phase and return a callback that advances it.
The returned callback forwards to *inner* (the CLI's carriage-return meter), so
the terminal output is unchanged while a web job gets real steps.
Args:
label: Phase label shown in the UI.
total: Number of lookups in the phase.
inner: The caller's own callback, if any.
"""
progress = current_progress()
progress.phase(label, total)
def report(done: int, total: int, found: int, name: str) -> None:
progress.advance(name, step=done)
if inner is not None:
inner(done, total, found, name)
return report
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Confidence-only batch fetch (no leisure/DLC detail pages) # Confidence-only batch fetch (no leisure/DLC detail pages)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
@ -55,7 +80,10 @@ def fetch_hltb_times_cached(
len(uncached), len(uncached),
len(games) - len(uncached), len(games) - len(uncached),
) )
elapsed = fetch_hltb_times_timed(uncached, cache, polls, progress_cb, extras) report = reporting_progress_cb(
"Fetching HLTB times", len(uncached), progress_cb
)
elapsed = fetch_hltb_times_timed(uncached, cache, polls, report, extras)
# Final save. # Final save.
save_hltb_cache(cache, polls, extras) save_hltb_cache(cache, polls, extras)

View File

@ -9,6 +9,7 @@ from __future__ import annotations
import logging import logging
import time import time
from steam_backlog_enforcer._hltb_cached import reporting_progress_cb
from steam_backlog_enforcer._hltb_types import ( from steam_backlog_enforcer._hltb_types import (
HLTB_BASE_URL, HLTB_BASE_URL,
ProgressCb, ProgressCb,
@ -82,7 +83,9 @@ def fetch_hltb_confidence_cached(
uncached, uncached,
cache=cache, cache=cache,
polls=polls, polls=polls,
progress_cb=progress_cb, progress_cb=reporting_progress_cb(
"Fetching HLTB poll counts", len(uncached), progress_cb
),
count_comp=count_comp, count_comp=count_comp,
) )
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
@ -169,7 +172,8 @@ def fetch_hltb_detail_missing(
) )
else: else:
logger.info("Backfilling HLTB game ID for %d game(s)...", n_id) logger.info("Backfilling HLTB game ID for %d game(s)...", n_id)
elapsed = fetch_hltb_times_timed(missing, cache, polls, progress_cb, extras) report = reporting_progress_cb("Fetching HLTB details", len(missing), progress_cb)
elapsed = fetch_hltb_times_timed(missing, cache, polls, report, extras)
restore_prior_hours(cache, prior_hours) restore_prior_hours(cache, prior_hours)

View File

@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re
from typing import Any from typing import Any
import aiohttp import aiohttp
@ -27,13 +26,10 @@ from steam_backlog_enforcer._hltb_types import (
_HLTBExtras, _HLTBExtras,
save_hltb_cache, save_hltb_cache,
) )
from steam_backlog_enforcer._progress import gather_tracked
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_NEXT_DATA_RE = re.compile(
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
)
def _extract_base_leisure_hours(game_data: dict[str, Any]) -> float: def _extract_base_leisure_hours(game_data: dict[str, Any]) -> float:
"""Extract base-game leisure hours from game detail data. """Extract base-game leisure hours from game detail data.
@ -183,7 +179,8 @@ async def _fetch_leisure_times(
connector=connector, connector=connector,
) as session: ) as session:
coros = [_fetch_detail_one(sem, session, r.hltb_game_id) for r in valid] coros = [_fetch_detail_one(sem, session, r.hltb_game_id) for r in valid]
details = await asyncio.gather(*coros) names = [r.game_name for r in valid]
details = await gather_tracked("Fetching HLTB detail pages", coros, names)
dlc_relationships_by_app, dlc_ids = _collect_dlc_relationships(valid, details) dlc_relationships_by_app, dlc_ids = _collect_dlc_relationships(valid, details)
dlc_hours_by_id = await _fetch_dlc_leisure_hours(sem, session, dlc_ids) dlc_hours_by_id = await _fetch_dlc_leisure_hours(sem, session, dlc_ids)
@ -237,7 +234,7 @@ async def _fetch_dlc_leisure_hours(
return {} return {}
coros = [_fetch_detail_one(sem, session, dlc_id) for dlc_id in dlc_ids] coros = [_fetch_detail_one(sem, session, dlc_id) for dlc_id in dlc_ids]
dlc_details = await asyncio.gather(*coros) dlc_details = await gather_tracked("Fetching HLTB DLC pages", coros)
dlc_hours_by_id: dict[int, float] = {} dlc_hours_by_id: dict[int, float] = {}
for dlc_id, dlc_data in zip(dlc_ids, dlc_details, strict=False): for dlc_id, dlc_data in zip(dlc_ids, dlc_details, strict=False):

View File

@ -22,6 +22,7 @@ from steam_backlog_enforcer._hltb_search_api import (
_get_auth_info, _get_auth_info,
_get_hltb_search_url, _get_hltb_search_url,
_pick_best_hltb_entry, _pick_best_hltb_entry,
forget_hltb_search_url,
) )
from steam_backlog_enforcer._hltb_types import ( from steam_backlog_enforcer._hltb_types import (
_SAVE_INTERVAL, _SAVE_INTERVAL,
@ -168,6 +169,7 @@ async def _search_batch(
auth = await _get_auth_info(search_url, init_session) auth = await _get_auth_info(search_url, init_session)
if auth is None: if auth is None:
logger.warning("Could not get HLTB auth info, aborting fetch.") logger.warning("Could not get HLTB auth info, aborting fetch.")
forget_hltb_search_url()
return [] return []
logger.info("HLTB auth token acquired.") logger.info("HLTB auth token acquired.")

View File

@ -45,7 +45,15 @@ _EXTENDED_MIN_CONFIDENCE = 3
_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0" _USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"
def _get_hltb_search_url() -> str: _DEFAULT_SEARCH_URL = "https://howlongtobeat.com/api/finder"
# Discovery scrapes the homepage and its JS bundles (~0.8 s), and one command
# can run several HLTB batches (``stats`` runs three), so a discovered URL is
# reused for a while. Failures are never remembered: they retry next time.
_SEARCH_URL_TTL_SECONDS = 1800.0
_search_url_memo: dict[str, tuple[float, str]] = {}
def _discover_hltb_search_url() -> str:
"""Discover the current HLTB search API endpoint. """Discover the current HLTB search API endpoint.
Scrapes the homepage for JS bundles containing the fetch URL, walking Scrapes the homepage for JS bundles containing the fetch URL, walking
@ -60,7 +68,24 @@ def _get_hltb_search_url() -> str:
return url return url
except OSError, RuntimeError, ValueError, TypeError: except OSError, RuntimeError, ValueError, TypeError:
logger.debug("Failed to discover HLTB search URL, using default") logger.debug("Failed to discover HLTB search URL, using default")
return "https://howlongtobeat.com/api/finder" return _DEFAULT_SEARCH_URL
def _get_hltb_search_url() -> str:
"""Return the HLTB search endpoint, rediscovering it once it is stale."""
now = time.monotonic()
memo = _search_url_memo.get("url")
if memo is not None and now - memo[0] < _SEARCH_URL_TTL_SECONDS:
return memo[1]
url = _discover_hltb_search_url()
if url != _DEFAULT_SEARCH_URL:
_search_url_memo["url"] = (now, url)
return url
def forget_hltb_search_url() -> None:
"""Drop the remembered endpoint, e.g. after it stopped handing out tokens."""
_search_url_memo.clear()
async def _get_auth_info( async def _get_auth_info(

View File

@ -0,0 +1,131 @@
"""Let the user name their own next game instead of taking one from the list.
The ranked list only offers games that clear the HLTB-confidence and ProtonDB
filters. Choosing your own game is a plain assignment, exactly as if it had
been on the list (no manual-pick lock): any owned game that is not 100 %
complete qualifies, confidence or not. Games still in a skip/abandon cooldown
stay out, so abandoning and re-picking cannot become a loop.
:func:`ineligible_reason` is the one rule: the web library greys games with
it, and a job only accepts an answer from the set it leaves eligible. A
front end that can browse the library (:class:`GamePicker`) gets that set;
the CLI keeps its text search.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._prompter import (
GamePicker,
PromptOption,
current_prompter,
)
from steam_backlog_enforcer.game_install import _echo
if TYPE_CHECKING:
from steam_backlog_enforcer.config import State
from steam_backlog_enforcer.steam_api import GameInfo
OWN_PICK_VALUE: Final = "own"
OWN_PICK_OPTION: Final = PromptOption(
value=OWN_PICK_VALUE,
label="Pick my own game…",
detail="Any owned game you have not 100%-completed",
)
# More matches than this and the user is asked to narrow the search.
_MAX_MATCHES: Final = 15
_SEARCH_AGAIN: Final = "search-again"
def ineligible_reason(
app_id: int, game: GameInfo | None, state: State, skipped: set[int]
) -> str | None:
"""Why *app_id* cannot be picked as your own game, or ``None`` if it can.
Args:
app_id: The owned game.
game: Its scan data; ``None`` when the scan has none (no
achievements, or not scanned yet), so it could never complete.
state: The enforcer state.
skipped: ``state.active_skipped_ids()``, computed once by the caller.
"""
if game is None:
return "No achievement data"
if game.is_complete:
return "Already 100% complete"
if app_id in state.finished_app_ids:
# Older states marked games finished below 100 %; still not pickable.
return "Marked finished"
if app_id in skipped:
until = state.skipped_until.get(str(app_id), "")[:10]
return f"Skipped until {until}" if until else "Skipped for now"
return None
def _eligible(games: list[GameInfo], state: State) -> list[GameInfo]:
"""Owned games the user may pick: unfinished and not cooling down."""
skipped = state.active_skipped_ids()
return [g for g in games if ineligible_reason(g.app_id, g, state, skipped) is None]
def _matches(query: str, games: list[GameInfo]) -> list[GameInfo]:
"""Exact app_id for a number, else case-insensitive name substring."""
if query.isdigit():
return [g for g in games if g.app_id == int(query)]
needle = query.casefold()
hits = [g for g in games if needle in g.name.casefold()]
return sorted(
hits, key=lambda g: (not g.name.casefold().startswith(needle), g.name)
)
def _label(game: GameInfo) -> PromptOption:
"""One match as a choice option."""
detail = f"{game.completion_pct:.0f}% achievements"
if game.completionist_hours > 0:
detail += f", ~{game.completionist_hours:.1f}h"
return PromptOption(
value=str(game.app_id),
label=f"{game.name} (AppID={game.app_id})",
detail=detail,
)
def _choose_among(hits: list[GameInfo]) -> GameInfo | None:
"""Ask which of several matches was meant; ``None`` means search again."""
options = [_label(g) for g in hits]
options.append(PromptOption(value=_SEARCH_AGAIN, label="Search again"))
picked = current_prompter().choice("Which game?", options)
if picked == _SEARCH_AGAIN:
return None
return next(g for g in hits if str(g.app_id) == picked)
def prompt_own_pick(games: list[GameInfo], state: State) -> GameInfo | None:
"""Search the user's own games until one is chosen.
Returns:
The chosen game, or ``None`` when the user leaves the search empty
to go back to the previous question.
"""
eligible = _eligible(games, state)
prompter = current_prompter()
if isinstance(prompter, GamePicker):
picked = prompter.game("Pick your next game", [g.app_id for g in eligible])
return next((g for g in eligible if g.app_id == picked), None)
while True:
query = current_prompter().text("Game name or app_id (leave empty to go back)")
if not query:
return None
hits = _matches(query, eligible)
if not hits:
_echo(f"No unfinished owned game matches {query!r}.")
continue
if len(hits) > _MAX_MATCHES:
_echo(f"{len(hits)} games match {query!r}; type more of the name.")
continue
chosen = hits[0] if len(hits) == 1 else _choose_among(hits)
if chosen is not None:
return chosen

View File

@ -15,7 +15,7 @@ from typing import Any
from steam_backlog_enforcer._snapshot import load_snapshot from steam_backlog_enforcer._snapshot import load_snapshot
from steam_backlog_enforcer.config import CONFIG_DIR, Config, _atomic_write from steam_backlog_enforcer.config import CONFIG_DIR, Config, _atomic_write
from steam_backlog_enforcer.steam_api import SteamAPIClient from steam_backlog_enforcer.steam_api import SteamAPIClient, SteamAPIError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -51,16 +51,80 @@ def _load_owned_app_ids_cache(steam_id: str) -> list[int] | None:
return [int(app_id) for app_id in raw_ids] return [int(app_id) for app_id in raw_ids]
def _save_owned_app_ids_cache(steam_id: str, app_ids: list[int]) -> None: def _owned_record(game: dict[str, Any]) -> dict[str, Any]:
"""Persist owned app IDs cache for this steam_id.""" """The part of a ``GetOwnedGames`` entry the web library shows."""
payload = { return {
"app_id": int(game["appid"]),
"name": str(game.get("name") or ""),
"playtime_minutes": int(game.get("playtime_forever") or 0),
"last_played": int(game.get("rtime_last_played") or 0),
# False: the game has no achievements at all (vs. not scanned yet).
"has_stats": bool(game.get("has_community_visible_stats")),
}
def _save_owned_app_ids_cache(
steam_id: str, app_ids: list[int], games: list[dict[str, Any]] | None = None
) -> None:
"""Persist owned app IDs cache for this steam_id.
*games* (the raw API entries) adds the ``games`` records the library
view reads; ``app_ids`` stays as it was for the enforce loop.
"""
payload: dict[str, Any] = {
"steam_id": steam_id, "steam_id": steam_id,
"fetched_at": time.time(), "fetched_at": time.time(),
"app_ids": app_ids, "app_ids": app_ids,
} }
if games is not None:
payload["games"] = [_owned_record(g) for g in games if "appid" in g]
_atomic_write(_OWNED_IDS_CACHE_FILE, json.dumps(payload, indent=2) + "\n") _atomic_write(_OWNED_IDS_CACHE_FILE, json.dumps(payload, indent=2) + "\n")
def owned_cache_stamp() -> tuple[int, int]:
"""``(mtime_ns, size)`` of the cache file; ``(0, 0)`` when absent."""
try:
st = _OWNED_IDS_CACHE_FILE.stat()
except OSError:
return (0, 0)
return (st.st_mtime_ns, st.st_size)
def load_owned_records(steam_id: str) -> list[dict[str, Any]] | None:
"""Cached per-game records (name, playtime, last played), any age.
Names and play history barely change between refreshes, so the TTL that
guards the id list does not apply. ``None`` when no refresh has stored
records for this account yet.
"""
try:
data = json.loads(_OWNED_IDS_CACHE_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError, OSError, ValueError:
return None
if not isinstance(data, dict) or str(data.get("steam_id", "")) != steam_id:
return None
games = data.get("games")
return games if isinstance(games, list) else None
def refresh_owned_records(config: Config) -> list[dict[str, Any]] | None:
"""Fetch the owned list from Steam and cache it with records.
Returns:
The records, or ``None`` when Steam could not be asked.
"""
if not (config.steam_api_key and config.steam_id):
return None
try:
owned = SteamAPIClient(config.steam_api_key, config.steam_id).get_owned_games()
except OSError, SteamAPIError, ValueError:
logger.warning("Could not fetch the owned game list.")
return None
api_ids = [int(g["appid"]) for g in owned if "appid" in g]
_save_owned_app_ids_cache(config.steam_id, api_ids, owned)
return [_owned_record(g) for g in owned if "appid" in g]
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Helpers # Helpers
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
@ -92,7 +156,7 @@ def get_all_owned_app_ids(config: Config) -> list[int]:
client = SteamAPIClient(config.steam_api_key, config.steam_id) client = SteamAPIClient(config.steam_api_key, config.steam_id)
owned = client.get_owned_games() owned = client.get_owned_games()
api_ids = [int(g["appid"]) for g in owned if "appid" in g] api_ids = [int(g["appid"]) for g in owned if "appid" in g]
_save_owned_app_ids_cache(config.steam_id, api_ids) _save_owned_app_ids_cache(config.steam_id, api_ids, owned)
merged_ids: list[int] = [] merged_ids: list[int] = []
seen: set[int] = set() seen: set[int] = set()

View File

@ -0,0 +1,125 @@
"""Progress reporting for long operations, shared by the CLI and web jobs.
Long loops (library scan, HLTB lookups, per-game uninstall, tampering check)
report *what* they are doing through :func:`current_progress`; *who* listens
is decided by the caller. The CLI listens to nothing — it already prints its
own meters with ``_echo``, and they stay byte-identical — while a web job
installs a reporter that writes ``progress`` events.
The active reporter lives in a context variable rather than a parameter so
the loops, which sit several calls below the command entry points, report
without every signature in between growing an argument.
"""
from __future__ import annotations
import asyncio
from contextlib import contextmanager
from contextvars import ContextVar
from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Iterator, Sequence
class Progress(Protocol):
"""Receives the phases and steps of a long operation."""
def phase(self, label: str, total: int | None = None) -> None:
"""Start a new phase; ``total`` is ``None`` when it is indeterminate."""
def advance(self, item: str | None = None, *, step: int | None = None) -> None:
"""Move one step forward, or to ``step`` when the caller counts itself.
Args:
item: What the step was about (a game name), shown next to it.
step: Absolute step number; ``None`` means "one more than before".
"""
class NullProgress:
"""The CLI reporter: does nothing, the CLI prints its own meters."""
def phase(self, label: str, total: int | None = None) -> None:
"""Ignore a phase start."""
del label, total
def advance(self, item: str | None = None, *, step: int | None = None) -> None:
"""Ignore a step."""
del item, step
_ACTIVE: ContextVar[Progress | None] = ContextVar("progress", default=None)
_NULL = NullProgress()
def current_progress() -> Progress:
"""Return the reporter the running command should talk to."""
return _ACTIVE.get() or _NULL
@contextmanager
def use_progress(progress: Progress) -> Iterator[Progress]:
"""Make *progress* the active reporter for the duration of the context."""
token = _ACTIVE.set(progress)
try:
yield progress
finally:
_ACTIVE.reset(token)
def tracked[T](items: Sequence[T], label: str, name: Callable[[T], str]) -> Iterator[T]:
"""Yield *items*, reporting one determinate step per item.
The step is reported *after* the caller finishes with the item, so the
count shown is always "done", never "started".
Args:
items: The work list; its length is the phase total.
label: Phase label, e.g. ``"Uninstalling games"``.
name: Turns an item into the text shown next to its step.
"""
progress = current_progress()
progress.phase(label, len(items))
for item in items:
yield item
progress.advance(name(item))
async def gather_tracked[T](
label: str, coros: Sequence[Awaitable[T]], names: Sequence[str] | None = None
) -> list[T]:
"""``asyncio.gather`` that reports one determinate step per finished awaitable.
Steps count completions (in whatever order they finish), so the bar moves
while the batch runs instead of jumping to full at the end.
Args:
label: Phase label, e.g. ``"Fetching HLTB detail pages"``.
coros: The awaitables; their count is the phase total.
names: Per-awaitable text shown next to its step, if any.
"""
progress = current_progress()
progress.phase(label, len(coros))
done = 0
async def one(coro: Awaitable[T], name: str | None) -> T:
nonlocal done
result = await coro
done += 1
progress.advance(name, step=done)
return result
labels = names if names is not None else [None] * len(coros)
return list(await asyncio.gather(*map(one, coros, labels, strict=True)))
def eta_seconds(elapsed: float, step: int, total: int | None) -> int | None:
"""Estimate the seconds left from the average time per finished step.
Returns:
``None`` until at least one step finished or when there is no total.
"""
if total is None or step <= 0:
return None
return max(0, round(elapsed / step * (total - step)))

View File

@ -0,0 +1,168 @@
"""Questions a command asks its user, answered by a terminal or the web UI.
Every ``input()`` in the package goes through :func:`current_prompter`. The
CLI prompter reads stdin exactly as the commands used to; a web job installs
one that emits a ``prompt`` event and waits for the answer the server writes
(see :mod:`steam_backlog_enforcer.jobs._job_prompter`).
Typed-phrase confirmations take their text from
:func:`steam_backlog_enforcer._friction.expected_phrase`, so the CLI, the job
runner and the daemon all demand the same sentence.
"""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
import sys
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from steam_backlog_enforcer._echo import _echo
from steam_backlog_enforcer._friction import expected_phrase, phrase_matches
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
@dataclass(frozen=True)
class PromptOption:
"""One answer to a ``choice`` prompt.
Attributes:
value: What the prompter returns when this option is chosen.
label: The text shown for it.
detail: Optional secondary text (the UI shows it under the label).
"""
value: str
label: str
detail: str | None = None
class PromptAbortedError(Exception):
"""The user declined, or never answered, a prompt the command needs."""
class Prompter(Protocol):
"""Asks the user something and returns the answer."""
@property
def interactive(self) -> bool:
"""Whether a human can answer at all (a piped CLI cannot)."""
def choice(self, message: str, options: Sequence[PromptOption]) -> str:
"""Return the ``value`` of the option the user picked."""
def confirm(self, message: str, *, default: bool) -> bool:
"""Return the user's yes/no answer."""
def phrase(self, message: str, expected: str) -> bool:
"""Return whether the user typed *expected* exactly."""
def text(self, message: str) -> str:
"""Return a line of free text, stripped."""
@runtime_checkable
class GamePicker(Protocol):
"""A prompter that can show the whole library to pick a game from.
Optional on top of :class:`Prompter`: the CLI has no browser and keeps
its text search (:func:`steam_backlog_enforcer._own_pick.prompt_own_pick`).
"""
def game(self, message: str, app_ids: Sequence[int]) -> int | None:
"""Return the picked app id (one of *app_ids*), or ``None`` to go back."""
class CliPrompter:
"""Reads answers from stdin; the CLI's behaviour before prompters existed."""
@property
def interactive(self) -> bool:
"""True when stdin is a terminal."""
return sys.stdin.isatty()
def choice(self, message: str, options: Sequence[PromptOption]) -> str:
"""Print a numbered list and loop until a valid number is typed."""
for i, option in enumerate(options, 1):
_echo(f" {i}. {option.label}")
while True:
raw = input(f"{message}: ")
try:
idx = int(raw)
except ValueError:
_echo(f"Invalid input: {raw!r}")
continue
if idx < 1 or idx > len(options):
_echo(f"Out of range: {idx}")
continue
return options[idx - 1].value
def confirm(self, message: str, *, default: bool) -> bool:
"""Ask ``[Y/n]``; an empty answer or end of input means *default*."""
hint = "[Y/n]" if default else "[y/N]"
while True:
try:
answer = input(f"{message} {hint}: ").strip().lower()
except EOFError:
return default
if not answer:
return default
if answer in {"y", "yes"}:
return True
if answer in {"n", "no"}:
return False
_echo(" Please answer 'y' or 'n'.")
def phrase(self, message: str, expected: str) -> bool:
"""Ask for the phrase once; a mismatch is a "no".
The CLI has already printed what is at stake before asking, so only
the phrase itself is shown; *message* is for front ends without that
preamble.
"""
del message
return phrase_matches(expected, input(f'Type "{expected}" to confirm: '))
def text(self, message: str) -> str:
"""Ask for one line of text."""
return input(f"{message}: ").strip()
_ACTIVE: ContextVar[Prompter | None] = ContextVar("prompter", default=None)
_CLI = CliPrompter()
def current_prompter() -> Prompter:
"""Return the prompter the running command should ask."""
return _ACTIVE.get() or _CLI
@contextmanager
def use_prompter(prompter: Prompter) -> Iterator[Prompter]:
"""Make *prompter* the active one for the duration of the context."""
token = _ACTIVE.set(prompter)
try:
yield prompter
finally:
_ACTIVE.reset(token)
def confirm_phrase(command: str, message: str, **fields: object) -> bool:
"""Ask for *command*'s friction phrase and say whether it was typed.
Args:
command: Key into :data:`steam_backlog_enforcer._friction.FRICTION`.
message: What is being confirmed, for front ends that show it.
**fields: The template's slots (``game_name``, ``count``, ...).
Raises:
KeyError: If *command* has no phrase; a missing phrase must never
silently mean "confirmed".
"""
expected = expected_phrase(command, **fields)
if expected is None:
raise KeyError(command)
return current_prompter().phrase(message, expected)

View File

@ -15,6 +15,12 @@ from steam_backlog_enforcer._assignment_progress import (
last_assigned_epoch, last_assigned_epoch,
record_assignment, record_assignment,
) )
from steam_backlog_enforcer._own_pick import (
OWN_PICK_OPTION,
OWN_PICK_VALUE,
prompt_own_pick,
)
from steam_backlog_enforcer._prompter import PromptOption, current_prompter
from steam_backlog_enforcer._scanning_candidates import ( from steam_backlog_enforcer._scanning_candidates import (
_pick_next_shortest_candidate, _pick_next_shortest_candidate,
_sort_key, _sort_key,
@ -45,24 +51,28 @@ _NO_CONF_MSG = (
) )
def _prompt_user_pick(qualified: list[GameInfo]) -> int: def _prompt_user_pick(
"""Present numbered list, return 0-based index of user's choice.""" qualified: list[GameInfo], games: list[GameInfo], state: State
for i, g in enumerate(qualified, 1): ) -> GameInfo:
"""Present the ranked list plus "pick my own"; return the chosen game.
Backing out of the own-game search (empty answer) shows the list again.
"""
options: list[PromptOption] = []
for i, g in enumerate(qualified):
hours_str = ( hours_str = (
f" (~{g.completionist_hours:.1f}h)" if g.completionist_hours > 0 else "" f" (~{g.completionist_hours:.1f}h)" if g.completionist_hours > 0 else ""
) )
_echo(f" {i}. {g.name} (AppID={g.app_id}){hours_str}") label = f"{g.name} (AppID={g.app_id}){hours_str}"
options.append(PromptOption(value=str(i), label=label))
options.append(OWN_PICK_OPTION)
while True: while True:
raw = input("Select game number: ") picked = current_prompter().choice("Select game number", options)
try: if picked != OWN_PICK_VALUE:
idx = int(raw) return qualified[int(picked)]
except ValueError: own = prompt_own_pick(games, state)
_echo(f"Invalid input: {raw!r}") if own is not None:
continue return own
if idx < 1 or idx > len(qualified):
_echo(f"Out of range: {idx}")
continue
return idx - 1
def _assign_chosen_game( def _assign_chosen_game(
@ -145,13 +155,14 @@ def _pick_next_game_sequential(
games: list[GameInfo], games: list[GameInfo],
state: State, state: State,
config: Config, config: Config,
on_select: Callable[[GameInfo], bool], on_select: Callable[[GameInfo], bool | GameInfo],
) -> None: ) -> None:
"""Pick the next-shortest playable game, asking the user per candidate. """Pick the next-shortest playable game, asking the user per candidate.
``on_select`` is called with each prospective pick. Returning ``True`` ``on_select`` is called with each prospective pick. Returning ``True``
accepts the assignment; returning ``False`` records a 7-day skip on accepts the assignment; returning ``False`` records a 7-day skip on
``state`` for that game and the next candidate is evaluated. ``state`` for that game and the next candidate is evaluated; returning a
game assigns that one instead (the user picked their own).
""" """
while True: while True:
candidates = _open_candidates(games, state) candidates = _open_candidates(games, state)
@ -168,11 +179,14 @@ def _pick_next_game_sequential(
) )
return return
if not on_select(chosen): verdict = on_select(chosen)
if verdict is False:
state.skip_for_days(chosen.app_id, 7) state.skip_for_days(chosen.app_id, 7)
state.save() state.save()
_echo(f"\n Skipped {chosen.name} for 7 days; picking next...") _echo(f"\n Skipped {chosen.name} for 7 days; picking next...")
continue continue
_assign_chosen_game(chosen, games, state, config) _assign_chosen_game(
chosen if verdict is True else verdict, games, state, config
)
return return

View File

@ -6,23 +6,34 @@ the 250-line cap. Leaf helpers: nothing here calls back into ``scanning``.
from __future__ import annotations from __future__ import annotations
from collections import deque
from concurrent.futures import Future, ThreadPoolExecutor
from contextlib import closing
from itertools import islice
import logging import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from steam_backlog_enforcer._allowed_games import allowed_games from steam_backlog_enforcer._allowed_games import allowed_games
from steam_backlog_enforcer._assignment_progress import iso_to_epoch from steam_backlog_enforcer._assignment_progress import iso_to_epoch
from steam_backlog_enforcer._progress import current_progress
from steam_backlog_enforcer._snapshot import load_snapshot from steam_backlog_enforcer._snapshot import load_snapshot
from steam_backlog_enforcer._steam_models import MAX_WORKERS
from steam_backlog_enforcer._whitelist_locking import get_approved_exception_ids from steam_backlog_enforcer._whitelist_locking import get_approved_exception_ids
from steam_backlog_enforcer.enforcer import send_notification from steam_backlog_enforcer.enforcer import send_notification
from steam_backlog_enforcer.game_install import _echo from steam_backlog_enforcer.game_install import _echo
from steam_backlog_enforcer.steam_api import SteamAPIClient from steam_backlog_enforcer.steam_api import SteamAPIClient
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Generator
from steam_backlog_enforcer.config import Config, State from steam_backlog_enforcer.config import Config, State
from steam_backlog_enforcer.steam_api import GameInfo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_TAMPER_CHECK_LIMIT = 3 _TAMPER_CHECK_LIMIT = 3
# Requests kept in flight while re-fetching; same width as the library scan.
_TAMPER_WINDOW = MAX_WORKERS
def _legitimately_played(state: State) -> set[int]: def _legitimately_played(state: State) -> set[int]:
@ -51,6 +62,51 @@ def _legitimately_played(state: State) -> set[int]:
return exempt return exempt
def _needs_refetch(entry: dict[str, Any], exempt: set[int]) -> bool:
"""Whether *entry* could show tampering, so is worth a Steam request.
Args:
entry: Snapshot entry for the game.
exempt: App ids from :func:`_legitimately_played`.
"""
if entry["app_id"] in exempt:
return False
if entry["unlocked_achievements"] >= entry["total_achievements"]:
return False
playtime: int = entry.get("playtime_minutes", 0)
return playtime > 0
def _unexpected_unlocks(
entry: dict[str, Any], game: GameInfo, state: State
) -> tuple[str, int, int] | None:
"""Compare fresh achievement data with the snapshot entry.
Returns:
Tuple of (name, app_id, diff) if tampering detected, else None.
"""
app_id = entry["app_id"]
released = iso_to_epoch(state.released_at.get(str(app_id), ""))
if released is not None:
# Released below 100%: the snapshot predates the unlocks earned while
# it was assigned, so only unlocks after the release are suspicious.
diff = sum(
1 for a in game.achievements if a.achieved and a.unlock_time > released
)
else:
diff = game.unlocked_achievements - entry["unlocked_achievements"]
if diff > 0:
return (entry["name"], app_id, diff)
return None
def _refetch(client: SteamAPIClient, entry: dict[str, Any]) -> GameInfo | None:
"""Re-fetch one snapshot entry's achievements from Steam."""
return client.refresh_single_game(
entry["app_id"], entry["name"], entry.get("playtime_minutes", 0)
)
def _check_game_tampering( def _check_game_tampering(
client: SteamAPIClient, client: SteamAPIClient,
entry: dict[str, Any], entry: dict[str, Any],
@ -66,30 +122,39 @@ def _check_game_tampering(
Returns: Returns:
Tuple of (name, app_id, diff) if tampering detected, else None. Tuple of (name, app_id, diff) if tampering detected, else None.
""" """
app_id = entry["app_id"] if not _needs_refetch(entry, _legitimately_played(state)):
if app_id in _legitimately_played(state):
return None return None
if entry["unlocked_achievements"] >= entry["total_achievements"]: game = _refetch(client, entry)
return None
if entry.get("playtime_minutes", 0) <= 0:
return None
game = client.refresh_single_game(
app_id, entry["name"], entry.get("playtime_minutes", 0)
)
if game is None: if game is None:
return None return None
released = iso_to_epoch(state.released_at.get(str(app_id), "")) return _unexpected_unlocks(entry, game, state)
if released is not None:
# Released below 100%: the snapshot predates the unlocks earned while
# it was assigned, so only unlocks after the release are suspicious. def _refetch_in_order(
diff = sum( client: SteamAPIClient, entries: list[dict[str, Any]]
1 for a in game.achievements if a.achieved and a.unlock_time > released ) -> Generator[tuple[dict[str, Any], GameInfo | None]]:
) """Yield ``(entry, fresh game)`` in *entries* order, fetching ahead.
else:
diff = game.unlocked_achievements - entry["unlocked_achievements"] A sliding window keeps :data:`_TAMPER_WINDOW` requests in flight (the
if diff > 0: client's own rate limiter still caps requests per second), so the caller
return (entry["name"], app_id, diff) can stop early without having asked Steam about the whole library. Closing
return None the generator drops queued requests instead of waiting for them, which is
also what makes a cancelled job stop promptly.
"""
pool = ThreadPoolExecutor(max_workers=_TAMPER_WINDOW)
pending: deque[tuple[dict[str, Any], Future[GameInfo | None]]] = deque()
upcoming = iter(entries)
try:
for entry in islice(upcoming, _TAMPER_WINDOW):
pending.append((entry, pool.submit(_refetch, client, entry)))
while pending:
entry, future = pending.popleft()
nxt = next(upcoming, None)
if nxt is not None:
pending.append((nxt, pool.submit(_refetch, client, nxt)))
yield entry, future.result()
finally:
pool.shutdown(wait=False, cancel_futures=True)
def detect_tampering(config: Config, state: State) -> None: def detect_tampering(config: Config, state: State) -> None:
@ -99,14 +164,21 @@ def detect_tampering(config: Config, state: State) -> None:
return return
client = SteamAPIClient(config.steam_api_key, config.steam_id) client = SteamAPIClient(config.steam_api_key, config.steam_id)
exempt = _legitimately_played(state)
to_check = [e for e in old_snapshot if _needs_refetch(e, exempt)]
# Quick check: only re-fetch a few random non-assigned games. # Stops at the first few hits, in snapshot order, like the serial loop did.
suspicious: list[tuple[str, int, int]] = [] suspicious: list[tuple[str, int, int]] = []
for entry in old_snapshot: progress = current_progress()
result = _check_game_tampering(client, entry, state) progress.phase("Checking other games for tampering", len(to_check))
with closing(_refetch_in_order(client, to_check)) as fetched:
for entry, game in fetched:
progress.advance(str(entry.get("name", "")))
result = None if game is None else _unexpected_unlocks(entry, game, state)
if result: if result:
suspicious.append(result) suspicious.append(result)
if len(suspicious) >= _TAMPER_CHECK_LIMIT: if len(suspicious) >= _TAMPER_CHECK_LIMIT:
progress.advance(step=len(to_check))
break break
if suspicious: if suspicious:

View File

@ -13,6 +13,12 @@ from typing import TYPE_CHECKING, Any
import requests import requests
from steam_backlog_enforcer._achievement_skip import (
is_no_stats_answer,
record_scan,
skippable_app_ids,
)
from steam_backlog_enforcer._progress import current_progress
from steam_backlog_enforcer._steam_models import ( from steam_backlog_enforcer._steam_models import (
MAX_WORKERS, MAX_WORKERS,
STEAM_API_BASE, STEAM_API_BASE,
@ -52,6 +58,8 @@ class SteamAPIClient:
self._rate_lock = threading.Lock() self._rate_lock = threading.Lock()
self._request_times: list[float] = [] self._request_times: list[float] = []
self._max_rps = 18 self._max_rps = 18
# Games Steam answered "no stats" for during the current scan.
self._no_stats: set[int] = set()
def _rate_limit(self) -> None: def _rate_limit(self) -> None:
"""Enforce rate limit across threads.""" """Enforce rate limit across threads."""
@ -95,8 +103,13 @@ class SteamAPIClient:
logger.info("Found %d owned games.", len(games)) logger.info("Found %d owned games.", len(games))
return games return games
def get_achievement_details(self, app_id: int) -> list[AchievementInfo]: def get_achievement_details(
"""Fetch per-achievement detail for a game.""" self, app_id: int, *, strict: bool = False
) -> list[AchievementInfo]:
"""Fetch per-achievement detail for a game.
``strict``: only Steam's "no stats" answer is empty; failures raise.
"""
url = f"{STEAM_API_BASE}/ISteamUserStats/GetPlayerAchievements/v1/" url = f"{STEAM_API_BASE}/ISteamUserStats/GetPlayerAchievements/v1/"
try: try:
data = self._get( data = self._get(
@ -108,7 +121,9 @@ class SteamAPIClient:
"format": "json", "format": "json",
}, },
) )
except SteamAPIError: except SteamAPIError as exc:
if strict and not is_no_stats_answer(exc):
raise
return [] return []
stats = data.get("playerstats", {}) stats = data.get("playerstats", {})
@ -130,8 +145,9 @@ class SteamAPIClient:
"""Fetch achievement data for one game. Thread-safe.""" """Fetch achievement data for one game. Thread-safe."""
app_id = game_dict["appid"] app_id = game_dict["appid"]
achievements = self.get_achievement_details(app_id) achievements = self.get_achievement_details(app_id, strict=True)
if not achievements: if not achievements:
self._no_stats.add(app_id) # set.add is atomic under the GIL
return None return None
name = game_dict.get("name", f"Unknown ({app_id})") name = game_dict.get("name", f"Unknown ({app_id})")
@ -151,15 +167,43 @@ class SteamAPIClient:
self, self,
progress_callback: Callable[[int, int], None] | None = None, progress_callback: Callable[[int, int], None] | None = None,
) -> list[GameInfo]: ) -> list[GameInfo]:
"""Build full game list with achievement data (parallel).""" """Build full game list with achievement data (parallel).
Games recently found to have no achievements are not asked about
again (see :mod:`._achievement_skip`).
"""
owned = self.get_owned_games() owned = self.get_owned_games()
now = time.time()
skip = skippable_app_ids(self.steam_id, owned, now)
to_fetch = [g for g in owned if g["appid"] not in skip]
if skip:
logger.info("Skipping %d games known to have no achievements.", len(skip))
self._no_stats = set()
games = self._fetch_games(to_fetch, progress_callback)
# Only games Steam actually answered for: a timeout or a 5xx must not
# be remembered as "no achievements" and hidden from scans for a month.
found = {g.app_id for g in games}
answered = [g for g in to_fetch if g["appid"] in found | self._no_stats]
record_scan(self.steam_id, answered, found, now)
games.sort(key=lambda g: g.name.lower())
return games
def _fetch_games(
self,
owned: list[dict[str, Any]],
progress_callback: Callable[[int, int], None] | None,
) -> list[GameInfo]:
"""Fetch achievements for *owned* in parallel, reporting each game."""
games: list[GameInfo] = [] games: list[GameInfo] = []
done_count = 0 done_count = 0
total = len(owned) total = len(owned)
lock = threading.Lock() lock = threading.Lock()
progress = current_progress()
progress.phase("Scanning Steam achievements", total)
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = {pool.submit(self._fetch_one_game, g): g for g in owned} futures = {pool.submit(self._fetch_one_game, g): g for g in owned}
try:
for future in as_completed(futures): for future in as_completed(futures):
try: try:
result = future.result() result = future.result()
@ -173,12 +217,18 @@ class SteamAPIClient:
result = None result = None
with lock: with lock:
done_count += 1 done_count += 1
progress.advance(
str(futures[future].get("name", "")), step=done_count
)
if progress_callback: if progress_callback:
progress_callback(done_count, total) progress_callback(done_count, total)
if result is not None: if result is not None:
games.append(result) games.append(result)
except BaseException:
games.sort(key=lambda g: g.name.lower()) # A cancelled job must not wait for the whole queue to drain:
# drop what has not started, leaving only requests in flight.
pool.shutdown(wait=False, cancel_futures=True)
raise
return games return games
def refresh_single_game( def refresh_single_game(

View File

@ -0,0 +1,139 @@
"""``GET /api/art/{app_id}``: a game's portrait cover for the library grid.
Looked for in order: Steam's own librarycache (portrait files only; the
40-hex ``.jpg`` there are 32x32 icons and ``header.jpg`` is landscape), this
server's disk cache of earlier CDN fetches, then Steam's CDN. No cover is a
404 and the UI draws a title tile instead.
The one view that may use the network, so it is fenced: owned app ids only
(the id is an int put into a fixed URL, never a proxy), a few fetches at a
time with a short timeout, and a failed fetch is remembered for a while so a
grid of 1000+ tiles does not re-ask a blocked or missing CDN on every
scroll. A 404 is remembered for a week; a network failure (the total block
lists ``steamstatic.com``) for minutes, so art appears once it lifts.
"""
from __future__ import annotations
import contextlib
from http import HTTPStatus
from pathlib import Path
import threading
import time
from typing import TYPE_CHECKING, Final
import requests
from steam_backlog_enforcer._web_errors import not_found
from steam_backlog_enforcer._web_io import Reply
from steam_backlog_enforcer._web_library import owned_app_ids
if TYPE_CHECKING:
from steam_backlog_enforcer._web_io import Request
LIBRARYCACHE = Path("~/.local/share/Steam/appcache/librarycache").expanduser()
ART_CACHE_DIR = Path.home() / ".cache" / "steam_backlog_enforcer" / "art"
_CDN: Final = (
"https://shared.steamstatic.com/store_item_assets/steam/apps/"
"{app_id}/library_600x900.jpg"
)
_PORTRAITS: Final = ("library_600x900.jpg", "library_capsule.jpg")
_NOT_FOUND_RETRY_S: Final = 7 * 24 * 3600
_FAILED_RETRY_S: Final = 10 * 60
_TIMEOUT: Final = (3.05, 6.0)
_MAX_BYTES: Final = 2 * 1024 * 1024
_HEADERS: Final = {"Cache-Control": "private, max-age=86400"}
_fetch_slots = threading.BoundedSemaphore(6)
def _local_cover(app_id: int) -> Path | None:
"""A portrait Steam itself cached for *app_id*, if any."""
base = LIBRARYCACHE / str(app_id)
for name in _PORTRAITS:
for candidate in (base / name, *sorted(base.glob(f"*/{name}"))):
if candidate.is_file():
return candidate
return None
def _cache_file(app_id: int) -> Path:
return ART_CACHE_DIR / f"{app_id}.jpg"
def _miss_file(app_id: int) -> Path:
return ART_CACHE_DIR / f"{app_id}.miss"
def _miss_active(app_id: int) -> bool:
"""Whether a recent fetch failed and it is too early to retry."""
try:
retry_at = float(_miss_file(app_id).read_text(encoding="utf-8"))
except OSError, ValueError:
return False
return time.time() < retry_at
def _record_miss(app_id: int, seconds: int) -> None:
ART_CACHE_DIR.mkdir(parents=True, exist_ok=True)
_miss_file(app_id).write_text(str(time.time() + seconds), encoding="utf-8")
def _store(app_id: int, body: bytes) -> None:
"""Write the cover atomically so a reader never sees half a JPEG."""
ART_CACHE_DIR.mkdir(parents=True, exist_ok=True)
tmp = ART_CACHE_DIR / f"{app_id}.jpg.{threading.get_ident()}.tmp"
tmp.write_bytes(body)
tmp.replace(_cache_file(app_id))
with contextlib.suppress(OSError):
_miss_file(app_id).unlink()
def _fetch(app_id: int) -> bytes | None:
"""Download the CDN cover into the cache; ``None`` when there is none."""
with _fetch_slots:
# Another request may have fetched it while this one waited.
with contextlib.suppress(OSError):
return _cache_file(app_id).read_bytes()
if _miss_active(app_id):
return None
try:
resp = requests.get(_CDN.format(app_id=app_id), timeout=_TIMEOUT)
except requests.RequestException:
_record_miss(app_id, _FAILED_RETRY_S)
return None
ctype = resp.headers.get("Content-Type", "")
if resp.status_code != HTTPStatus.OK or not ctype.startswith("image/"):
missing = resp.status_code in {HTTPStatus.NOT_FOUND, HTTPStatus.FORBIDDEN}
_record_miss(app_id, _NOT_FOUND_RETRY_S if missing else _FAILED_RETRY_S)
return None
if len(resp.content) > _MAX_BYTES:
_record_miss(app_id, _NOT_FOUND_RETRY_S)
return None
_store(app_id, resp.content)
return resp.content
def cover_bytes(app_id: int) -> bytes | None:
"""The cover for *app_id* from the first source that has one."""
local = _local_cover(app_id)
if local is not None:
return local.read_bytes()
with contextlib.suppress(OSError):
return _cache_file(app_id).read_bytes()
if _miss_active(app_id):
return None
return _fetch(app_id)
def art_view(request: Request) -> Reply:
"""``GET /api/art/{app_id}`` — JPEG bytes, or ``not_found``."""
raw = request.args[0]
if not raw.isdigit() or int(raw) not in owned_app_ids():
msg = f"{raw} is not an owned game."
raise not_found(msg)
body = cover_bytes(int(raw))
if body is None:
msg = f"No cover art for {raw}."
raise not_found(msg)
return Reply(HTTPStatus.OK, raw=body, ctype="image/jpeg", headers=_HEADERS)

View File

@ -0,0 +1,129 @@
"""Who may talk to the web server: Host, Origin and the per-launch token.
The contract's "Web server auth" section, in one place:
- ``Host`` must name this server on loopback (``127.0.0.1:<port>`` or
``localhost:<port>``) on *every* request, static files included. That is
the DNS-rebinding guard: a page on ``evil.example`` re-pointed at
127.0.0.1 still sends its own name as ``Host``, so it can read neither
the data nor the ``index.html`` that carries the token.
- Every non-GET must carry ``Origin`` equal to the origin it was served from.
- Every non-GET, and the SSE stream (``EventSource`` cannot set headers, so
it sends ``?token=``), must carry the per-launch token.
The token is 32 random bytes, regenerated at every launch, written owner-only
to ``$XDG_RUNTIME_DIR/steam-backlog-enforcer/web-<port>.token`` (per port, so
a scratch server on another port never clobbers the live one's file) and
injected into the served ``index.html`` as ``<meta name="sbe-token">``. A page from a
previous launch therefore stops working when the server restarts: the UI
must reload on ``bad_token``.
"""
from __future__ import annotations
import hmac
import os
from pathlib import Path
import re
import secrets
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._web_errors import ApiError
if TYPE_CHECKING:
from email.message import Message
# Where the token travels. Names, not secrets; the UI's copies are
# ``TOKEN_HEADER`` / ``TOKEN_META`` in web/src/api/contract.ts.
AUTH_HEADER: Final = "X-SBE-Token"
AUTH_META: Final = "sbe-token"
AUTH_QUERY: Final = "token"
_RUNTIME_SUBDIR: Final = "steam-backlog-enforcer"
_LOOPBACK_NAMES: Final = ("127.0.0.1", "localhost")
_HEAD_TAG = re.compile(rb"<head[^>]*>", re.IGNORECASE)
def new_token() -> str:
"""A fresh per-launch token: 32 random bytes as hex."""
return secrets.token_hex(32)
def token_path(port: int) -> Path:
"""Where the token file of the server on *port* lives.
``$XDG_RUNTIME_DIR/steam-backlog-enforcer/web-<port>.token`` (runtime dir
``/run/user/<uid>`` when unset). The Vite dev server reads the same file.
"""
runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
return Path(runtime) / _RUNTIME_SUBDIR / f"web-{port}.token"
def write_token_file(token: str, port: int) -> Path:
"""Write *token* owner-only (dir 0700, file 0600) and return its path.
The file is created with mode 0600 from the start, never chmod-ed after
a world-readable write, and replaced atomically so a reader never sees a
half-written token.
"""
path = token_path(port)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
path.parent.chmod(0o700)
tmp = path.with_suffix(".tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.fchmod(fd, 0o600)
os.write(fd, f"{token}\n".encode("ascii"))
finally:
os.close(fd)
tmp.replace(path)
return path
def allowed_hosts(port: int) -> frozenset[str]:
"""The ``Host`` header values this server answers to."""
return frozenset(f"{name}:{port}" for name in _LOOPBACK_NAMES)
def check_host(headers: Message, port: int) -> str:
"""Return the validated ``Host`` header.
Raises:
ApiError: ``bad_host`` for a missing or foreign host.
"""
host = (headers.get("Host") or "").strip().lower()
if host not in allowed_hosts(port):
msg = f"Host {host or '(none)'!r} is not this server."
raise ApiError(msg, code="bad_host")
return host
def check_origin(headers: Message, host: str) -> None:
"""Require ``Origin`` to be the origin this request's page came from.
Raises:
ApiError: ``bad_origin`` for a missing or different origin.
"""
origin = (headers.get("Origin") or "").strip().lower()
if origin != f"http://{host}":
msg = f"Origin {origin or '(none)'!r} may not change state here."
raise ApiError(msg, code="bad_origin")
def check_token(supplied: str | None, token: str) -> None:
"""Compare a supplied token in constant time.
Raises:
ApiError: ``bad_token`` for a missing or wrong token.
"""
if not supplied or not hmac.compare_digest(supplied.encode(), token.encode()):
msg = "Missing or stale token: reload the page."
raise ApiError(msg, code="bad_token")
def inject_token(html: bytes, token: str) -> bytes:
"""Insert the token ``<meta>`` right after ``<head>`` in *html*."""
meta = f'<meta name="{AUTH_META}" content="{token}">'.encode("ascii")
match = _HEAD_TAG.search(html)
if match is None:
return meta + html
return html[: match.end()] + meta + html[match.end() :]

View File

@ -0,0 +1,153 @@
"""The root daemon as the web API sees it: status, and the two-phase reset.
Every call goes through :func:`daemon_call`, which turns the client's
``DaemonUnreachableError`` / ``CtlError`` into contract errors. Calls block for
as long as the daemon takes (up to its own timeouts); each request has its
own thread (``ThreadingHTTPServer``), so a slow op stalls nobody else.
The daemon answers the two-phase reset with ``pending_id`` and an extra
``lapse_after_seconds``; :func:`pending_action` maps that onto the
contract's ``PendingAction``.
"""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer import _ctl_client
from steam_backlog_enforcer._ctl_protocol import CtlError, DaemonUnreachableError
from steam_backlog_enforcer._web_errors import (
ApiError,
daemon_unreachable,
from_ctl_error,
)
from steam_backlog_enforcer._web_io import Reply, no_content, ok
from steam_backlog_enforcer.jobs._daemon_jobs import (
EXIT_FAILED,
EXIT_OK,
DaemonOutcome,
record_daemon_job,
)
from steam_backlog_enforcer.jobs._events import now_iso
from steam_backlog_enforcer.jobs._view import read_job
if TYPE_CHECKING:
from collections.abc import Callable
from steam_backlog_enforcer._ctl_types import PendingReset
from steam_backlog_enforcer._web_io import Request
_JOURNAL_LINES: Final = 50
_RESET: Final = "gaming-reset"
def daemon_call[**P, R](fn: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
"""Run one control-socket call, mapping its failures to :class:`ApiError`.
Raises:
ApiError: ``daemon_unreachable`` (503) or the daemon's own code.
"""
try:
return fn(*args, **kwargs)
except DaemonUnreachableError as exc:
raise daemon_unreachable(str(exc)) from None
except CtlError as exc:
raise from_ctl_error(exc) from None
def daemon_view(_request: Request) -> Reply:
"""``GET /api/daemon`` — ``state: 'unreachable'`` when nobody answers."""
try:
info = _ctl_client.status()
except DaemonUnreachableError:
return ok(
{
"state": "unreachable",
"started_at": None,
"pid": None,
"journal_tail": [],
"restart_available_at": None,
}
)
except CtlError as exc:
raise from_ctl_error(exc) from None
try:
journal = _ctl_client.journal_tail(_JOURNAL_LINES)
except DaemonUnreachableError, CtlError:
journal = [] # The status still stands without its log.
return ok(
{
"state": info.state,
"started_at": info.started_at,
"pid": info.pid,
"journal_tail": journal,
"restart_available_at": info.restart_available_at,
}
)
def pending_action(pending: PendingReset) -> dict[str, object]:
"""The contract ``PendingAction`` for an armed gaming reset."""
return {
"id": pending.pending_id,
"command": _RESET,
"phrase": pending.phrase,
"armed_at": pending.armed_at,
"ready_at": pending.ready_at,
"heartbeat_interval_seconds": pending.heartbeat_interval_seconds,
}
def confirm_phrase_of(request: Request) -> str:
"""The typed phrase from a body (``confirm_phrase``; ``confirmPhrase`` too)."""
raw = request.body.get("confirm_phrase", request.body.get("confirmPhrase"))
if raw is None:
return ""
if not isinstance(raw, str):
msg = "confirm_phrase must be a string."
raise ApiError(msg, code="invalid_params")
return raw
def heartbeat_view(request: Request) -> Reply:
"""``POST /api/pending/{id}/heartbeat`` — keep the countdown alive."""
pending = daemon_call(_ctl_client.gaming_reset_heartbeat, request.args[0])
return ok(pending_action(pending))
def cancel_pending_view(request: Request) -> Reply:
"""``DELETE /api/pending/{id}`` — abandon the armed reset."""
daemon_call(_ctl_client.gaming_reset_cancel, request.args[0])
return no_content()
def commit_view(request: Request) -> Reply:
"""``POST /api/pending/{id}/commit`` — run the reset, record it as a job."""
created_at = now_iso()
try:
result = daemon_call(
_ctl_client.gaming_reset_commit,
request.args[0],
confirm_phrase_of(request),
)
except ApiError as exc:
if exc.code == "op_failed":
failed = DaemonOutcome(ok=False, summary=exc.message, exit_code=EXIT_FAILED)
record_daemon_job(_RESET, {}, created_at=created_at, outcome=failed)
raise
minutes = result.seconds_before / 60
summary = f"Today's gaming budget reset ({minutes:.0f} min billed before)."
done = DaemonOutcome(
ok=True,
summary=summary,
exit_code=EXIT_OK,
data={
"day_key": result.day_key,
"seconds_before": result.seconds_before,
"was_blocked": result.was_blocked,
"released": result.released,
},
)
job_id = record_daemon_job(_RESET, {}, created_at=created_at, outcome=done)
return Reply(HTTPStatus.CREATED, read_job(job_id))

View File

@ -0,0 +1,105 @@
"""The web API's error type and how every lower-level error maps onto it.
Every refusal leaves the server as ``{"error": "<code>", "message": "…"}``
with the status below (``DOCS-web-control-api.md``, "Endpoints"). The job
store raises :class:`~steam_backlog_enforcer.jobs._errors.JobError` and the
daemon client raises :class:`~steam_backlog_enforcer._ctl_protocol.CtlError`
/ ``DaemonUnreachableError``; the converters here are the only place their codes
become HTTP statuses, so one code always means one status.
"""
from __future__ import annotations
from http import HTTPStatus
import math
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from steam_backlog_enforcer._ctl_protocol import CtlError
from steam_backlog_enforcer.jobs._errors import JobError
STATUS_BY_CODE: Final[dict[str, HTTPStatus]] = {
"bad_token": HTTPStatus.FORBIDDEN,
"bad_origin": HTTPStatus.FORBIDDEN,
"bad_host": HTTPStatus.FORBIDDEN,
"unknown_command": HTTPStatus.BAD_REQUEST,
"invalid_params": HTTPStatus.BAD_REQUEST,
"wrong_phrase": HTTPStatus.BAD_REQUEST,
"not_found": HTTPStatus.NOT_FOUND,
"locked": HTTPStatus.CONFLICT,
"busy": HTTPStatus.CONFLICT,
"countdown_running": HTTPStatus.CONFLICT,
"not_cancellable": HTTPStatus.CONFLICT,
"pending_lapsed": HTTPStatus.GONE,
"rate_limited": HTTPStatus.TOO_MANY_REQUESTS,
"daemon_unreachable": HTTPStatus.SERVICE_UNAVAILABLE,
"server_stale": HTTPStatus.SERVICE_UNAVAILABLE,
"op_failed": HTTPStatus.BAD_GATEWAY,
"unsupported": HTTPStatus.NOT_IMPLEMENTED,
}
class ApiError(Exception):
"""A request the server answers with a contract error body.
Attributes:
code: The contract's ``ApiErrorCode``.
message: Human text for the body.
status: HTTP status (defaults to the code's entry in the table).
retry_after: Seconds the client should wait (``Retry-After``), if any.
"""
def __init__(
self,
message: str,
*,
code: str,
status: HTTPStatus | None = None,
retry_after: float | None = None,
) -> None:
"""Store the code, message, status and optional retry delay."""
super().__init__(f"{code}: {message}")
self.code = code
self.message = message
self.status = status or STATUS_BY_CODE.get(code, HTTPStatus.BAD_GATEWAY)
self.retry_after = retry_after
def body(self) -> dict[str, object]:
"""The JSON object sent to the client."""
out: dict[str, object] = {"error": self.code, "message": self.message}
if self.retry_after is not None:
out["retry_after_seconds"] = self.retry_after
return out
def headers(self) -> dict[str, str]:
"""Extra response headers (``Retry-After`` when a delay is known)."""
if self.retry_after is None:
return {}
return {"Retry-After": str(max(1, math.ceil(self.retry_after)))}
def not_found(message: str) -> ApiError:
"""A ``not_found`` 404 for an unknown job, backup or endpoint."""
return ApiError(message, code="not_found")
def from_job_error(exc: JobError) -> ApiError:
"""Convert a job-store refusal (an unknown job id is ``not_found``)."""
return ApiError(exc.message, code=exc.code)
def from_ctl_error(exc: CtlError) -> ApiError:
"""Convert a daemon refusal; codes the contract lacks become ``op_failed``."""
code = exc.code if exc.code in STATUS_BY_CODE else "op_failed"
retry = exc.data.get("retry_after_seconds") if exc.data else None
return ApiError(
exc.message or exc.code,
code=code,
retry_after=float(retry) if isinstance(retry, int | float) else None,
)
def daemon_unreachable(detail: str) -> ApiError:
"""The 503 for a daemon that did not answer on its socket."""
msg = f"The enforcer daemon is not answering on its control socket ({detail})."
return ApiError(msg, code="daemon_unreachable")

View File

@ -0,0 +1,163 @@
"""Request and reply plumbing shared by every API route.
Routes are plain functions ``(Request) -> Reply``; this module turns the raw
``BaseHTTPRequestHandler`` into a :class:`Request` (body size cap, JSON
content-type check) and writes a :class:`Reply` or an
:class:`~steam_backlog_enforcer._web_errors.ApiError` back. No CORS headers
are ever sent: the API is same-origin only.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from http import HTTPStatus
import json
from typing import TYPE_CHECKING, Any, Final
from steam_backlog_enforcer._web_errors import ApiError
if TYPE_CHECKING:
from email.message import Message
from http.server import BaseHTTPRequestHandler
# Requests are a handful of short fields; the cap stops a client from
# making the server buffer an arbitrary upload.
MAX_BODY_BYTES: Final = 64 * 1024
@dataclass(frozen=True)
class Request:
"""One parsed API request.
Attributes:
method: ``GET``, ``POST`` or ``DELETE``.
path: URL path without the query.
query: Parsed query string (first value per key).
args: Path segments captured by the route pattern.
headers: The raw request headers.
body: Parsed JSON object body (empty when there was none).
"""
method: str
path: str
query: dict[str, str]
args: tuple[str, ...]
headers: Message
body: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class Reply:
"""A route's answer: a status and an optional JSON payload.
A route that answers with something other than JSON (cover art) sets
*raw* and *ctype* instead; *headers* are sent as they are.
"""
status: HTTPStatus
payload: object = None
raw: bytes | None = None
ctype: str = ""
headers: dict[str, str] = field(default_factory=dict)
def ok(payload: object) -> Reply:
"""A ``200`` with *payload* as JSON."""
return Reply(HTTPStatus.OK, payload)
def no_content() -> Reply:
"""A ``204`` with no body."""
return Reply(HTTPStatus.NO_CONTENT)
def read_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
"""Read and parse the request body; an absent body is ``{}``.
Raises:
ApiError: ``invalid_params`` (413/415/400) for an oversized,
non-JSON or malformed body.
"""
if handler.headers.get("Transfer-Encoding"):
msg = "Chunked request bodies are not accepted; send Content-Length."
raise ApiError(msg, code="invalid_params", status=HTTPStatus.LENGTH_REQUIRED)
try:
length = int(handler.headers.get("Content-Length") or 0)
except ValueError:
msg = "Content-Length is not a number."
raise ApiError(msg, code="invalid_params") from None
if length <= 0:
return {}
if length > MAX_BODY_BYTES:
msg = f"Request body over {MAX_BODY_BYTES} bytes."
raise ApiError(
msg, code="invalid_params", status=HTTPStatus.REQUEST_ENTITY_TOO_LARGE
)
ctype = (handler.headers.get("Content-Type") or "").split(";", 1)[0].strip()
if ctype.lower() != "application/json":
msg = "Request bodies must be application/json."
raise ApiError(
msg, code="invalid_params", status=HTTPStatus.UNSUPPORTED_MEDIA_TYPE
)
try:
body = json.loads(handler.rfile.read(length))
except ValueError:
msg = "Request body is not valid JSON."
raise ApiError(msg, code="invalid_params") from None
if not isinstance(body, dict):
msg = "Request body must be a JSON object."
raise ApiError(msg, code="invalid_params")
return body
def send_bytes(
handler: BaseHTTPRequestHandler,
status: HTTPStatus,
body: bytes,
ctype: str,
headers: dict[str, str] | None = None,
) -> None:
"""Write a complete response with a fixed-length body."""
handler.send_response(status)
handler.send_header("Content-Type", ctype)
handler.send_header("Content-Length", str(len(body)))
handler.send_header("X-Content-Type-Options", "nosniff")
for name, value in (headers or {}).items():
handler.send_header(name, value)
handler.end_headers()
if body:
handler.wfile.write(body)
def send_reply(handler: BaseHTTPRequestHandler, reply: Reply) -> None:
"""Write a route's :class:`Reply` (``None`` payload means no body)."""
if reply.raw is not None:
send_bytes(handler, reply.status, reply.raw, reply.ctype, reply.headers)
return
if reply.payload is None:
# 202/204 without a body: no Content-Type, nothing to sniff.
handler.send_response(reply.status)
handler.send_header("Content-Length", "0")
handler.end_headers()
return
body = json.dumps(reply.payload).encode("utf-8")
send_bytes(
handler,
reply.status,
body,
"application/json; charset=utf-8",
{"Cache-Control": "no-store"},
)
def send_error(handler: BaseHTTPRequestHandler, error: ApiError) -> None:
"""Write an :class:`ApiError` as the contract's error body."""
body = json.dumps(error.body()).encode("utf-8")
send_bytes(
handler,
error.status,
body,
"application/json; charset=utf-8",
{"Cache-Control": "no-store", **error.headers()},
)

View File

@ -0,0 +1,151 @@
"""Job routes: list, create, read, answer, cancel, and backup restore.
``POST /api/jobs`` decides where a command runs *before* the job store sees
it: privileged commands go to the root daemon
(:func:`._web_privileged.run_privileged`), everything else becomes a job
subprocess. Deciding first matters because the store guards jobs with the
one-mutating-job lock, and an emergency ``gaming-unblock`` must not be
refused as ``busy`` because a scan is running.
"""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING, Any
from steam_backlog_enforcer._backups import list_backups
from steam_backlog_enforcer._command_params import InvalidParamsError, validate_params
from steam_backlog_enforcer._friction import expected_phrase, phrase_matches
from steam_backlog_enforcer._web_daemon import confirm_phrase_of
from steam_backlog_enforcer._web_errors import ApiError, from_job_error, not_found
from steam_backlog_enforcer._web_io import Reply, no_content, ok
from steam_backlog_enforcer._web_privileged import run_privileged
from steam_backlog_enforcer.jobs._control import cancel, write_answer
from steam_backlog_enforcer.jobs._errors import JobError
from steam_backlog_enforcer.jobs._specs import JOB_FLAGS, is_privileged
from steam_backlog_enforcer.jobs._store import create_job
from steam_backlog_enforcer.jobs._view import list_jobs, read_job
if TYPE_CHECKING:
from steam_backlog_enforcer._web_io import Request
# The UI's enforce modes onto the job registry's ``demo`` flag; ``restart``
# (demo 0) is the daemon's.
_ENFORCE_MODES = {"demo": 1, "restart": 0}
def _invalid(message: str) -> ApiError:
"""An ``invalid_params`` 400."""
return ApiError(message, code="invalid_params")
def _enforce_params(params: dict[str, Any]) -> dict[str, Any]:
"""Translate ``{"mode": "demo"|"restart"}`` into the registry's ``demo``.
Raises:
ApiError: ``invalid_params`` for a missing or unknown mode.
"""
if "mode" not in params and "demo" in params:
return params
mode = params.get("mode")
if set(params) - {"mode"} or mode not in _ENFORCE_MODES:
msg = "enforce needs params.mode: 'restart' or 'demo'."
raise _invalid(msg)
return {"demo": _ENFORCE_MODES[str(mode)]}
def _job_request(request: Request) -> tuple[str, dict[str, Any], str | None]:
"""Unpack a ``JobRequest`` body.
Raises:
ApiError: ``invalid_params`` for a malformed body.
"""
command, params = request.body.get("command"), request.body.get("params", {})
phrase = request.body.get("confirm_phrase")
if not isinstance(command, str) or not isinstance(params, dict):
msg = "Body must be {command: string, params: object}."
raise _invalid(msg)
if phrase is not None and not isinstance(phrase, str):
msg = "confirm_phrase must be a string."
raise _invalid(msg)
if command == "enforce":
params = _enforce_params(params)
return command, params, phrase
def jobs_view(_request: Request) -> Reply:
"""``GET /api/jobs`` — newest first, at most 50."""
return ok(list_jobs())
def job_view(request: Request) -> Reply:
"""``GET /api/jobs/{id}``."""
try:
return ok(read_job(request.args[0]))
except JobError as exc:
raise from_job_error(exc) from None
def create_job_view(request: Request) -> Reply:
"""``POST /api/jobs`` — ``201`` Job, or ``202`` PendingAction (reset)."""
command, params, phrase = _job_request(request)
if command not in JOB_FLAGS:
msg = f"{command!r} does not run as a job."
raise ApiError(msg, code="unknown_command")
try:
clean = validate_params(command, params)
except InvalidParamsError as exc:
raise _invalid(str(exc)) from None
if is_privileged(command, clean):
return run_privileged(command, clean, phrase)
try:
job_id = create_job(command, clean, confirm_phrase=phrase)
except JobError as exc:
raise from_job_error(exc) from None
return Reply(HTTPStatus.CREATED, read_job(job_id))
def answer_view(request: Request) -> Reply:
"""``POST /api/jobs/{id}/answer`` — deliver a ``PromptAnswer``."""
prompt_id, value = request.body.get("prompt_id"), request.body.get("value")
if not isinstance(prompt_id, str) or not isinstance(value, str):
msg = "Body must be {prompt_id: string, value: string}."
raise _invalid(msg)
try:
write_answer(request.args[0], prompt_id, value)
except JobError as exc:
raise from_job_error(exc) from None
return no_content()
def cancel_view(request: Request) -> Reply:
"""``POST /api/jobs/{id}/cancel`` — only for cancellable commands."""
try:
cancel(request.args[0])
except JobError as exc:
raise from_job_error(exc) from None
return no_content()
def restore_backup_view(request: Request) -> Reply:
"""``POST /api/backups/{id}/restore`` — phrase checked here, then a job.
The job re-checks the same phrase (it arrives as the job's preset
answer), so neither layer trusts the other.
"""
backup_id = request.args[0]
if backup_id not in {backup.id for backup in list_backups()}:
msg = f"No such backup: {backup_id!r}"
raise not_found(msg)
typed = confirm_phrase_of(request)
expected = expected_phrase("restore-backup", backup_id=backup_id) or ""
if not phrase_matches(expected, typed):
msg = f'Type "{expected}" to restore this backup.'
raise ApiError(msg, code="wrong_phrase")
try:
job_id = create_job(
"restore-backup", {"backup_id": backup_id}, confirm_phrase=typed
)
except JobError as exc:
raise from_job_error(exc) from None
return Reply(HTTPStatus.CREATED, read_job(job_id))

View File

@ -0,0 +1,169 @@
"""``GET /api/library``: every owned game, for the web library browser.
Joins the owned-games records (names, playtime, last played: see
:mod:`._owned_apps_cache`), the achievement snapshot, the HLTB cache, the
installed manifests and the state. Eligibility for "pick my own game" comes
from :func:`steam_backlog_enforcer._own_pick.ineligible_reason`, the same
rule the job enforces on the answer.
Reads local files only, except once: when no refresh has stored owned-game
records yet, it asks Steam for them (the snapshot alone misses every game
without achievements).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from steam_backlog_enforcer import config as _config
from steam_backlog_enforcer._actions import allowed_app_ids
from steam_backlog_enforcer._hltb_types import _read_raw_cache
from steam_backlog_enforcer._own_pick import ineligible_reason
from steam_backlog_enforcer._owned_apps_cache import (
load_owned_records,
owned_cache_stamp,
refresh_owned_records,
)
from steam_backlog_enforcer._snapshot import load_snapshot
from steam_backlog_enforcer._web_io import ok
from steam_backlog_enforcer.config import Config, State
from steam_backlog_enforcer.game_install import get_installed_games
from steam_backlog_enforcer.steam_api import GameInfo
if TYPE_CHECKING:
from steam_backlog_enforcer._web_io import Reply, Request
@dataclass(frozen=True)
class _SnapshotMemo:
"""The snapshot as achievement-free ``GameInfo``s, keyed by file stamp."""
stamp: tuple[int, int]
games: dict[int, GameInfo]
@dataclass
class _Memos:
"""The process-wide memos, each keyed by the stamp of its source file."""
snapshot: _SnapshotMemo | None = None
owned_ids: tuple[tuple[int, int], set[int]] | None = None
_MEMOS = _Memos()
def _snapshot_games() -> dict[int, GameInfo]:
"""Snapshot rows by app id; re-parsed only when the file changes.
The snapshot is ~11 MB of per-achievement detail the library never
shows, so one parse per file version, not one per request.
"""
try:
st = _config.SNAPSHOT_FILE.stat()
except OSError:
return {}
stamp = (st.st_mtime_ns, st.st_size)
memo = _MEMOS.snapshot
if memo is None or memo.stamp != stamp:
games = {
int(row["app_id"]): GameInfo(
app_id=int(row["app_id"]),
name=str(row.get("name") or ""),
total_achievements=int(row.get("total_achievements") or 0),
unlocked_achievements=int(row.get("unlocked_achievements") or 0),
playtime_minutes=int(row.get("playtime_minutes") or 0),
completionist_hours=float(row.get("completionist_hours") or -1),
)
for row in load_snapshot() or []
if "app_id" in row
}
memo = _MEMOS.snapshot = _SnapshotMemo(stamp, games)
return memo.games
def _owned_records(config: Config) -> dict[int, dict[str, Any]]:
"""Owned-game records by app id; fetched once when none are cached."""
records = load_owned_records(config.steam_id)
if records is None:
records = refresh_owned_records(config) or []
return {int(r["app_id"]): r for r in records if "app_id" in r}
def _hltb_hours(
app_id: int, game: GameInfo | None, hltb: dict[int, Any]
) -> float | None:
"""Completionist hours: the HLTB cache, else the snapshot, else unknown."""
hours = float(hltb.get(app_id, {}).get("hours") or -1)
if hours <= 0 and game is not None:
hours = game.completionist_hours
return round(hours, 1) if hours > 0 else None
def owned_app_ids() -> set[int]:
"""Every app id the library can list (the art route serves only these).
Never asks Steam: an image request must not turn into an API call. Kept
per owned-cache file version, since a grid load asks once per cover.
"""
stamp = owned_cache_stamp()
snapshot = _snapshot_games()
memo = _MEMOS.owned_ids
if memo is None or memo[0] != stamp:
records = load_owned_records(Config.load().steam_id) or []
ids = {int(r["app_id"]) for r in records if "app_id" in r}
memo = _MEMOS.owned_ids = (stamp, ids)
return set(snapshot) | memo[1]
def _reason(
app_id: int,
game: GameInfo | None,
record: dict[str, Any],
state: State,
skipped: set[int],
) -> str | None:
""":func:`ineligible_reason`, saying *why* a game has no scan data."""
if game is None and record.get("has_stats") is False:
return "No achievements"
if game is None and record.get("has_stats"):
return "Not scanned yet"
return ineligible_reason(app_id, game, state, skipped)
def library_rows(state: State, config: Config) -> list[dict[str, object]]:
"""One contract ``LibraryGame`` per owned game, in no particular order."""
snapshot = _snapshot_games()
records = _owned_records(config)
hltb = _read_raw_cache()
installed = {app_id for app_id, _ in get_installed_games()}
allowed = allowed_app_ids(state)
skipped = state.active_skipped_ids()
rows: list[dict[str, object]] = []
for app_id in records.keys() | snapshot.keys():
game = snapshot.get(app_id)
record = records.get(app_id, {})
rows.append(
{
"app_id": app_id,
"name": record.get("name") or (game.name if game else str(app_id)),
"achievements_total": game.total_achievements if game else 0,
"achievements_unlocked": game.unlocked_achievements if game else 0,
"hltb_hours": _hltb_hours(app_id, game, hltb),
"playtime_minutes": int(
record.get("playtime_minutes")
or (game.playtime_minutes if game else 0)
),
"last_played": int(record.get("last_played") or 0) or None,
"installed": app_id in installed,
"assigned": app_id in allowed,
"ineligible_reason": _reason(app_id, game, record, state, skipped),
}
)
return rows
def library_view(_request: Request) -> Reply:
"""``GET /api/library`` — every owned game as ``LibraryPayload``."""
return ok({"games": library_rows(State.load(), Config.load())})

View File

@ -0,0 +1,141 @@
"""Privileged commands: ``POST /api/jobs`` requests the root daemon carries out.
The web server never gets root, so ``unblock``, ``buy-dlc``, ``block-gaming``,
``gaming-unblock``, ``gaming-reset`` and the real ``enforce`` restart become
control-socket ops. The typed phrase is passed through untouched: the daemon
is the trust boundary and checks it, along with the locks and caps.
A completed op is recorded as a finished job (:mod:`.jobs._daemon_jobs`) so
it shows in the same history as everything else; so is an op the daemon
ran and failed (``op_failed``). Refusals — wrong phrase, locked, rate limited,
daemon unreachable — are answered as errors and leave no job behind.
``gaming-reset`` answers ``202`` with a ``PendingAction`` instead: its job is
written when it is committed (:func:`._web_daemon.commit_view`).
"""
from __future__ import annotations
from datetime import datetime
from http import HTTPStatus
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer import _ctl_client
from steam_backlog_enforcer._store_window import DEFAULT_WINDOW_MINUTES
from steam_backlog_enforcer._web_daemon import daemon_call, pending_action
from steam_backlog_enforcer._web_errors import ApiError
from steam_backlog_enforcer._web_io import Reply
from steam_backlog_enforcer.jobs._daemon_jobs import (
EXIT_FAILED,
EXIT_OK,
DaemonOutcome,
record_daemon_job,
)
from steam_backlog_enforcer.jobs._events import now_iso
from steam_backlog_enforcer.jobs._view import read_job
if TYPE_CHECKING:
from collections.abc import Callable, Mapping
from steam_backlog_enforcer._command_params import ParamValue
type Outcome = tuple[str, dict[str, object]]
type Op = Callable[[Mapping[str, ParamValue], str], Outcome]
# buy-dlc has no knobs: always the default-length store window.
BUY_DLC_MINUTES: Final = DEFAULT_WINDOW_MINUTES
def _local_hhmm(iso: str | None) -> str:
"""``HH:MM`` local time of an ISO timestamp (the raw text if unparsable)."""
if not iso:
return "?"
try:
return datetime.fromisoformat(iso).astimezone().strftime("%H:%M")
except ValueError:
return iso
def _store_window(minutes: int, phrase: str) -> Outcome:
"""Open the store for *minutes*."""
window = daemon_call(_ctl_client.store_unblock, minutes, phrase)
summary = (
f"Store unblocked for {window.minutes} min, until {_local_hhmm(window.until)}."
)
return summary, {"minutes": window.minutes, "until": window.until}
def _unblock(params: Mapping[str, ParamValue], phrase: str) -> Outcome:
"""``unblock`` — a 1-30 minute store window."""
return _store_window(int(params["minutes"]), phrase)
def _buy_dlc(_params: Mapping[str, ParamValue], phrase: str) -> Outcome:
"""``buy-dlc`` — the fixed default-length store window."""
return _store_window(BUY_DLC_MINUTES, phrase)
def _block_gaming(params: Mapping[str, ParamValue], phrase: str) -> Outcome:
"""``block-gaming`` — the irreversible total block."""
result = daemon_call(_ctl_client.block_gaming, int(params["days"]), phrase)
summary = f"Total gaming block active for {result.days} day(s)."
return summary, {"days": result.days, "until": result.until}
def _gaming_unblock(_params: Mapping[str, ParamValue], phrase: str) -> Outcome:
"""``gaming-unblock`` — force-release the playtime mounts."""
released = daemon_call(_ctl_client.gaming_unblock, phrase)
return f"Released {len(released)} playtime mount(s).", {"released": released}
def _restart(_params: Mapping[str, ParamValue], _phrase: str) -> Outcome:
"""``enforce`` (restart) — flush, exit, come back under systemd."""
available = daemon_call(_ctl_client.restart)
summary = "The enforcer daemon is restarting (state flushed first)."
return summary, {"restart_available_at": available}
OPS: Final[dict[str, Op]] = {
"unblock": _unblock,
"buy-dlc": _buy_dlc,
"block-gaming": _block_gaming,
"gaming-unblock": _gaming_unblock,
"enforce": _restart,
}
def run_privileged(
command: str, params: Mapping[str, ParamValue], phrase: str | None
) -> Reply:
"""Carry out a privileged command through the daemon.
Args:
command: Canonical command name.
params: Params already validated against the command's spec.
phrase: ``JobRequest.confirm_phrase`` (checked by the daemon).
Returns:
``201`` with the recorded ``Job``, or ``202`` with a
``PendingAction`` for ``gaming-reset``.
Raises:
ApiError: The daemon's refusal, mapped to its HTTP status.
"""
typed = phrase or ""
if command == "gaming-reset":
pending = daemon_call(_ctl_client.gaming_reset_arm, typed)
return Reply(HTTPStatus.ACCEPTED, pending_action(pending))
op = OPS.get(command)
if op is None:
msg = f"{command!r} is not a daemon command."
raise ApiError(msg, code="unknown_command")
created_at = now_iso()
try:
summary, data = op(params, typed)
except ApiError as exc:
if exc.code == "op_failed":
failed = DaemonOutcome(ok=False, summary=exc.message, exit_code=EXIT_FAILED)
record_daemon_job(command, params, created_at=created_at, outcome=failed)
raise
done = DaemonOutcome(ok=True, summary=summary, exit_code=EXIT_OK, data=data)
job_id = record_daemon_job(command, params, created_at=created_at, outcome=done)
return Reply(HTTPStatus.CREATED, read_job(job_id))

View File

@ -0,0 +1,116 @@
"""The server process itself: when it started, what code it runs, stand-down.
``ServerHealth`` (``GET /api/server``) and ``POST /api/server/restart`` live
here, next to the retirement switch the stale-code check
(:mod:`steam_backlog_enforcer._serve_stale`) already used: a restart is the
same stand-down, asked for instead of detected. Exiting is enough because
``steam-backlog-enforcer-web.service`` has ``Restart=always``; jobs survive
it, being their own processes in their own systemd scopes.
"""
from __future__ import annotations
from datetime import UTC, datetime
from functools import cache
from http import HTTPStatus
import os
from pathlib import Path
import shutil
import subprocess
import threading
import time
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._serve_stale import newest_py_after, outdated_source
from steam_backlog_enforcer._web_errors import ApiError
from steam_backlog_enforcer._web_io import Reply, ok
if TYPE_CHECKING:
from socketserver import BaseServer
from steam_backlog_enforcer._web_io import Request
# Epoch seconds this process began, captured at import. Anything in the
# package newer than this is code we did not load.
STARTED_AT: Final = time.time()
# Set once the server has begun standing down, so concurrent requests do
# not each spawn a shutdown thread (and SSE streams know to end).
RETIRING: Final = threading.Event()
# Long enough for the 202 to leave before the process goes.
_RESTART_DELAY_SECONDS: Final = 0.5
_REPO_ROOT: Final = Path(__file__).resolve().parent.parent
_server: list[BaseServer] = []
def register(server: BaseServer) -> None:
"""Remember the running server so :func:`retire` can stop it."""
_server[:] = [server]
def retire(delay: float = 0.0) -> None:
"""Stop serving (once) so the supervisor starts current code.
``shutdown`` blocks until ``serve_forever`` returns and cannot run on a
request thread, so it gets its own; *delay* lets the reply that asked
for it reach the client first.
"""
if RETIRING.is_set() or not _server:
return
RETIRING.set()
timer = threading.Timer(delay, _server[0].shutdown)
timer.daemon = True
timer.start()
def _iso(epoch: float) -> str:
"""Epoch seconds as ISO-8601 UTC with a ``Z`` suffix."""
return datetime.fromtimestamp(epoch, UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
@cache
def version() -> str:
"""The git short sha of the checkout, else the newest source mtime."""
git = shutil.which("git")
if git is not None:
try:
out = subprocess.run(
[git, "-C", str(_REPO_ROOT), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
timeout=5,
check=True,
)
except OSError, subprocess.SubprocessError:
pass
else:
return out.stdout.strip()
newest = newest_py_after(0.0)
return _iso(newest.stat().st_mtime) if newest else "unknown"
def health() -> dict[str, object]:
"""The contract ``ServerHealth``."""
return {
"stale": outdated_source(STARTED_AT) is not None,
"started_at": _iso(STARTED_AT),
"version": version(),
}
def health_view(_request: Request) -> Reply:
"""``GET /api/server``."""
return ok(health())
def restart_view(_request: Request) -> Reply:
"""``POST /api/server/restart`` — answer 202, then exit for systemd.
Raises:
ApiError: ``unsupported`` when no supervisor would bring the server
back (started by hand, the restart would be a stop).
"""
if not os.environ.get("INVOCATION_ID"):
msg = "This server is not running under systemd; a restart would stop it."
raise ApiError(msg, code="unsupported")
retire(_RESTART_DELAY_SECONDS)
return Reply(HTTPStatus.ACCEPTED)

View File

@ -0,0 +1,93 @@
"""The API's route table: method + path pattern → route function.
One table, so the whole surface (``DOCS-web-control-api.md``, "Endpoints")
can be read in one screen. Path ids are restricted to the characters the
stores issue; the stores re-validate them anyway.
"""
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer import (
_web_art,
_web_daemon,
_web_jobs,
_web_library,
_web_process,
_web_setup,
_web_views,
)
if TYPE_CHECKING:
from collections.abc import Callable
from steam_backlog_enforcer._web_io import Reply, Request
type Route = Callable[[Request], Reply]
_ID = r"([0-9A-Za-z_-]{1,64})"
# Matched before the table by the server: it streams instead of replying.
EVENTS_PATH: Final = re.compile(rf"^/api/jobs/{_ID}/events$")
@dataclass(frozen=True)
class _Entry:
"""One route."""
method: str
pattern: re.Pattern[str]
route: Route
def _r(method: str, path: str, route: Route) -> _Entry:
"""Build an entry; ``{id}`` in *path* captures one id segment."""
return _Entry(method, re.compile("^" + path.replace("{id}", _ID) + "$"), route)
ROUTES: Final[tuple[_Entry, ...]] = (
_r("GET", "/api/dataset", _web_views.dataset_view),
_r("GET", "/api/budget", _web_views.budget_view),
_r("GET", "/api/status", _web_views.status_view),
_r("GET", "/api/stats", _web_views.stats_view),
_r("GET", "/api/installed", _web_views.installed_view),
_r("GET", "/api/library", _web_library.library_view),
_r("GET", "/api/art/{id}", _web_art.art_view),
_r("GET", "/api/commands", _web_views.commands_view),
_r("GET", "/api/setup", _web_views.setup_view),
_r("POST", "/api/setup", _web_setup.save_setup),
_r("GET", "/api/daemon", _web_daemon.daemon_view),
_r("GET", "/api/server", _web_process.health_view),
_r("POST", "/api/server/restart", _web_process.restart_view),
_r("GET", "/api/backups", _web_views.backups_view),
_r("POST", "/api/backups/{id}/restore", _web_jobs.restore_backup_view),
_r("GET", "/api/jobs", _web_jobs.jobs_view),
_r("POST", "/api/jobs", _web_jobs.create_job_view),
_r("GET", "/api/jobs/{id}", _web_jobs.job_view),
_r("POST", "/api/jobs/{id}/answer", _web_jobs.answer_view),
_r("POST", "/api/jobs/{id}/cancel", _web_jobs.cancel_view),
_r("POST", "/api/pending/{id}/heartbeat", _web_daemon.heartbeat_view),
_r("POST", "/api/pending/{id}/commit", _web_daemon.commit_view),
_r("DELETE", "/api/pending/{id}", _web_daemon.cancel_pending_view),
)
def resolve(method: str, path: str) -> tuple[Route, tuple[str, ...]] | str | None:
"""Find the route for *method* and *path*.
Returns:
``(route, captured ids)`` on a match; the string ``"method"`` when
the path exists under another method (405); ``None`` when no route
has this path (404).
"""
path_known = False
for entry in ROUTES:
match = entry.pattern.fullmatch(path)
if match is None:
continue
if entry.method == method:
return entry.route, match.groups()
path_known = True
return "method" if path_known else None

View File

@ -1,10 +1,13 @@
"""Minimal read-only localhost HTTP server for the interactive web UI. """Localhost HTTP server for the web UI: the control API and the React bundle.
Serves the projected dataset at ``GET /api/dataset`` and the built React A thin router. Per request it, in order: checks ``Host`` (every route — the
bundle (``web/dist``) as static files. Binds to localhost only and never DNS-rebinding guard, :mod:`._web_auth`), fails closed on stale code
exposes secrets: the payload comes from :func:`build_web_dataset`, which reads (:mod:`._serve_stale`), checks ``Origin`` and the per-launch token on
the data caches but never ``config.json``. anything that is not a GET (and on the SSE stream), then hands off to a
route from :mod:`._web_routes`, the SSE streamer (:mod:`._web_sse`) or the
static bundle (``web/dist``, with the token injected into ``index.html``).
Binds to loopback only; never serves secrets and never sends CORS headers.
In development the Vite dev server proxies ``/api`` here; in production the In development the Vite dev server proxies ``/api`` here; in production the
``serve`` command serves the built bundle and the API from one process. ``serve`` command serves the built bundle and the API from one process.
""" """
@ -13,18 +16,23 @@ from __future__ import annotations
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import logging import logging
import mimetypes import mimetypes
from pathlib import Path from pathlib import Path
import threading from urllib.parse import parse_qs, urlsplit
import time
from urllib.parse import urlsplit
from steam_backlog_enforcer._budget_view import build_budget_snapshot from steam_backlog_enforcer import _web_auth, _web_process
from steam_backlog_enforcer._serve_stale import outdated_source from steam_backlog_enforcer._serve_stale import outdated_source
from steam_backlog_enforcer._web_dataset import build_web_dataset, dataset_to_payload from steam_backlog_enforcer._web_errors import ApiError, not_found
from steam_backlog_enforcer.config import State from steam_backlog_enforcer._web_io import (
Request,
read_json_body,
send_bytes,
send_error,
send_reply,
)
from steam_backlog_enforcer._web_routes import EVENTS_PATH, resolve
from steam_backlog_enforcer._web_sse import stream_job_events
from steam_backlog_enforcer.game_install import _echo from steam_backlog_enforcer.game_install import _echo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,132 +42,178 @@ WEB_DIST = (Path(__file__).resolve().parent.parent / "web" / "dist").resolve()
DEFAULT_HOST = "127.0.0.1" DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8000 DEFAULT_PORT = 8000
_API_DATASET = "/api/dataset" _INDEX = "index.html"
_API_BUDGET = "/api/budget"
# Content types that are text but not under the ``text/`` prefix.
_EXTRA_TEXT_TYPES = frozenset(
{"application/javascript", "application/json", "image/svg+xml"}
)
_NOT_BUILT_MSG = b"Frontend not built. Run: cd web && npm install && npm run build" _NOT_BUILT_MSG = b"Frontend not built. Run: cd web && npm install && npm run build"
# Epoch seconds this process began, captured at import. Anything in the
# package newer than this is code we did not load.
_STARTED_AT = time.time()
# Set once a stale server has begun standing down, so concurrent requests
# do not each spawn a shutdown thread.
_RETIRING = threading.Event()
_STALE_MSG = ( _STALE_MSG = (
b"This server is running outdated code and has stopped answering rather " "This server is running outdated code and has stopped answering rather "
b"than report numbers the enforcer is not applying. Restart it: " "than report numbers the enforcer is not applying. It restarts on "
b"./run.sh serve" "current code by itself under systemd; otherwise run ./run.sh serve"
) )
class WebServer(ThreadingHTTPServer):
"""The HTTP server plus its per-launch token."""
def __init__(self, address: tuple[str, int]) -> None:
"""Bind to *address* and mint this launch's token."""
super().__init__(address, _Handler)
self.token = _web_auth.new_token()
@property
def port(self) -> int:
"""The bound port (the real one, also when bound to port 0)."""
return int(self.server_address[1])
class _Handler(BaseHTTPRequestHandler): class _Handler(BaseHTTPRequestHandler):
"""Serve the dataset JSON and the static frontend bundle (read-only).""" """Route one request: auth, stale check, then API, SSE or static."""
server: WebServer
def log_message(self, fmt: str, /, *args: object) -> None: def log_message(self, fmt: str, /, *args: object) -> None:
"""Route the default request log to ``logging`` at debug level.""" """Route the default request log to ``logging`` at debug level."""
logger.debug("%s - %s", self.address_string(), fmt % args) logger.debug("%s - %s", self.address_string(), fmt % args)
def do_GET(self) -> None: def do_GET(self) -> None:
"""Dispatch a GET to one of the APIs or to a static file.""" """Handle a GET."""
stale = outdated_source(_STARTED_AT) self._handle("GET")
if stale is not None:
# Fail closed on every route, static included: a page served from a def do_POST(self) -> None:
# fresh bundle that then fetches numbers from stale code is the """Handle a POST."""
# same lie with extra steps. self._handle("POST")
self._send(HTTPStatus.SERVICE_UNAVAILABLE, _STALE_MSG, "text/plain")
self._retire() def do_DELETE(self) -> None:
return """Handle a DELETE."""
self._handle("DELETE")
def _handle(self, method: str) -> None:
"""Run the checks, then dispatch; every refusal is a JSON error."""
split = urlsplit(self.path) split = urlsplit(self.path)
if split.path == _API_DATASET: try:
self._serve_dataset() host = _web_auth.check_host(self.headers, self.server.port)
elif split.path == _API_BUDGET: if self._refused_as_stale(split.path):
# ``?demo=1`` reads the demo run's state and log, which is how the return
# 60-second demo can be watched hitting its cutoff in the browser if method != "GET":
# without spending a real day's budget to see it. _web_auth.check_origin(self.headers, host)
self._serve_budget(demo="demo=1" in split.query) token = self.headers.get(_web_auth.AUTH_HEADER)
else: _web_auth.check_token(token, self.server.token)
self._serve_static(split.path) self._dispatch(method, split.path, _query(split.query))
except ApiError as exc:
send_error(self, exc)
except Exception:
logger.exception("%s %s failed", method, split.path)
msg = "The server failed to answer this request; see its log."
send_error(
self,
ApiError(
msg, code="op_failed", status=HTTPStatus.INTERNAL_SERVER_ERROR
),
)
def _retire(self) -> None: def _dispatch(self, method: str, path: str, query: dict[str, str]) -> None:
"""Answer this request, then stop the server so it can be replaced. """Send the request to the SSE stream, an API route or the bundle."""
events = EVENTS_PATH.fullmatch(path) if method == "GET" else None
if events is not None:
# EventSource cannot send headers: the token rides in the query.
_web_auth.check_token(query.get(_web_auth.AUTH_QUERY), self.server.token)
request = Request(method, path, query, events.groups(), self.headers)
stream_job_events(self, request)
return
if not path.startswith("/api/"):
if method != "GET":
raise _method_not_allowed()
self._serve_static(path)
return
found = resolve(method, path)
if found is None:
msg = f"No such endpoint: {path}"
raise not_found(msg)
if isinstance(found, str):
raise _method_not_allowed()
route, args = found
body = read_json_body(self) if method != "GET" else {}
send_reply(self, route(Request(method, path, query, args, self.headers, body)))
Refusing to serve is only half a fix: a process that 503s forever is def _refused_as_stale(self, path: str) -> bool:
honest and useless. Exiting hands the problem to the supervisor, and """Fail closed when our own source changed since this process started.
``steam-backlog-enforcer-web.service`` has ``Restart=always``, so the
server comes back on current code with nobody having to remember to
restart it. Started outside systemd it simply exits, which is still
better than serving numbers the enforcer is not applying.
``shutdown`` blocks until ``serve_forever`` returns and cannot be Every route refuses, static included: a page served from a fresh
called from the thread handling a request, so it goes to its own. bundle that then fetches numbers from stale code is the same lie
with extra steps. ``/api/server`` is the exception, because saying
"I am stale" is the one answer that is still true. Either way the
server then stands down so the supervisor restarts it on current
code (``Restart=always``).
""" """
if _RETIRING.is_set(): if outdated_source(_web_process.STARTED_AT) is None:
return return False
_RETIRING.set() if path == "/api/server":
threading.Thread(target=self.server.shutdown, daemon=True).start() send_reply(self, _web_process.health_view(_no_request(path, self)))
elif path.startswith("/api/"):
def _serve_budget(self, *, demo: bool) -> None: send_error(self, ApiError(_STALE_MSG, code="server_stale"))
"""Build and send the gaming-budget snapshot as JSON.""" else:
try: body = _STALE_MSG.encode()
body = json.dumps(build_budget_snapshot(demo=demo)).encode("utf-8") send_bytes(
except OSError, ValueError, KeyError: self, HTTPStatus.SERVICE_UNAVAILABLE, body, "text/plain; charset=utf-8"
logger.exception("Failed to build budget snapshot") )
self._send(HTTPStatus.INTERNAL_SERVER_ERROR, b"budget error", "text/plain") _web_process.retire()
return return True
self._send(HTTPStatus.OK, body, "application/json")
def _serve_dataset(self) -> None:
"""Build and send the projected dataset as JSON."""
try:
payload = dataset_to_payload(build_web_dataset(State.load()))
body = json.dumps(payload).encode("utf-8")
except OSError, ValueError, KeyError:
logger.exception("Failed to build web dataset")
self._send(HTTPStatus.INTERNAL_SERVER_ERROR, b"dataset error", "text/plain")
return
self._send(HTTPStatus.OK, body, "application/json")
def _serve_static(self, path: str) -> None: def _serve_static(self, path: str) -> None:
"""Serve a file from ``WEB_DIST`` with SPA fallback and traversal guard.""" """Serve a file from ``WEB_DIST`` with SPA fallback and traversal guard."""
rel = path.lstrip("/") or "index.html" rel = path.lstrip("/") or _INDEX
candidate = (WEB_DIST / rel).resolve() candidate = (WEB_DIST / rel).resolve()
# Reject path traversal, then fall back to index.html for SPA routes. # Reject path traversal, then fall back to index.html for SPA routes.
if not candidate.is_relative_to(WEB_DIST) or not candidate.is_file(): if not candidate.is_relative_to(WEB_DIST) or not candidate.is_file():
candidate = WEB_DIST / "index.html" candidate = WEB_DIST / _INDEX
if not candidate.is_file(): if not candidate.is_file():
self._send(HTTPStatus.NOT_FOUND, _NOT_BUILT_MSG, "text/plain") send_bytes(self, HTTPStatus.NOT_FOUND, _NOT_BUILT_MSG, "text/plain")
return return
ctype, _ = mimetypes.guess_type(candidate.name) ctype, _ = mimetypes.guess_type(candidate.name)
self._send(HTTPStatus.OK, candidate.read_bytes(), ctype or "text/plain") ctype = ctype or "text/plain"
if ctype.startswith("text/") or ctype in _TEXT_TYPES:
def _send(self, status: HTTPStatus, body: bytes, ctype: str) -> None:
"""Write a complete response with the given status, body, and type."""
if ctype.startswith("text/") or ctype in _EXTRA_TEXT_TYPES:
ctype = f"{ctype}; charset=utf-8" ctype = f"{ctype}; charset=utf-8"
self.send_response(status) body = candidate.read_bytes()
self.send_header("Content-Type", ctype) headers: dict[str, str] = {}
self.send_header("Content-Length", str(len(body))) if candidate.name == _INDEX and candidate.parent == WEB_DIST:
self.end_headers() # Carries this launch's token: never cache it.
self.wfile.write(body) body = _web_auth.inject_token(body, self.server.token)
headers["Cache-Control"] = "no-store"
send_bytes(self, HTTPStatus.OK, body, ctype, headers)
def create_server( # Content types that are text but not under the ``text/`` prefix.
host: str = DEFAULT_HOST, port: int = DEFAULT_PORT _TEXT_TYPES = frozenset({"application/javascript", "application/json", "image/svg+xml"})
) -> ThreadingHTTPServer:
def _query(raw: str) -> dict[str, str]:
"""The query string, first value per key."""
return {key: values[0] for key, values in parse_qs(raw).items()}
def _no_request(path: str, handler: _Handler) -> Request:
"""A bodiless GET request for *path* (for routes that ignore it)."""
return Request("GET", path, {}, (), handler.headers)
def _method_not_allowed() -> ApiError:
"""The 405 for a known path under the wrong method."""
msg = "Method not allowed here."
return ApiError(msg, code="invalid_params", status=HTTPStatus.METHOD_NOT_ALLOWED)
def create_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> WebServer:
"""Create (but do not start) the threading HTTP server.""" """Create (but do not start) the threading HTTP server."""
return ThreadingHTTPServer((host, port), _Handler) server = WebServer((host, port))
_web_process.register(server)
return server
def serve(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None: def serve(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
"""Run the web server until interrupted with Ctrl-C.""" """Run the web server until interrupted (or until it stands down)."""
server = create_server(host, port) server = create_server(host, port)
token_file = _web_auth.write_token_file(server.token, server.port)
_echo(f"Steam Backlog Enforcer web UI: http://{host}:{port}") _echo(f"Steam Backlog Enforcer web UI: http://{host}:{port}")
_echo(f"Session token written to {token_file}.")
_echo("Press Ctrl-C to stop.") _echo("Press Ctrl-C to stop.")
try: try:
server.serve_forever() server.serve_forever()

View File

@ -0,0 +1,91 @@
"""``POST /api/setup``: store the Steam credentials, after Steam accepts them.
The CLI's setup writes whatever is typed; the web form checks first, so a
typo cannot leave the enforcer with a key that fails on its next pass. One
``GetPlayerSummaries`` call proves both halves: Steam answers 401/403 for a
bad key, and an empty ``players`` list for a SteamID it does not know.
Nothing is written unless that call succeeds, and the key is never echoed
back — the reply is the same ``SetupStatus`` that ``GET`` serves.
"""
from __future__ import annotations
from http import HTTPStatus
import re
from typing import TYPE_CHECKING, Final
import requests
from steam_backlog_enforcer._command_gate import lock_reason
from steam_backlog_enforcer._config_setup import save_credentials
from steam_backlog_enforcer._web_errors import ApiError
from steam_backlog_enforcer._web_io import Reply, ok
from steam_backlog_enforcer._web_views import setup_status
from steam_backlog_enforcer.config import Config, State
if TYPE_CHECKING:
from steam_backlog_enforcer._web_io import Request
_SUMMARIES_URL: Final = "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
_TIMEOUT_SECONDS: Final = 15
_API_KEY = re.compile(r"^[0-9A-Fa-f]{32}$")
_STEAM_ID = re.compile(r"^7656119\d{10}$")
def _field(request: Request, name: str, pattern: re.Pattern[str], what: str) -> str:
"""One validated, stripped string field of the body.
Raises:
ApiError: ``invalid_params`` when it is missing or malformed.
"""
raw = request.body.get(name)
value = raw.strip() if isinstance(raw, str) else ""
if not pattern.fullmatch(value):
msg = f"{name} must be {what}."
raise ApiError(msg, code="invalid_params")
return value
def verify_with_steam(api_key: str, steam_id: str) -> None:
"""Ask Steam whether *api_key* works and *steam_id* exists.
Raises:
ApiError: ``invalid_params`` for a rejected key or unknown id,
``op_failed`` (502) when Steam cannot be asked at all.
"""
try:
resp = requests.get(
_SUMMARIES_URL,
params={"key": api_key, "steamids": steam_id},
timeout=_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
msg = (
f"Could not reach the Steam Web API to check the key: {type(exc).__name__}."
)
raise ApiError(msg, code="op_failed") from None
if resp.status_code in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}:
msg = "Steam rejected this API key."
raise ApiError(msg, code="invalid_params")
if resp.status_code != HTTPStatus.OK:
msg = f"The Steam Web API answered HTTP {resp.status_code}; try again."
raise ApiError(msg, code="op_failed")
try:
players = resp.json()["response"]["players"]
except ValueError, KeyError, TypeError:
msg = "The Steam Web API sent an unexpected reply."
raise ApiError(msg, code="op_failed") from None
if not players:
msg = "Steam knows no account with this SteamID64."
raise ApiError(msg, code="invalid_params")
def save_setup(request: Request) -> Reply:
"""``POST /api/setup`` — validate live, then save like the CLI does."""
api_key = _field(request, "steam_api_key", _API_KEY, "a 32-character hex key")
steam_id = _field(request, "steam_id", _STEAM_ID, "a 17-digit SteamID64")
reason = lock_reason("setup", Config.load(), State.load())
if reason is not None:
raise ApiError(reason, code="locked")
verify_with_steam(api_key, steam_id)
return ok(setup_status(save_credentials(api_key, steam_id)))

View File

@ -0,0 +1,114 @@
"""``GET /api/jobs/{id}/events``: a job's ``events.jsonl`` as Server-Sent Events.
Each event is one unnamed frame — ``id: <seq>`` and ``data: <JobEvent JSON>``
— so ``EventSource.onmessage`` sees every type and a reconnect resumes with
``Last-Event-ID``. That header wins over ``?after=``: ``EventSource`` keeps
the URL it was opened with, so on a reconnect ``after`` is stale.
The stream ends once the job is over: right after the final ``state`` event
that follows ``result`` (the runner writes them back to back), or a short
grace after ``result`` if that state never comes. It also ends when the
server stands down; the browser reconnects to its replacement and resumes.
The server only reads files here, so a job is unaffected by any of this.
"""
from __future__ import annotations
from http import HTTPStatus
import json
import time
from typing import TYPE_CHECKING, Any, Final
from steam_backlog_enforcer._web_errors import ApiError, from_job_error
from steam_backlog_enforcer._web_process import RETIRING
from steam_backlog_enforcer.jobs._errors import JobError
from steam_backlog_enforcer.jobs._events import TERMINAL_STATES
from steam_backlog_enforcer.jobs._view import read_job, read_job_events
if TYPE_CHECKING:
from http.server import BaseHTTPRequestHandler
from steam_backlog_enforcer._web_io import Request
_POLL_SECONDS: Final = 0.25
_KEEPALIVE_SECONDS: Final = 15.0
_RESULT_GRACE_SECONDS: Final = 2.0
_RETRY_MS: Final = 2000
def resume_after(request: Request) -> int:
"""The last seq the client has: ``Last-Event-ID``, else ``?after=``.
Raises:
ApiError: ``invalid_params`` for a non-numeric value.
"""
raw = request.headers.get("Last-Event-ID") or request.query.get("after") or "0"
try:
return max(0, int(raw))
except ValueError:
msg = f"after must be an event seq, got {raw!r}"
raise ApiError(msg, code="invalid_params") from None
def _frame(event: dict[str, Any]) -> bytes:
"""One SSE frame for *event*."""
return f"id: {event['seq']}\ndata: {json.dumps(event)}\n\n".encode()
def _is_terminal_state(event: dict[str, Any]) -> bool:
"""Whether *event* is the job's final ``state`` transition."""
return event.get("type") == "state" and event.get("state") in TERMINAL_STATES
def _pump(handler: BaseHTTPRequestHandler, job_id: str, after: int) -> None:
"""Write events as they appear until the job is over or we retire."""
last_write = time.monotonic()
result_at: float | None = None
while not RETIRING.is_set():
events = read_job_events(job_id, after)
for event in events:
handler.wfile.write(_frame(event))
after = event["seq"]
if event.get("type") == "result":
result_at = time.monotonic()
if _is_terminal_state(event):
return
now = time.monotonic()
if events:
handler.wfile.flush()
last_write = now
elif result_at is not None and now - result_at > _RESULT_GRACE_SECONDS:
return
elif result_at is None and read_job(job_id)["state"] in TERMINAL_STATES:
return # Resumed past the end of a finished job: nothing more.
elif now - last_write >= _KEEPALIVE_SECONDS:
handler.wfile.write(b": keep-alive\n\n")
handler.wfile.flush()
last_write = now
time.sleep(_POLL_SECONDS)
def stream_job_events(handler: BaseHTTPRequestHandler, request: Request) -> None:
"""Serve the SSE stream for ``request.args[0]`` (auth already checked).
Raises:
ApiError: Before any byte is sent, for an unknown job or bad ``after``.
"""
job_id = request.args[0]
after = resume_after(request)
try:
read_job(job_id)
except JobError as exc:
raise from_job_error(exc) from None
handler.close_connection = True
handler.send_response(HTTPStatus.OK)
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
handler.send_header("Cache-Control", "no-store")
handler.send_header("X-Accel-Buffering", "no")
handler.end_headers()
try:
handler.wfile.write(f"retry: {_RETRY_MS}\n\n".encode())
_pump(handler, job_id, after)
handler.wfile.flush()
except BrokenPipeError, ConnectionResetError:
return # The browser went away; it will reconnect if it cares.

View File

@ -0,0 +1,131 @@
"""Read-only API views: status, stats, installed games, commands, backups.
Each builder returns the contract payload (``web/src/api/contract.ts``) and
reads only local state and caches — never ``config.json`` secrets, never the
network. They are the same numbers the CLI prints, projected to JSON.
"""
from __future__ import annotations
import contextlib
import re
from typing import TYPE_CHECKING, Any
from steam_backlog_enforcer import _steam_state
from steam_backlog_enforcer._actions import allowed_app_ids
from steam_backlog_enforcer._backups import list_backups
from steam_backlog_enforcer._budget_view import build_budget_snapshot
from steam_backlog_enforcer._command_catalog import command_specs
from steam_backlog_enforcer._manual_pick_lifecycle import status_payload
from steam_backlog_enforcer._web_dataset import build_web_dataset, dataset_to_payload
from steam_backlog_enforcer._web_io import Reply, ok
from steam_backlog_enforcer.config import Config, State
from steam_backlog_enforcer.game_install import get_installed_games, is_protected_app
if TYPE_CHECKING:
from steam_backlog_enforcer._web_io import Request
_SIZE_ON_DISK = re.compile(r'"SizeOnDisk"\s+"(\d+)"')
def _manual_pick(pick: dict[str, Any]) -> dict[str, object]:
"""One contract ``ManualPickInfo`` from a ``status_payload`` pick."""
age = pick.get("age_days")
return {
"app_id": pick["app_id"],
"name": pick.get("game_name") or str(pick["app_id"]),
# None means "start time unknown"; the contract field is a number.
"age_days": round(float(age), 1) if age is not None else 0.0,
}
def status_view(_request: Request) -> Reply:
"""``GET /api/status`` — the ``status`` command as ``StatusPayload``."""
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"]]
return ok(payload)
def stats_view(_request: Request) -> Reply:
"""``GET /api/stats`` — the MCP ``get_stats`` projection of the dataset.
Not imported from ``_mcp_query``: that pulls in the MCP server itself.
"""
payload = dataset_to_payload(build_web_dataset(State.load()))
return ok(
{
"default_summary": payload["default_summary"],
"pace_vs_hltb": payload["pace_vs_hltb"],
}
)
def _size_on_disk(app_id: int) -> int:
"""``SizeOnDisk`` from the game's appmanifest (0 when unknown)."""
# Through the module: tests redirect the path after import.
manifest = _steam_state.STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
with contextlib.suppress(OSError):
match = _SIZE_ON_DISK.search(manifest.read_text(encoding="utf-8"))
if match:
return int(match.group(1))
return 0
def installed_view(_request: Request) -> Reply:
"""``GET /api/installed`` — what ``installed`` lists, with sizes.
``assigned`` covers every game the enforcer lets exist (the assignment
and active manual picks), the set the uninstaller spares.
"""
allowed = allowed_app_ids(State.load())
games = [
{
"app_id": app_id,
"name": name,
"size_bytes": _size_on_disk(app_id),
"assigned": app_id in allowed,
"protected": is_protected_app(app_id),
}
for app_id, name in get_installed_games()
]
return ok({"games": games})
def commands_view(_request: Request) -> Reply:
"""``GET /api/commands`` — the catalog, locks evaluated now."""
return ok(command_specs())
def setup_view(_request: Request) -> Reply:
"""``GET /api/setup`` — configured flags; the API key is never returned."""
return ok(setup_status(Config.load()))
def setup_status(config: Config) -> dict[str, object]:
"""The contract ``SetupStatus`` for *config*."""
return {
"configured": bool(config.steam_api_key and config.steam_id),
"has_api_key": bool(config.steam_api_key),
"steam_id": config.steam_id or None,
}
def backups_view(_request: Request) -> Reply:
"""``GET /api/backups`` — every state backup, newest first."""
return ok([backup.to_json() for backup in list_backups()])
def dataset_view(_request: Request) -> Reply:
"""``GET /api/dataset`` — the projected backlog dataset."""
return ok(dataset_to_payload(build_web_dataset(State.load())))
def budget_view(request: Request) -> Reply:
"""``GET /api/budget`` — the gaming-budget snapshot.
``?demo=1`` reads the demo run's state and log, which is how the
60-second demo can be watched hitting its cutoff in the browser without
spending a real day's budget to see it.
"""
return ok(build_budget_snapshot(demo=request.query.get("demo") == "1"))

View File

@ -71,6 +71,11 @@ def _atomic_write(path: Path, data: str, *, mode: int | None = None) -> None:
raise raise
# The public name, for callers outside this module. The private one predates
# it and is still what most of the package imports (and what tests patch).
atomic_write = _atomic_write
def _field_names(cls: type) -> frozenset[str]: def _field_names(cls: type) -> frozenset[str]:
"""The dataclass's own field names, for filtering a loaded JSON object.""" """The dataclass's own field names, for filtering a loaded JSON object."""
return frozenset(f.name for f in fields(cls)) return frozenset(f.name for f in fields(cls))

View File

@ -14,6 +14,7 @@ import shutil
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from steam_backlog_enforcer._game_names import _is_protected_name from steam_backlog_enforcer._game_names import _is_protected_name
from steam_backlog_enforcer._progress import tracked
from steam_backlog_enforcer._protected_apps import PROTECTED_APP_IDS from steam_backlog_enforcer._protected_apps import PROTECTED_APP_IDS
from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH, _assert_not_real_steam from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH, _assert_not_real_steam
from steam_backlog_enforcer._whitelist_locking import get_approved_exception_ids from steam_backlog_enforcer._whitelist_locking import get_approved_exception_ids
@ -148,7 +149,7 @@ def uninstall_other_games(allowed_app_ids: set[int]) -> int:
installed = get_installed_games() installed = get_installed_games()
count = 0 count = 0
for app_id, name in installed: for app_id, name in tracked(installed, "Uninstalling games", lambda g: g[1]):
if app_id in allowed_app_ids: if app_id in allowed_app_ids:
logger.info("KEEPING allowed game: %s (AppID=%d)", name, app_id) logger.info("KEEPING allowed game: %s (AppID=%d)", name, app_id)
continue continue

View File

@ -0,0 +1,13 @@
"""Web jobs: CLI commands run as detached subprocesses with live events.
A job is a directory under ``~/.local/state/steam-backlog-enforcer/jobs/``
(see ``DOCS-web-control-api.md``, "Jobs"). The web server creates it and
spawns ``python -m steam_backlog_enforcer.jobs run <job_dir>``; from then on
the two only share files, so a job outlives page reloads and server restarts.
- :mod:`._store` — create, prune and spawn jobs; the one-mutating-job lock.
- :mod:`._view` — read jobs and their events back for the API.
- :mod:`._control` — answer a prompt, cancel a job.
- :mod:`._runner` — what runs inside the subprocess.
- :mod:`._registry` — which command runs as which job.
"""

View File

@ -0,0 +1,27 @@
"""``python -m steam_backlog_enforcer.jobs run <job_dir>``: run one web job.
Spawned by the web server (:func:`steam_backlog_enforcer.jobs._store.create_job`);
it can also be run by hand on a prepared job dir to debug a job.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from steam_backlog_enforcer.jobs._runner import run_job
def main(argv: list[str] | None = None) -> int:
"""Parse the command line and run the job; return its exit code."""
parser = argparse.ArgumentParser(prog="python -m steam_backlog_enforcer.jobs")
actions = parser.add_subparsers(dest="action", required=True)
run = actions.add_parser("run", help="run the job in JOB_DIR")
run.add_argument("job_dir", type=Path)
args = parser.parse_args(argv)
return run_job(args.job_dir)
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,90 @@
"""Act on a running job: answer its prompt, or cancel it.
Both only touch files and signals the job already watches, so they work
the same whether the job was started by this server process or a previous
one.
"""
from __future__ import annotations
import os
import signal
from steam_backlog_enforcer.jobs import _store
from steam_backlog_enforcer.jobs._errors import JobError
from steam_backlog_enforcer.jobs._events import (
ANSWERS_NAME,
CANCEL_NAME,
TERMINAL_STATES,
append_jsonl,
read_events,
read_jsonl,
)
from steam_backlog_enforcer.jobs._specs import JOB_FLAGS
from steam_backlog_enforcer.jobs._view import pid_of, read_job
def _open_prompt_id(job_id: str) -> str | None:
"""The id of the prompt the job is waiting on, if it is waiting."""
path = _store.job_dir(job_id)
prompts = [e for e in read_events(path) if e.get("type") == "prompt"]
if not prompts:
return None
answered = {a.get("prompt_id") for a in read_jsonl(path / ANSWERS_NAME)}
latest = str(prompts[-1]["prompt_id"])
return None if latest in answered else latest
def write_answer(job_id: str, prompt_id: str, value: str) -> None:
"""Deliver the user's answer to the prompt the job is waiting on.
The answer is not judged here — the job re-checks it — but it must be
for the prompt that is actually open, so a stale or replayed answer
cannot land on a later question.
Raises:
JobError: ``not_found`` for an unknown job; ``invalid_params`` for a
finished job or a ``prompt_id`` that is not the open prompt.
"""
job = read_job(job_id)
if job["state"] in TERMINAL_STATES:
msg = f"Job {job_id} has already finished."
raise JobError(msg, code="invalid_params")
if _open_prompt_id(job_id) != prompt_id:
msg = f"Prompt {prompt_id!r} is not waiting for an answer."
raise JobError(msg, code="invalid_params")
path = _store.job_dir(job_id)
append_jsonl(path / ANSWERS_NAME, {"prompt_id": prompt_id, "value": value})
def cancel(job_id: str) -> None:
"""Ask a job to stop: any job waiting on a prompt, else cancellable ones.
A cancellable job gets SIGTERM, to its own process only.
Only the job's pid is signalled, never its process group: a job may
have started Steam, and cancelling a scan must not take Steam down.
Raises:
JobError: ``not_cancellable`` for a command that is unsafe to stop
midway; ``not_found`` for an unknown job; ``invalid_params`` for
a finished one.
"""
job = read_job(job_id)
if job["state"] == "waiting_input":
# Blocked on a prompt, so nothing is half-done: any command may stop
# here. The job's own prompt loop sees the marker and cancels itself.
(_store.job_dir(job_id) / CANCEL_NAME).touch()
return
flags = JOB_FLAGS.get(job["command"])
if flags is None or not flags.cancellable:
msg = f"{job['command']} cannot be cancelled once started."
raise JobError(msg, code="not_cancellable")
if job["state"] in TERMINAL_STATES:
msg = f"Job {job_id} has already finished."
raise JobError(msg, code="invalid_params")
pid = pid_of(_store.job_dir(job_id))
if pid is None:
msg = f"Job {job_id} has no running process."
raise JobError(msg, code="invalid_params")
os.kill(pid, signal.SIGTERM)

View File

@ -0,0 +1,88 @@
"""Record privileged actions, carried out by the root daemon, as jobs.
A daemon op is one blocking socket call, not a subprocess, so it has no pid
for :mod:`._view` to watch. The job dir is therefore written only once the
call has returned, all at once: ``request.json``, the full event sequence
(``queued`` → ``running`` → ``log`` → ``result`` → final ``state``) and
``result.json``, the same files a real job leaves behind. Written earlier,
a 10-minute ``block_gaming`` would be failed by the dead-pid reconciler
half-way through and then get a second, contradictory result.
The phrase the user typed is never stored: it is not a secret, but it is
also not something history needs.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import TYPE_CHECKING, Any
from steam_backlog_enforcer.config import _atomic_write
from steam_backlog_enforcer.jobs import _store
from steam_backlog_enforcer.jobs._events import REQUEST_NAME, RESULT_NAME, EventWriter
if TYPE_CHECKING:
from collections.abc import Mapping
EXIT_OK = 0
EXIT_FAILED = 1
EXIT_REFUSED = 2
@dataclass(frozen=True)
class DaemonOutcome:
"""How a daemon op ended, as the job records it.
Attributes:
ok: Whether the op did what was asked.
summary: One line for the job list and the ``result`` event.
exit_code: ``0`` ok, ``1`` failed, ``2`` refused.
data: Optional result data for the ``result`` event.
"""
ok: bool
summary: str
exit_code: int
data: Mapping[str, Any] | None = None
def record_daemon_job(
command: str,
params: Mapping[str, object],
*,
created_at: str,
outcome: DaemonOutcome,
) -> str:
"""Write a finished job for a daemon op and return its id.
Args:
command: The contract command name (``unblock``, ``gaming-reset``…).
params: The validated params the op ran with.
created_at: ISO time the request arrived (before the daemon call).
outcome: How the op ended.
"""
ok, summary, data = outcome.ok, outcome.summary, outcome.data
_store.prune(_store.MAX_JOBS - 1)
path = _store.JOBS_DIR / _store.new_job_id()
path.mkdir(parents=True, mode=0o700)
request = {
"command": command,
"params": dict(params),
"created_at": created_at,
"via": "daemon",
}
(path / REQUEST_NAME).write_text(json.dumps(request) + "\n", encoding="utf-8")
writer = EventWriter(path)
state = "succeeded" if ok else "failed"
writer.emit("state", state="queued")
writer.emit("state", state="running")
writer.emit("log", level="info" if ok else "error", message=summary)
result: dict[str, Any] = {"ok": ok, "summary": summary}
if data is not None:
result["data"] = json.loads(json.dumps(dict(data), default=str))
writer.emit("result", **result)
final = result | {"state": state, "exit_code": outcome.exit_code}
_atomic_write(path / RESULT_NAME, json.dumps(final, indent=2) + "\n")
writer.emit("state", state=state)
return path.name

View File

@ -0,0 +1,48 @@
"""Errors the job API raises, carrying the contract's ``ApiErrorCode``."""
from __future__ import annotations
from typing import Literal
JobErrorCode = Literal[
"unknown_command",
"invalid_params",
"not_found",
"wrong_phrase",
"locked",
"busy",
"not_cancellable",
"op_failed",
]
class JobError(Exception):
"""A job request the server must refuse (4xx; 502 for ``op_failed``).
Attributes:
code: The contract's ``ApiErrorCode`` for the response body.
message: Human text for the response body.
"""
def __init__(self, message: str, *, code: JobErrorCode) -> None:
"""Store the code next to the message."""
super().__init__(message)
self.code: JobErrorCode = code
self.message = message
class PrivilegedViaDaemonError(Exception):
"""The command needs root, so it runs in the daemon, never as a job.
The web server catches this and routes the request to the control
socket instead (``DOCS-web-control-api.md``, "Control socket").
"""
def __init__(self, command: str) -> None:
"""Name the command that has to go through the daemon."""
super().__init__(f"{command} runs in the root daemon, not as a web job.")
self.command = command
class JobCancelledError(Exception):
"""Raised inside a cancellable job when it receives SIGTERM."""

View File

@ -0,0 +1,117 @@
"""The files of one job directory and the JSONL event log inside it.
``events.jsonl`` is append-only, one ``JobEvent`` per line, ``seq`` strictly
increasing. Normally only the job subprocess appends; the server appends
too in two cases (the initial ``queued`` state, and the ``failed`` verdict
for a job whose process died), so every append takes an exclusive ``flock``
and recomputes ``seq`` whenever someone else wrote since.
Readers only ever parse complete, newline-terminated lines: a line still
being written is invisible until it is whole.
"""
from __future__ import annotations
from datetime import UTC, datetime
import fcntl
import json
import os
import threading
from typing import TYPE_CHECKING, Any, Final
if TYPE_CHECKING:
from pathlib import Path
REQUEST_NAME: Final = "request.json"
EVENTS_NAME: Final = "events.jsonl"
ANSWERS_NAME: Final = "answers.jsonl"
# Touched by the server to cancel a job that is blocked on a prompt.
CANCEL_NAME: Final = "cancel.request"
RESULT_NAME: Final = "result.json"
PID_NAME: Final = "pid"
OUTPUT_NAME: Final = "output.log"
TERMINAL_STATES: Final = frozenset({"succeeded", "failed", "cancelled"})
def now_iso() -> str:
"""Current UTC time as ISO-8601 with a ``Z`` suffix, second precision."""
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
def read_jsonl(path: Path) -> list[dict[str, Any]]:
"""Parse every complete line of a JSONL file; skip torn or bad lines."""
try:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return []
complete = raw if raw.endswith("\n") else raw[: raw.rfind("\n") + 1]
out: list[dict[str, Any]] = []
for line in complete.splitlines():
try:
obj = json.loads(line)
except ValueError:
continue
if isinstance(obj, dict):
out.append(obj)
return out
def append_jsonl(path: Path, obj: dict[str, Any]) -> None:
"""Append one object as a line, under an exclusive lock."""
with path.open("a", encoding="utf-8") as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
fh.write(json.dumps(obj) + "\n")
fh.flush()
def read_events(job_dir: Path, after_seq: int = 0) -> list[dict[str, Any]]:
"""Return the job's events with ``seq`` greater than *after_seq*."""
return [
event
for event in read_jsonl(job_dir / EVENTS_NAME)
if isinstance(event.get("seq"), int) and event["seq"] > after_seq
]
class EventWriter:
"""Appends events to one job's ``events.jsonl`` with gap-free ``seq``."""
def __init__(self, job_dir: Path) -> None:
"""Bind to *job_dir*; the file is created on the first event."""
self._path = job_dir / EVENTS_NAME
self._seq = 0
self._size = -1 # File size after our last write; -1 = never wrote.
self._lock = threading.Lock()
def _last_seq(self) -> int:
"""The highest ``seq`` already in the file (0 if none)."""
seqs = [
e["seq"] for e in read_jsonl(self._path) if isinstance(e.get("seq"), int)
]
return max(seqs, default=0)
def emit(self, event_type: str, **fields: object) -> dict[str, Any]:
"""Append one event and return it.
Args:
event_type: ``state``, ``log``, ``progress``, ``prompt`` or
``result``.
**fields: The type-specific fields from the contract.
"""
with self._lock, self._path.open("a", encoding="utf-8") as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
if os.fstat(fh.fileno()).st_size != self._size:
# First write, or another process appended since our last one.
self._seq = self._last_seq()
self._seq += 1
event: dict[str, Any] = {
"seq": self._seq,
"ts": now_iso(),
"type": event_type,
**fields,
}
fh.write(json.dumps(event) + "\n")
fh.flush()
self._size = os.fstat(fh.fileno()).st_size
return event

View File

@ -0,0 +1,143 @@
"""Turn a command's output and progress into job events.
Inside a job the command code is unchanged: it still calls ``_echo``,
``current_progress()`` and ``logging``. The runner installs the three
adapters here, which write ``log`` and ``progress`` events instead of a
terminal.
"""
from __future__ import annotations
import logging
import re
import time
from typing import TYPE_CHECKING, Literal
from steam_backlog_enforcer._progress import eta_seconds
if TYPE_CHECKING:
from steam_backlog_enforcer.jobs._events import EventWriter
LogLevel = Literal["info", "warning", "error"]
# At most this many progress events per second, plus every phase start and
# the final step: enough for a smooth bar, cheap for SSE and the event fold.
_MIN_PROGRESS_INTERVAL = 0.2
_CONTROL = re.compile(r"(\r|\n)")
def _level_for(line: str) -> LogLevel:
"""Guess a CLI line's severity from how the commands phrase it."""
lowered = line.lower()
if lowered.startswith(("error", "failed")):
return "error"
if lowered.startswith(("warning", "⚠")) or "⚠" in line:
return "warning"
return "info"
class JobLogSink:
"""An ``_echo`` sink that behaves like a terminal, one event per line.
A carriage return discards the unfinished line, exactly as a terminal
overwrites it, so the CLI's carriage-return progress meters collapse into the
single line they finally show instead of thousands of events.
"""
def __init__(self, writer: EventWriter) -> None:
"""Bind to the job's event writer."""
self._writer = writer
self._buffer = ""
self.last_line: str | None = None
"""The last line with any text: the job's default summary."""
self.last_error: str | None = None
"""The last error-level line, if any: a printed failure."""
def __call__(self, text: str) -> None:
"""Consume raw ``_echo`` output (message plus ``end``)."""
for part in _CONTROL.split(text):
if part == "\n":
self.flush()
elif part == "\r":
self._buffer = ""
else:
self._buffer += part
def flush(self) -> None:
"""Emit the buffered line, if it has any visible text."""
line = self._buffer.strip()
self._buffer = ""
if line:
if any(ch.isalnum() for ch in line): # Not a "────" rule.
self.last_line = line
level = _level_for(line)
if level == "error":
self.last_error = line
self._writer.emit("log", level=level, message=line)
class JobProgress:
"""A :class:`~steam_backlog_enforcer._progress.Progress` writing events."""
def __init__(self, writer: EventWriter) -> None:
"""Bind to the job's event writer."""
self._writer = writer
self._label = ""
self._total: int | None = None
self._step = 0
self._item: str | None = None
self._started = time.monotonic()
self._last_emit = 0.0
def phase(self, label: str, total: int | None = None) -> None:
"""Start a phase and announce it at once."""
self._label, self._total, self._step, self._item = label, total, 0, None
self._started = time.monotonic()
self._emit(force=True)
def advance(self, item: str | None = None, *, step: int | None = None) -> None:
"""Record a step; emitted unless one went out a moment ago."""
self._step = self._step + 1 if step is None else step
self._item = item
done = self._total is not None and self._step >= self._total
self._emit(force=done)
def _emit(self, *, force: bool) -> None:
"""Write a ``progress`` event, throttled unless *force*."""
now = time.monotonic()
if not force and now - self._last_emit < _MIN_PROGRESS_INTERVAL:
return
self._last_emit = now
fields: dict[str, object] = {
"step": self._step,
"total": self._total,
"label": self._label,
}
if self._item:
fields["item"] = self._item
eta = eta_seconds(now - self._started, self._step, self._total)
if eta is not None:
fields["eta_seconds"] = eta
self._writer.emit("progress", **fields)
class EventLogHandler(logging.Handler):
"""Forwards WARNING-and-above log records as ``log`` events.
INFO chatter stays in the job's ``output.log``; what a user should see
(a failed uninstall, an unreachable Steam) reaches the UI.
"""
def __init__(self, writer: EventWriter) -> None:
"""Bind to the job's event writer."""
super().__init__(level=logging.WARNING)
self._writer = writer
def emit(self, record: logging.LogRecord) -> None:
"""Write one record as a ``log`` event."""
level: LogLevel = "error" if record.levelno >= logging.ERROR else "warning"
try:
message = record.getMessage()
except TypeError, ValueError:
message = str(record.msg)
self._writer.emit("log", level=level, message=message)

View File

@ -0,0 +1,172 @@
"""The prompter a job uses: a ``prompt`` event out, an answer line back.
The job writes a ``prompt`` event and blocks, polling ``answers.jsonl``
(appended by the server on ``POST /api/jobs/{id}/answer``) for a line with
its ``prompt_id``. Nothing the UI sends is trusted: a choice must be one of
the offered values and a phrase is re-checked with
:func:`steam_backlog_enforcer._friction.phrase_matches` right here, in the
process that carries the action out.
"""
from __future__ import annotations
import secrets
import time
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._friction import phrase_matches
from steam_backlog_enforcer._prompter import PromptAbortedError
from steam_backlog_enforcer.jobs._errors import JobCancelledError
from steam_backlog_enforcer.jobs._events import ANSWERS_NAME, CANCEL_NAME, read_jsonl
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
from steam_backlog_enforcer._prompter import PromptOption
from steam_backlog_enforcer.jobs._events import EventWriter
# An unanswered prompt holds the mutating-job lock; give up eventually.
PROMPT_TIMEOUT_SECONDS: Final = 3600
MAX_PHRASE_ATTEMPTS: Final = 3
_POLL_SECONDS = 0.2
_YES = frozenset({"y", "yes", "true", "1"})
_NO = frozenset({"n", "no", "false", "0"})
class JobPrompter:
"""A :class:`~steam_backlog_enforcer._prompter.Prompter` over job files."""
def __init__(
self,
writer: EventWriter,
job_dir: Path,
*,
preset_phrase: str | None = None,
timeout: float = PROMPT_TIMEOUT_SECONDS,
) -> None:
"""Bind to a job.
Args:
writer: The job's event writer.
job_dir: Where ``answers.jsonl`` appears.
preset_phrase: ``JobRequest.confirm_phrase``, typed up front. The
first phrase prompt consumes it if it matches, and asks
normally if it does not.
timeout: Seconds to wait for any one answer.
"""
self._writer = writer
self._answers = job_dir / ANSWERS_NAME
self._cancel = job_dir / CANCEL_NAME
self._preset = preset_phrase
self._timeout = timeout
@property
def interactive(self) -> bool:
"""A job always has the UI to ask."""
return True
def _wait(self, prompt_id: str) -> str:
"""Block until the answer for *prompt_id* arrives.
Raises:
JobCancelledError: The user cancelled while the job was waiting.
Safe for every command, cancellable or not: a job blocked on
a question is where the CLI's Ctrl-C would land.
"""
deadline = time.monotonic() + self._timeout
while time.monotonic() < deadline:
if self._cancel.exists():
raise JobCancelledError
for answer in read_jsonl(self._answers):
if answer.get("prompt_id") == prompt_id:
return str(answer.get("value", ""))
time.sleep(_POLL_SECONDS)
msg = f"No answer within {self._timeout / 60:.0f} minute(s)."
raise PromptAbortedError(msg)
def _ask(self, kind: str, message: str, **extra: object) -> str:
"""Emit one prompt, wait for its answer, and return it."""
prompt_id = secrets.token_hex(8)
self._writer.emit("state", state="waiting_input")
self._writer.emit(
"prompt", prompt_id=prompt_id, kind=kind, message=message.strip(), **extra
)
value = self._wait(prompt_id)
self._writer.emit("state", state="running")
return value
def _warn(self, message: str) -> None:
"""Tell the user why their answer was not accepted."""
self._writer.emit("log", level="warning", message=message)
def choice(self, message: str, options: Sequence[PromptOption]) -> str:
"""Ask until the answer is one of the offered values."""
payload = [
{"value": o.value, "label": o.label}
| ({"detail": o.detail} if o.detail else {})
for o in options
]
valid = {o.value for o in options}
while True:
value = self._ask("choice", message, options=payload)
if value in valid:
return value
self._warn(f"Not one of the options: {value!r}")
def confirm(self, message: str, *, default: bool) -> bool:
"""Ask yes/no; an empty answer means *default*."""
while True:
value = self._ask(
"confirm", message, default="yes" if default else "no"
).strip()
if not value:
return default
if value.lower() in _YES:
return True
if value.lower() in _NO:
return False
self._warn(f"Answer yes or no, not {value!r}")
def phrase(self, message: str, expected: str) -> bool:
"""Return True once *expected* is typed exactly.
Raises:
PromptAbortedError: On an empty answer (declined) or after
:data:`MAX_PHRASE_ATTEMPTS` wrong ones. A job never treats a
wrong phrase as a quiet "no": it fails, visibly.
"""
preset, self._preset = self._preset, None
if preset is not None:
if phrase_matches(expected, preset):
return True
self._warn("The phrase sent with the request did not match.")
for _ in range(MAX_PHRASE_ATTEMPTS):
value = self._ask("phrase", message, phrase=expected)
if not value.strip():
msg = "Declined."
raise PromptAbortedError(msg)
if phrase_matches(expected, value):
return True
self._warn(f'That is not the phrase. Type exactly: "{expected}"')
msg = f"Wrong phrase {MAX_PHRASE_ATTEMPTS} times; nothing was changed."
raise PromptAbortedError(msg)
def text(self, message: str) -> str:
"""Ask for one line of free text."""
return self._ask("text", message).strip()
def game(self, message: str, app_ids: Sequence[int]) -> int | None:
"""Ask for a game from the library browser; empty means go back.
The event carries only the ids (the UI already has the library); the
answer must be one of them, whatever the browser let the user click.
"""
valid = frozenset(app_ids)
while True:
value = self._ask("game", message, app_ids=sorted(valid)).strip()
if not value:
return None
if value.isdigit() and int(value) in valid:
return int(value)
self._warn(f"Not a game you can pick here: {value!r}")

View File

@ -0,0 +1,166 @@
"""Which command runs as which job, and how.
Every handler is the CLI command itself, run under the job's output sink,
progress reporter and prompter (installed by :mod:`._runner`), so a job
cannot drift from what ``./run.sh <cmd>`` does. Params arrive validated
(:mod:`steam_backlog_enforcer._command_params`) and are turned back into
the argument list the CLI function already parses.
Privileged commands are registered too, with a handler that refuses: they
run in the root daemon, and the web server routes them there before a job
would ever be created (see :func:`._specs.is_privileged`).
Only the job subprocess (:mod:`._runner`) imports this module: it pulls in
every command, and the web server must not (see :mod:`._specs`).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from steam_backlog_enforcer._backups import restore_backup
from steam_backlog_enforcer._cmd_playtime import cmd_enforce
from steam_backlog_enforcer._echo import _echo
from steam_backlog_enforcer._prompter import PromptAbortedError, confirm_phrase
from steam_backlog_enforcer.config import Config, State
from steam_backlog_enforcer.jobs._errors import PrivilegedViaDaemonError
from steam_backlog_enforcer.main import (
cmd_abandon_pick,
cmd_add_exception,
cmd_done,
cmd_hide,
cmd_install,
cmd_list,
cmd_pick,
cmd_pick_manual,
cmd_reset,
cmd_stats,
cmd_unhide,
cmd_uninstall,
)
from steam_backlog_enforcer.scanning import do_check, do_scan
if TYPE_CHECKING:
from collections.abc import Callable, Mapping
from steam_backlog_enforcer._command_params import ParamValue
from steam_backlog_enforcer._progress import Progress
from steam_backlog_enforcer._prompter import Prompter
type JobResult = dict[str, object] | None
type JobHandler = Callable[[Mapping[str, ParamValue], Progress, Prompter], JobResult]
def _assignment(state: State) -> dict[str, object]:
"""The result data every backlog job returns: what is assigned now."""
return {
"current_app_id": state.current_app_id,
"current_game_name": state.current_game_name or None,
}
def _cli(command: Callable[[Config, State], object]) -> JobHandler:
"""Wrap a ``(config, state)`` CLI command as a job handler."""
def handler(
_params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter
) -> JobResult:
state = State.load()
command(Config.load(), state)
return _assignment(state)
return handler
def _with_args(
command: Callable[[Config, State, list[str]], object], param: str
) -> JobHandler:
"""Wrap a ``(config, state, args)`` command taking one positional arg."""
def handler(
params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter
) -> JobResult:
state = State.load()
command(Config.load(), state, [str(params[param])])
return _assignment(state)
return handler
def _add_exception(
params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter
) -> JobResult:
"""``add-exception <app_id> --reason <reason>``."""
cmd_add_exception([str(params["app_id"]), "--reason", str(params["reason"])])
return {"app_id": params["app_id"]}
def _enforce_demo(
params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter
) -> JobResult:
"""``enforce --demo``: the 60-second budget, until cancelled."""
if not params.get("demo"):
command = "enforce"
raise PrivilegedViaDaemonError(command)
return {"exit_code": cmd_enforce(Config.load(), State.load(), ["--demo"])}
def _restore_backup(
params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter
) -> JobResult:
"""Replace ``state.json`` with a backup, after the typed phrase."""
backup_id = str(params["backup_id"])
if not confirm_phrase(
"restore-backup",
f"Replace the current state with backup {backup_id}?",
backup_id=backup_id,
):
msg = "Aborted."
raise PromptAbortedError(msg)
previous = restore_backup(backup_id)
_echo(f"Restored backup {backup_id}.")
if previous is not None:
_echo(f"The replaced state was saved as backup {previous.id}.")
return {"restored": backup_id, "previous_backup": previous and previous.id}
def _via_daemon(command: str) -> JobHandler:
"""A placeholder for a privileged command: it always refuses."""
def handler(
_params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter
) -> JobResult:
raise PrivilegedViaDaemonError(command)
return handler
# (params, progress, prompter) -> result data, one per job command; the
# lock/cancel/privilege flags for the same commands are in _specs.JOB_FLAGS.
HANDLERS: dict[str, JobHandler] = {
"check": _cli(do_check),
"scan": _cli(do_scan),
"done": _cli(cmd_done),
"pick": _cli(cmd_pick),
"pick-manual": _with_args(cmd_pick_manual, "app_id"),
"abandon-pick": _with_args(cmd_abandon_pick, "app_id"),
"install": _cli(cmd_install),
"uninstall": _cli(cmd_uninstall),
"hide": _cli(cmd_hide),
"unhide": _cli(cmd_unhide),
"add-exception": _add_exception,
"reset": _cli(cmd_reset),
"restore-backup": _restore_backup,
"stats": _cli(cmd_stats),
"list": _cli(cmd_list),
"enforce": _enforce_demo,
} | {
command: _via_daemon(command)
for command in (
"gaming-reset",
"gaming-unblock",
"block-gaming",
"unblock",
"buy-dlc",
)
}

View File

@ -0,0 +1,215 @@
"""What runs inside a job subprocess: one command, start to result.
``python -m steam_backlog_enforcer.jobs run <job_dir>`` lands here. The
runner passes the same lock gate as the CLI, runs the command with the job's
output sink, progress reporter and prompter installed, and always ends with
exactly one ``result`` event, ``result.json`` and a final ``state`` event —
whether the command succeeded, refused, raised, exited or was cancelled.
"""
from __future__ import annotations
from contextlib import ExitStack
from dataclasses import dataclass
import fcntl
import json
import logging
import os
import signal
from typing import TYPE_CHECKING, Any, Final
from steam_backlog_enforcer._command_gate import lock_reason
from steam_backlog_enforcer._echo import routed_echo
from steam_backlog_enforcer._progress import use_progress
from steam_backlog_enforcer._prompter import PromptAbortedError, use_prompter
from steam_backlog_enforcer.config import Config, State, _atomic_write
from steam_backlog_enforcer.jobs import _spawn, _store
from steam_backlog_enforcer.jobs._errors import (
JobCancelledError,
JobError,
PrivilegedViaDaemonError,
)
from steam_backlog_enforcer.jobs._events import (
PID_NAME,
REQUEST_NAME,
RESULT_NAME,
EventWriter,
)
from steam_backlog_enforcer.jobs._job_io import EventLogHandler, JobLogSink, JobProgress
from steam_backlog_enforcer.jobs._job_prompter import JobPrompter
from steam_backlog_enforcer.jobs._registry import HANDLERS
from steam_backlog_enforcer.jobs._specs import JOB_FLAGS, JobFlags
from steam_backlog_enforcer.main._gate import enforce_gate
if TYPE_CHECKING:
from pathlib import Path
from types import FrameType
logger = logging.getLogger(__name__)
EXIT_OK = 0
EXIT_FAILED = 1
EXIT_REFUSED = 2
EXIT_CANCELLED = 130
# The job lock fd is ours alone: anything the job spawns must not keep the
# lock alive after we exit. (``os.set_inheritable`` takes it positionally.)
_CHILDREN_INHERIT_LOCK: Final = False
@dataclass
class _Outcome:
"""How the job ended; becomes the ``result`` event and ``result.json``."""
state: str
summary: str
exit_code: int
data: Any = None
@property
def ok(self) -> bool:
"""Whether the command did what was asked."""
return self.state == "succeeded"
def _failed(summary: str, exit_code: int = EXIT_FAILED) -> _Outcome:
"""A failed outcome."""
return _Outcome("failed", summary, exit_code)
def _hold_lock(flags: JobFlags) -> int | None:
"""Hold the one-mutating-job lock for the life of this process.
Spawned by the server, the lock is already ours (inherited descriptor);
run by hand, it is taken here. Either way it is made non-inheritable so
a program the job starts (Steam) cannot keep it after the job ends.
The inherited descriptor is checked against the lock file itself, not
trusted by number: were it closed or reused on the way in (a wrapper like
``systemd-run``), the busy guarantee would silently vanish.
Raises:
JobError: ``busy`` when another job holds the lock, or the inherited
descriptor is not the lock file.
"""
if not flags.mutating:
return None
inherited = os.environ.pop(_spawn.LOCK_FD_ENV, None)
if inherited is None:
fd = _store.acquire_mutating_lock()
else:
fd = int(inherited)
lock_file = (_store.JOBS_DIR / _store.LOCK_NAME).stat()
held = os.fstat(fd)
if (held.st_dev, held.st_ino) != (lock_file.st_dev, lock_file.st_ino):
msg = "The inherited job lock is not the lock file; refusing to run."
raise JobError(msg, code="busy")
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) # Ours already: no-op.
os.set_inheritable(fd, _CHILDREN_INHERIT_LOCK)
return fd
def _on_sigterm(*, cancellable: bool) -> None:
"""Install the SIGTERM policy: cancel if that is safe, else ignore it.
The handler does no I/O: a signal landing mid-``emit`` would otherwise
deadlock on the event writer's own lock.
"""
def cancel(_signum: int, _frame: FrameType | None) -> None:
raise JobCancelledError
signal.signal(signal.SIGTERM, cancel if cancellable else signal.SIG_IGN)
def _completed(sink: JobLogSink, data: object = None) -> _Outcome:
"""The outcome of a command that returned (or exited 0) normally.
Many commands report a failure by printing ``Error: ...`` and returning;
such a run is a failure, with that line as its summary.
"""
sink.flush()
if sink.last_error is not None:
return _failed(sink.last_error)
return _Outcome("succeeded", sink.last_line or "Done.", EXIT_OK, data)
def _run_command(
job_dir: Path, writer: EventWriter, sink: JobLogSink, request: dict[str, Any]
) -> _Outcome:
"""Gate and run the requested command under the job's I/O adapters."""
command = str(request["command"])
params = dict(request.get("params", {}))
flags, job = JOB_FLAGS.get(command), HANDLERS.get(command)
if flags is None or job is None:
return _failed(f"{command!r} does not run as a job.", EXIT_REFUSED)
_hold_lock(flags)
writer.emit("state", state="running")
progress = JobProgress(writer)
prompter = JobPrompter(writer, job_dir, preset_phrase=request.get("confirm_phrase"))
handler = EventLogHandler(writer)
with ExitStack() as stack:
stack.enter_context(routed_echo(sink))
stack.enter_context(use_progress(progress))
stack.enter_context(use_prompter(prompter))
logging.getLogger().addHandler(handler)
stack.callback(logging.getLogger().removeHandler, handler)
_on_sigterm(cancellable=flags.cancellable)
stack.callback(signal.signal, signal.SIGTERM, signal.SIG_IGN)
config = Config.load()
try:
enforce_gate(command, config)
except SystemExit:
# The gate already printed its banner (now log events); the
# summary is the one-line form of the same refusal.
sink.flush()
reason = lock_reason(command, config, State.load())
return _failed(reason or "Refused by the lock gate.", EXIT_REFUSED)
try:
data = job(params, progress, prompter)
except SystemExit as exc:
if exc.code in {0, None}:
return _completed(sink)
sink.flush()
return _failed(sink.last_line or f"Exited with status {exc.code}.")
return _completed(sink, data)
def _finish(job_dir: Path, writer: EventWriter, outcome: _Outcome) -> None:
"""Write the single result: event, ``result.json``, then final state."""
signal.signal(signal.SIGTERM, signal.SIG_IGN) # Never die half-written.
fields: dict[str, Any] = {"ok": outcome.ok, "summary": outcome.summary}
if outcome.data is not None:
fields["data"] = json.loads(json.dumps(outcome.data, default=str))
writer.emit("result", **fields)
result = fields | {"state": outcome.state, "exit_code": outcome.exit_code}
_atomic_write(job_dir / RESULT_NAME, json.dumps(result, indent=2) + "\n")
writer.emit("state", state=outcome.state)
def run_job(job_dir: Path) -> int:
"""Run the job in *job_dir* to completion and return its exit code."""
job_dir = job_dir.resolve()
writer = EventWriter(job_dir)
sink = JobLogSink(writer)
(job_dir / PID_NAME).write_text(f"{os.getpid()}\n", encoding="utf-8")
try:
request = json.loads((job_dir / REQUEST_NAME).read_text(encoding="utf-8"))
outcome = _run_command(job_dir, writer, sink, request)
except JobCancelledError, KeyboardInterrupt:
sink.flush()
outcome = _Outcome("cancelled", "Cancelled.", EXIT_CANCELLED)
except PromptAbortedError as exc:
outcome = _failed(str(exc) or "Aborted.")
except JobError as exc:
outcome = _failed(exc.message, EXIT_REFUSED)
except PrivilegedViaDaemonError as exc:
outcome = _failed(str(exc), EXIT_REFUSED)
except Exception as exc:
logger.exception("Job %s crashed", job_dir.name)
outcome = _failed(f"{type(exc).__name__}: {exc}")
except BaseException as exc: # SystemExit from outside a handler, etc.
logger.exception("Job %s stopped", job_dir.name)
outcome = _failed(f"{type(exc).__name__}: {exc}")
_finish(job_dir, writer, outcome)
return outcome.exit_code

View File

@ -0,0 +1,147 @@
"""Start a job's detached process, and notice at once when it never started.
Under systemd the job runs through ``systemd-run --user --scope``. When that
cannot register the scope (no user bus, a bad ``XDG_RUNTIME_DIR``) it exits
straight away and the job never runs — yet ``Popen`` succeeded, so without a
check the job would sit ``queued`` until the dead-pid reconciler failed it
30 s later. :func:`spawn_job` waits the few milliseconds it takes to see
either the job's own interpreter (``systemd-run`` execs into it in place) or
the launcher's exit, and fails the job with the launcher's output instead.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
from typing import Final
from steam_backlog_enforcer.config import _atomic_write
from steam_backlog_enforcer.jobs._errors import JobError
from steam_backlog_enforcer.jobs._events import (
OUTPUT_NAME,
RESULT_NAME,
EventWriter,
read_events,
)
LOCK_FD_ENV: Final = "SBE_JOB_LOCK_FD"
_PACKAGE_ROOT = Path(__file__).resolve().parents[2]
# How long to wait for the launcher to exec into the job (or die). A healthy
# systemd-run takes milliseconds; past this the reconciler takes over.
_CONFIRM_SECONDS: Final = 3.0
_POLL_SECONDS: Final = 0.02
_OUTPUT_TAIL_CHARS: Final = 600
_EXIT_SPAWN_FAILED: Final = 1
def _spawn_argv(path: Path) -> list[str]:
"""The job subprocess command line.
Under systemd (the web server's user unit) the job is moved into its own
transient scope, so restarting the server's unit — which kills its
whole cgroup — does not take running jobs down with it.
"""
argv = [sys.executable, "-m", "steam_backlog_enforcer.jobs", "run", str(path)]
systemd_run = shutil.which("systemd-run")
if os.environ.get("INVOCATION_ID") and systemd_run:
unit = f"sbe-job-{path.name.lower()}"
argv = [systemd_run, "--user", "--scope", "--quiet", "--collect",
f"--unit={unit}", "--", *argv] # fmt: skip
return argv
def _is_job_interpreter(pid: int) -> bool:
"""Whether *pid* is already the job's Python (not still the launcher)."""
try:
argv0 = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0", 1)[0]
except OSError:
return False
return argv0 == os.fsencode(sys.executable)
def _output_tail(path: Path) -> str:
"""The end of the job's captured stdout/stderr, for the failure log."""
try:
text = (path / OUTPUT_NAME).read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
return text.strip()[-_OUTPUT_TAIL_CHARS:]
def _fail_unstarted(path: Path, summary: str) -> JobError:
"""Fail a job whose process never started; return the error to raise."""
tail = _output_tail(path)
writer = EventWriter(path)
writer.emit("log", level="error", message=f"{summary}\n{tail}".strip())
writer.emit("result", ok=False, summary=summary)
result = {"ok": False, "summary": summary, "state": "failed"}
_atomic_write(
path / RESULT_NAME,
json.dumps(result | {"exit_code": _EXIT_SPAWN_FAILED}, indent=2) + "\n",
)
writer.emit("state", state="failed")
return JobError(f"{summary} {tail}".strip(), code="op_failed")
def _confirm_started(proc: subprocess.Popen[bytes], path: Path) -> None:
"""Return once the job runs; raise if the launcher died before it did.
A job that already finished (fast read-only commands can, without
systemd) wrote its own result, so an exit with a result is a success.
Raises:
JobError: ``op_failed`` when the launcher exited without the job
ever running.
"""
deadline = time.monotonic() + _CONFIRM_SECONDS
while time.monotonic() < deadline:
code = proc.poll()
if code is not None:
if any(e.get("type") == "result" for e in read_events(path)):
return
msg = f"The job process could not be started (launcher exit {code})."
raise _fail_unstarted(path, msg)
if _is_job_interpreter(proc.pid):
return
time.sleep(_POLL_SECONDS)
def spawn_job(path: Path, lock_fd: int | None) -> int:
"""Start the detached job process for *path* and return its pid.
Args:
path: The prepared job directory.
lock_fd: The held one-mutating-job lock, handed to the child.
Raises:
JobError: ``op_failed`` when the process could not be started; the
job is already marked ``failed`` with the reason in its log.
"""
env = dict(os.environ)
env["PYTHONPATH"] = os.pathsep.join(
p for p in (str(_PACKAGE_ROOT), env.get("PYTHONPATH", "")) if p
)
if lock_fd is not None:
env[LOCK_FD_ENV] = str(lock_fd)
try:
with (path / OUTPUT_NAME).open("ab") as out:
proc = subprocess.Popen(
_spawn_argv(path),
stdin=subprocess.DEVNULL,
stdout=out,
stderr=out,
cwd=_PACKAGE_ROOT,
env=env,
start_new_session=True,
pass_fds=() if lock_fd is None else (lock_fd,),
)
except OSError as exc:
msg = f"The job process could not be started: {exc}."
raise _fail_unstarted(path, msg) from exc
_confirm_started(proc, path)
return proc.pid

View File

@ -0,0 +1,73 @@
"""How each job command behaves: lock, cancel and privilege flags.
Pure data, deliberately free of command imports. The web server imports
this (through the job store and the catalog) from inside
``steam_backlog_enforcer.main`` → ``misc`` → ``_web_server``; importing the
command modules from here would close that loop into a circular import.
The handlers live in :mod:`._registry`, which only the job subprocess loads.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from collections.abc import Mapping
@dataclass(frozen=True)
class JobFlags:
"""How one command runs as a job.
Attributes:
mutating: Takes the one-mutating-job lock.
cancellable: SIGTERM cancels it (only where stopping midway is safe).
privileged: Runs in the root daemon; never spawned as a job.
"""
mutating: bool = True
cancellable: bool = False
privileged: bool = False
_PRIVILEGED = JobFlags(privileged=True)
JOB_FLAGS: Final[dict[str, JobFlags]] = {
# check is mutating: a new achievement releases the game and assigns
# the next one, exactly like done.
"check": JobFlags(),
"scan": JobFlags(cancellable=True),
"done": JobFlags(),
"pick": JobFlags(),
"pick-manual": JobFlags(),
"abandon-pick": JobFlags(),
"install": JobFlags(),
"uninstall": JobFlags(),
"hide": JobFlags(),
"unhide": JobFlags(),
"add-exception": JobFlags(),
"reset": JobFlags(),
"restore-backup": JobFlags(),
"stats": JobFlags(mutating=False, cancellable=True),
"list": JobFlags(mutating=False, cancellable=True),
# Only the demo runs as a job (see is_privileged).
"enforce": JobFlags(cancellable=True),
"gaming-reset": _PRIVILEGED,
"gaming-unblock": _PRIVILEGED,
"block-gaming": _PRIVILEGED,
"unblock": _PRIVILEGED,
"buy-dlc": _PRIVILEGED,
}
def is_privileged(command: str, params: Mapping[str, object]) -> bool:
"""Whether *command* with *params* must go to the root daemon.
``enforce`` is both: its 60-second demo runs as a job, anything else is
the real enforcer, which only the daemon may (re)start.
"""
if command == "enforce":
return not params.get("demo")
flags = JOB_FLAGS.get(command)
return flags is not None and flags.privileged

View File

@ -0,0 +1,199 @@
"""Create, prune and start jobs; enforce one mutating job at a time.
The ``busy`` check is a ``flock`` on ``jobs/.mutating.lock``. The server
takes it non-blocking *before* spawning (:mod:`._spawn`) and hands the open
descriptor to the child (``pass_fds``), so the child holds the lock for
exactly its own lifetime and there is no window between "nobody is running"
and "I am". The request is validated, lock gate included, *before* the lock
is taken, so a locked command is refused as ``locked``, never ``busy``.
"""
from __future__ import annotations
from datetime import UTC, datetime
import fcntl
import json
import os
from pathlib import Path
import re
import secrets
import shutil
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._command_gate import lock_reason
from steam_backlog_enforcer._command_params import (
InvalidParamsError,
validate_params,
)
from steam_backlog_enforcer.config import Config, State
from steam_backlog_enforcer.jobs._errors import JobError, PrivilegedViaDaemonError
from steam_backlog_enforcer.jobs._events import (
PID_NAME,
REQUEST_NAME,
TERMINAL_STATES,
EventWriter,
now_iso,
read_events,
)
from steam_backlog_enforcer.jobs._spawn import spawn_job
from steam_backlog_enforcer.jobs._specs import JOB_FLAGS, is_privileged
if TYPE_CHECKING:
from collections.abc import Mapping
JOBS_DIR = Path.home() / ".local" / "state" / "steam-backlog-enforcer" / "jobs"
MAX_JOBS: Final = 50
LOCK_NAME: Final = ".mutating.lock"
# Microseconds since 2026-10: two jobs created in the same second still
# sort by creation. Ids without them (older history) remain valid.
_ID_PATTERN = re.compile(r"^\d{8}T\d{6}(?:\d{6})?Z-[0-9a-f]{6}$")
def job_dir(job_id: str) -> Path:
"""Resolve a job id to its directory, refusing anything path-like.
Raises:
JobError: ``not_found`` for a malformed or unknown id.
"""
path = JOBS_DIR / job_id
if not _ID_PATTERN.fullmatch(job_id) or not (path / REQUEST_NAME).is_file():
msg = f"No such job: {job_id!r}"
raise JobError(msg, code="not_found")
return path
def new_job_id() -> str:
"""A sortable id: UTC timestamp to the microsecond, plus a random suffix.
Sorting by name is creation order (newest-first lists, oldest-first
pruning); the suffix only keeps simultaneous ids distinct.
"""
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
return f"{stamp}-{secrets.token_hex(3)}"
def is_terminal(path: Path) -> bool:
"""Whether the job in *path* has reached a final state."""
states = [e.get("state") for e in read_events(path) if e.get("type") == "state"]
return bool(states) and states[-1] in TERMINAL_STATES
def prune(keep: int = MAX_JOBS) -> None:
"""Delete the oldest finished job dirs so at most *keep* remain.
A job that is still running is never deleted, however old.
"""
if not JOBS_DIR.is_dir():
return
dirs = sorted(
(p for p in JOBS_DIR.iterdir() if _ID_PATTERN.fullmatch(p.name)),
key=lambda p: p.name,
)
for path in dirs[: max(0, len(dirs) - keep)]:
if is_terminal(path):
shutil.rmtree(path, ignore_errors=True)
def acquire_mutating_lock() -> int:
"""Take the one-mutating-job lock and return its open descriptor.
Raises:
JobError: ``busy`` when another mutating job holds it.
"""
JOBS_DIR.mkdir(parents=True, exist_ok=True)
fd = os.open(JOBS_DIR / LOCK_NAME, os.O_RDWR | os.O_CREAT, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
os.close(fd)
msg = "Another job that changes state is running."
raise JobError(msg, code="busy") from None
return fd
def _check_request(command: str, params: Mapping[str, object]) -> dict[str, object]:
"""Validate a request the way the server must before creating a job.
Raises:
JobError: ``unknown_command``, ``invalid_params`` or ``locked``.
PrivilegedViaDaemonError: The command belongs to the root daemon.
"""
if command not in JOB_FLAGS:
msg = f"{command!r} does not run as a job."
raise JobError(msg, code="unknown_command")
try:
clean = validate_params(command, params)
except InvalidParamsError as exc:
raise JobError(str(exc), code="invalid_params") from exc
if is_privileged(command, clean):
raise PrivilegedViaDaemonError(command)
reason = lock_reason(command, Config.load(), State.load())
if reason is not None:
raise JobError(reason, code="locked")
return dict(clean)
def prepare_job(
command: str,
params: Mapping[str, object],
*,
confirm_phrase: str | None = None,
) -> Path:
"""Validate a request and write its job dir, without starting it.
Returns:
The new job directory, holding ``request.json`` and a ``queued`` event.
"""
return _write_job(
command, _check_request(command, params), confirm_phrase=confirm_phrase
)
def _write_job(
command: str, clean: Mapping[str, object], *, confirm_phrase: str | None
) -> Path:
"""Write the job dir for an already-validated request."""
prune(MAX_JOBS - 1)
path = JOBS_DIR / new_job_id()
path.mkdir(parents=True, mode=0o700)
request: dict[str, object] = {
"command": command,
"params": dict(clean),
"created_at": now_iso(),
}
if confirm_phrase is not None:
request["confirm_phrase"] = confirm_phrase
(path / REQUEST_NAME).write_text(json.dumps(request) + "\n", encoding="utf-8")
EventWriter(path).emit("state", state="queued")
return path
def create_job(
command: str,
params: Mapping[str, object],
*,
confirm_phrase: str | None = None,
) -> str:
"""Create and start a job; return its id.
Args:
command: Canonical command name.
params: Raw params from ``JobRequest.params``.
confirm_phrase: ``JobRequest.confirm_phrase``; re-checked by the job.
Raises:
JobError: ``unknown_command``, ``invalid_params``, ``locked``,
``busy``, or ``op_failed`` when the process could not be started
(the job is then already marked ``failed``).
PrivilegedViaDaemonError: Route this request to the daemon instead.
"""
clean = _check_request(command, params)
lock_fd = acquire_mutating_lock() if JOB_FLAGS[command].mutating else None
try:
path = _write_job(command, clean, confirm_phrase=confirm_phrase)
pid = spawn_job(path, lock_fd)
(path / PID_NAME).write_text(f"{pid}\n", encoding="utf-8")
finally:
if lock_fd is not None:
os.close(lock_fd) # The child keeps its inherited copy.
return path.name

View File

@ -0,0 +1,149 @@
"""Read jobs back for the API: the ``Job`` objects and their event streams.
A ``Job`` is never stored; it is folded from ``request.json`` and the event
log every time, so it cannot disagree with the events the UI streams.
A job whose process died without writing a result (killed, OOM, machine
reboot) would otherwise read as "running" forever. :func:`read_job` notices
the dead pid and appends the ``failed`` verdict on the job's behalf.
"""
from __future__ import annotations
import json
from pathlib import Path
import time
from typing import Any, Final
from steam_backlog_enforcer.jobs import _store
from steam_backlog_enforcer.jobs._errors import JobError
from steam_backlog_enforcer.jobs._events import (
PID_NAME,
REQUEST_NAME,
RESULT_NAME,
TERMINAL_STATES,
EventWriter,
read_events,
)
# A fresh job has no pid file for the instant between mkdir and spawn.
_SPAWN_GRACE_SECONDS: Final = 30
_PROGRESS_KEYS: Final = ("step", "total", "label", "item", "eta_seconds")
def _pid_alive(path: Path) -> bool | None:
"""Whether the job's own process is running; ``None`` if unknown yet.
Checks the command line too, so a recycled pid is not mistaken for the
job (a zombie has an empty one and counts as dead).
"""
try:
pid = int((path / PID_NAME).read_text(encoding="utf-8").strip())
except FileNotFoundError, ValueError:
return None
try:
cmdline = Path(f"/proc/{pid}/cmdline").read_bytes()
except OSError:
return False
return b"steam_backlog_enforcer.jobs" in cmdline and path.name.encode() in cmdline
def pid_of(path: Path) -> int | None:
"""The live job process's pid, or ``None`` if it is not running."""
if not _pid_alive(path):
return None
return int((path / PID_NAME).read_text(encoding="utf-8").strip())
def _reconcile(path: Path, state: str) -> None:
"""Fail a non-terminal job whose process is gone."""
if state in TERMINAL_STATES:
return
alive = _pid_alive(path)
if alive is None:
age = time.time() - (path / REQUEST_NAME).stat().st_mtime
if age < _SPAWN_GRACE_SECONDS:
return
elif alive:
return
summary = "The job process exited without reporting a result."
writer = EventWriter(path)
writer.emit("result", ok=False, summary=summary)
writer.emit("state", state="failed")
def _fold(path: Path) -> dict[str, Any]:
"""Build the contract's ``Job`` object from one job dir."""
request = json.loads((path / REQUEST_NAME).read_text(encoding="utf-8"))
job: dict[str, Any] = {
"id": path.name,
"command": request["command"],
"params": request.get("params", {}),
"state": "queued",
"created_at": request.get("created_at"),
"started_at": None,
"ended_at": None,
"exit_code": None,
"progress": None,
"summary": None,
}
for event in read_events(path):
kind = event.get("type")
if kind == "state":
job["state"] = event["state"]
if event["state"] == "running" and job["started_at"] is None:
job["started_at"] = event["ts"]
if event["state"] in TERMINAL_STATES:
job["ended_at"] = event["ts"]
elif kind == "progress":
job["progress"] = {k: event[k] for k in _PROGRESS_KEYS if k in event}
elif kind == "result":
job["summary"] = event.get("summary")
try:
result = json.loads((path / RESULT_NAME).read_text(encoding="utf-8"))
job["exit_code"] = result.get("exit_code")
except FileNotFoundError, ValueError:
pass
return job
def read_job(job_id: str) -> dict[str, Any]:
"""Return one ``Job``, failing it first if its process died.
Raises:
JobError: ``not_found`` for an unknown id.
"""
path = _store.job_dir(job_id)
job = _fold(path)
if job["state"] not in TERMINAL_STATES:
_reconcile(path, job["state"])
job = _fold(path)
return job
def list_jobs() -> list[dict[str, Any]]:
"""Return every kept ``Job``, newest first (at most ``MAX_JOBS``)."""
if not _store.JOBS_DIR.is_dir():
return []
ids = sorted(
(p.name for p in _store.JOBS_DIR.iterdir() if (p / REQUEST_NAME).is_file()),
reverse=True,
)
jobs = []
for job_id in ids[: _store.MAX_JOBS]:
try:
jobs.append(read_job(job_id))
except JobError, OSError, ValueError, KeyError:
continue # A half-written or hand-damaged dir is not fatal.
return jobs
def read_job_events(job_id: str, after_seq: int = 0) -> list[dict[str, Any]]:
"""Return a job's events after *after_seq*, for the SSE stream.
Raises:
JobError: ``not_found`` for an unknown id.
"""
path = _store.job_dir(job_id)
read_job(job_id) # Reconcile first, so a dead job's stream can end.
return read_events(path, after_seq)

View File

@ -24,6 +24,7 @@ from steam_backlog_enforcer._actions import allowed_app_ids
from steam_backlog_enforcer._cdp import _cdp_result_value, _evaluate_js from steam_backlog_enforcer._cdp import _cdp_result_value, _evaluate_js
from steam_backlog_enforcer._echo import _echo from steam_backlog_enforcer._echo import _echo
from steam_backlog_enforcer._owned_apps_cache import get_all_owned_app_ids from steam_backlog_enforcer._owned_apps_cache import get_all_owned_app_ids
from steam_backlog_enforcer._progress import current_progress
from steam_backlog_enforcer._steam_errors import ( from steam_backlog_enforcer._steam_errors import (
SteamUnavailableError, SteamUnavailableError,
) )
@ -177,10 +178,15 @@ def try_hide_other_games(
pass ran; otherwise it explains why hiding was skipped and the count pass ran; otherwise it explains why hiding was skipped and the count
is 0. is 0.
""" """
# One CDP evaluation does every pass, so there is no per-game step.
progress = current_progress()
progress.phase("Hiding games in the Steam library", 1)
try: try:
return hide_other_games(owned_app_ids, allowed_app_ids), None return hide_other_games(owned_app_ids, allowed_app_ids), None
except SteamUnavailableError as exc: except SteamUnavailableError as exc:
return 0, str(exc) return 0, str(exc)
finally:
progress.advance(step=1)
def unhide_all_games(owned_app_ids: list[int]) -> int: def unhide_all_games(owned_app_ids: list[int]) -> int:
@ -188,6 +194,9 @@ def unhide_all_games(owned_app_ids: list[int]) -> int:
Returns the number of games that were unhidden. Returns the number of games that were unhidden.
""" """
# One CDP evaluation unhides everything, so the phase is a single step.
progress = current_progress()
progress.phase("Unhiding games in the Steam library", 1)
ensure_steam_debug_port() ensure_steam_debug_port()
json.dumps(sorted(owned_app_ids)) json.dumps(sorted(owned_app_ids))
@ -203,6 +212,7 @@ def unhide_all_games(owned_app_ids: list[int]) -> int:
""" """
result = _evaluate_js(js) result = _evaluate_js(js)
progress.advance(step=1)
value = _cdp_result_value(result) value = _cdp_result_value(result)
parsed = json.loads(value) parsed = json.loads(value)
count: int = parsed["count"] count: int = parsed["count"]

View File

@ -23,9 +23,14 @@ from steam_backlog_enforcer._cmd_playtime import (
cmd_gaming_status, cmd_gaming_status,
cmd_gaming_unblock, cmd_gaming_unblock,
) )
from steam_backlog_enforcer._command_help import (
COMMAND_DESCRIPTIONS,
EXTRA_COMMAND_DESCRIPTIONS,
)
from steam_backlog_enforcer._stats import cmd_stats from steam_backlog_enforcer._stats import cmd_stats
from steam_backlog_enforcer.config import Config, State from steam_backlog_enforcer.config import Config, State
from steam_backlog_enforcer.game_install import _echo from steam_backlog_enforcer.game_install import _echo
from steam_backlog_enforcer.main._gate import enforce_gate
from steam_backlog_enforcer.main._registry import _make_all_commands from steam_backlog_enforcer.main._registry import _make_all_commands
from steam_backlog_enforcer.main._registry import ( from steam_backlog_enforcer.main._registry import (
_print_usage as _print_usage_for, _print_usage as _print_usage_for,
@ -45,6 +50,7 @@ from steam_backlog_enforcer.main._shared import (
_show_total_block_lock_message, _show_total_block_lock_message,
) )
from steam_backlog_enforcer.main.abandon import cmd_abandon_pick from steam_backlog_enforcer.main.abandon import cmd_abandon_pick
from steam_backlog_enforcer.main.exception import cmd_add_exception
from steam_backlog_enforcer.main.install import ( from steam_backlog_enforcer.main.install import (
cmd_hide, cmd_hide,
cmd_install, cmd_install,
@ -53,7 +59,6 @@ from steam_backlog_enforcer.main.install import (
cmd_uninstall, cmd_uninstall,
) )
from steam_backlog_enforcer.main.misc import ( from steam_backlog_enforcer.main.misc import (
cmd_add_exception,
cmd_block_gaming, cmd_block_gaming,
cmd_buy_dlc, cmd_buy_dlc,
cmd_reset, cmd_reset,
@ -117,41 +122,34 @@ __all__ = [
"main", "main",
] ]
_HANDLERS: dict[str, Callable[[Config, State], object]] = {
"scan": do_scan,
"check": do_check,
"status": cmd_status,
"list": cmd_list,
"install": cmd_install,
"hide": cmd_hide,
"unhide": cmd_unhide,
"buy-dlc": cmd_buy_dlc,
"reset": cmd_reset,
"installed": cmd_installed,
"uninstall": cmd_uninstall,
"setup": cmd_setup,
"done": cmd_done,
"pick": cmd_pick,
"stats": cmd_stats,
"gaming-status": cmd_gaming_status,
"gaming-reset": cmd_gaming_reset,
}
# Descriptions live in _command_help (the web catalog reads them too); the
# help order is theirs.
COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = { COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = {
"scan": ("Scan library & assign a game", do_scan), name: (desc, _HANDLERS[name]) for name, desc in COMMAND_DESCRIPTIONS.items()
"check": ("Check assigned game for a new achievement", do_check),
"status": ("Show current status", cmd_status),
"list": ("List games from snapshot", cmd_list),
"install": ("Install the assigned game", cmd_install),
"hide": ("Hide all non-assigned games in library", cmd_hide),
"unhide": ("Unhide all games in library", cmd_unhide),
"buy-dlc": ("Unblock the store for 15 min to buy a game/DLC", cmd_buy_dlc),
"reset": ("Reset all state", cmd_reset),
"installed": ("List installed games", cmd_installed),
"uninstall": ("Uninstall all non-assigned games", cmd_uninstall),
"setup": ("Run first-time setup", cmd_setup),
"done": ("Move on after a new achievement, pick next", cmd_done),
"pick": ("Manually pick your next game from candidates", cmd_pick),
"stats": ("Show backlog completion-time estimates", cmd_stats),
"gaming-status": ("Show today's gaming time and block state", cmd_gaming_status),
"gaming-reset": ("Reset today's gaming counter (root + YES)", cmd_gaming_reset),
} }
# Extra commands with non-standard arg handling (shown in help but not in COMMANDS). # Plus the commands with non-standard arg handling (help only, not COMMANDS).
_EXTRA_COMMAND_DESCRIPTIONS: dict[str, str] = { _ALL_COMMANDS: dict[str, str] = _make_all_commands(COMMANDS, EXTRA_COMMAND_DESCRIPTIONS)
"add-exception": "Request 24h-locked whitelist exception (use --reason)",
"unblock": "Unblock the store for [minutes] (default 15, max 30)",
"serve": "Start the web UI (--port N; replaces a stale server)",
"pick-manual": f"Pick a game by app_id, lock enforcer for {_MANUAL_LOCK_DAYS} days",
"abandon-pick": "Undo a manual pick at any time (needs app_id)",
"block-gaming": "Block ALL gaming for <days> days, no in-app undo",
"enforce": "Run enforcer: block, uninstall, kill, hide (--demo for a 60s budget)",
"gaming-unblock": "Force-release playtime bind mounts (root; recovery hatch)",
}
_ALL_COMMANDS: dict[str, str] = _make_all_commands(
COMMANDS, _EXTRA_COMMAND_DESCRIPTIONS
)
def _resolve_command(raw: str) -> str | None: def _resolve_command(raw: str) -> str | None:
@ -215,19 +213,8 @@ def main() -> None:
_echo(f"Note: treating '{sys.argv[1]}' as '{command}'.") _echo(f"Note: treating '{sys.argv[1]}' as '{command}'.")
config = Config.load() config = Config.load()
# Not-configured, total-block and manual-pick checks; web jobs share it.
if command not in {"setup", "add-exception"} and not config.steam_api_key: state = enforce_gate(command, config)
_echo("Not configured. Run 'setup' first.")
sys.exit(1)
state = State.load()
# Total block is the most restrictive lock - check it first.
_enforce_total_block_lock(command)
# Enforce the manual-pick lock before dispatching any command.
# This also covers add-exception (previously dispatched before state load).
_enforce_manual_pick_lock(command, state)
if _dispatch_extra_command(command, config, state): if _dispatch_extra_command(command, config, state):
return return

View File

@ -0,0 +1,51 @@
"""The lock gate every command passes before it runs, CLI or web job.
One gate, so a command refused at the terminal is refused in the browser
too. The rules themselves live in :mod:`steam_backlog_enforcer._command_gate`
(:func:`~steam_backlog_enforcer._command_gate.lock_reason`); this module is
the CLI's printing, exiting half of it.
"""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING
from steam_backlog_enforcer._command_gate import NOT_CONFIGURED_MSG, UNCONFIGURED_OK
from steam_backlog_enforcer.config import State
from steam_backlog_enforcer.game_install import _echo
from steam_backlog_enforcer.main._shared import (
_enforce_manual_pick_lock,
_enforce_total_block_lock,
)
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config
def enforce_gate(command: str, config: Config) -> State:
"""Run the gate the CLI way: print the lock message and exit on refusal.
The web job runner calls this too, under its output sink, so the very
same lock banner reaches the UI as log lines.
Args:
command: Canonical command name (never raw argv).
config: Loaded configuration.
Returns:
The loaded state, once every check passed.
"""
if command not in UNCONFIGURED_OK and not config.steam_api_key:
_echo(NOT_CONFIGURED_MSG)
sys.exit(1)
state = State.load()
# Total block is the most restrictive lock - check it first.
_enforce_total_block_lock(command)
# Enforce the manual-pick lock before dispatching any command.
# This also covers add-exception (previously dispatched before state load).
_enforce_manual_pick_lock(command, state)
return state

View File

@ -18,6 +18,10 @@ from steam_backlog_enforcer._actions import (
from steam_backlog_enforcer._allowed_games import ( from steam_backlog_enforcer._allowed_games import (
MANUAL_LOCK_DAYS as _MANUAL_LOCK_DAYS, MANUAL_LOCK_DAYS as _MANUAL_LOCK_DAYS,
) )
from steam_backlog_enforcer._command_locks import (
_MANUAL_LOCK_EXEMPT_COMMANDS,
_TOTAL_BLOCK_EXEMPT_COMMANDS,
)
from steam_backlog_enforcer._manual_pick_lifecycle import manual_pick_age_days from steam_backlog_enforcer._manual_pick_lifecycle import manual_pick_age_days
from steam_backlog_enforcer._total_block import ( from steam_backlog_enforcer._total_block import (
TotalBlockStatus, TotalBlockStatus,
@ -32,42 +36,6 @@ if TYPE_CHECKING:
_LIST_DISPLAY_LIMIT = 50 _LIST_DISPLAY_LIMIT = 50
_MIN_CLI_ARGS = 2 _MIN_CLI_ARGS = 2
# Commands that remain usable while the manual pick lock is active.
# Principle: only what is needed to release the lock (done/check) or
# that cannot change the game assignment (status, enforce, setup, serve).
_MANUAL_LOCK_EXEMPT_COMMANDS = frozenset(
{
"done",
"check",
"status",
"enforce",
"setup",
"serve",
"abandon-pick",
# Allowed so a second game can be locked in alongside the first; the
# cap inside cmd_pick_manual is what stops this being a way out.
"pick-manual",
# The daily gaming budget is orthogonal to which game is assigned, and
# gaming-unblock is a recovery hatch for a stuck bind mount - locking
# it behind a manual pick would leave Steam masked with no way back.
"gaming-status",
"gaming-unblock",
"gaming-reset",
}
)
# Commands that remain usable while a total gaming block is active. Far
# stricter than _MANUAL_LOCK_EXEMPT_COMMANDS: no done/pick/reset/
# add-exception - there is no in-app way to shorten a total block.
#
# gaming-unblock is included because a playtime bind mount makes the total
# block's own `pacman -R steam` fail EBUSY - it must stay reachable exactly
# when the two collide. gaming-reset is NOT included: it shortens enforcement.
_TOTAL_BLOCK_EXEMPT_COMMANDS = frozenset(
{"status", "enforce", "gaming-status", "gaming-unblock"}
)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Total gaming block lock helpers # Total gaming block lock helpers
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────

View File

@ -13,6 +13,7 @@ from steam_backlog_enforcer._actions import (
find_manual_pick, find_manual_pick,
) )
from steam_backlog_enforcer._manual_pick_lifecycle import abandon_manual_pick from steam_backlog_enforcer._manual_pick_lifecycle import abandon_manual_pick
from steam_backlog_enforcer._prompter import confirm_phrase
from steam_backlog_enforcer.game_install import ( from steam_backlog_enforcer.game_install import (
_echo, _echo,
is_game_installed, is_game_installed,
@ -98,8 +99,9 @@ def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None:
else: else:
_echo("\n - Leaves you with no assigned game (run 'scan' to get one)") _echo("\n - Leaves you with no assigned game (run 'scan' to get one)")
_echo() _echo()
confirm = input(f"Type YES to abandon {game_name}: ").strip() if not confirm_phrase(
if confirm != "YES": "abandon-pick", f"Abandon manual pick {game_name}?", game_name=game_name
):
_echo("Aborted.") _echo("Aborted.")
return return

View File

@ -0,0 +1,79 @@
"""``add-exception``: request a whitelist exception for one game.
Split out of :mod:`steam_backlog_enforcer.main.misc` when the command gained
its typed-phrase confirmation, to keep both files under the 250-line cap.
"""
from __future__ import annotations
import sys
from steam_backlog_enforcer._prompter import confirm_phrase
from steam_backlog_enforcer._snapshot import snapshot_game_name
from steam_backlog_enforcer._whitelist import validate_reason
from steam_backlog_enforcer._whitelist_locking import add_pending_exception
from steam_backlog_enforcer.game_install import _echo
_MIN_ADD_EXCEPTION_ARGS = 3
_ADD_EXCEPTION_USAGE = (
'Usage: add-exception <app_id> --reason "<justification>"\n'
" app_id : numeric Steam application ID\n"
" --reason : genuine justification (>= 5 words)\n\n"
"Example:\n"
" add-exception 440 --reason "
'"TF2 is needed for a community event this weekend"\n\n'
"Exceptions become active immediately."
)
def cmd_add_exception(args: list[str]) -> None:
"""Add a whitelist exception, active immediately.
Usage: add-exception <app_id> --reason "<text>"
The exception becomes active right away (no cooldown). The reason must be
a genuine justification of at least 5 words with sufficient entropy, and
the ``add-exception`` friction phrase must be typed to confirm.
Args:
args: CLI argument list after the command name.
"""
if len(args) < _MIN_ADD_EXCEPTION_ARGS or "--reason" not in args:
_echo(_ADD_EXCEPTION_USAGE)
sys.exit(1)
try:
app_id = int(args[0])
except ValueError:
_echo(f"Error: app_id must be a number, got '{args[0]}'.")
sys.exit(1)
reason_idx = args.index("--reason")
reason_parts = args[reason_idx + 1 :]
if not reason_parts:
_echo("Error: --reason requires a value.")
sys.exit(1)
reason = " ".join(reason_parts)
# Show validation feedback before attempting to add.
err = validate_reason(reason)
if err is not None:
_echo(f"Invalid reason: {err}")
sys.exit(1)
game_name = snapshot_game_name(app_id) or f"AppID={app_id}"
if not confirm_phrase(
"add-exception",
f"Request a whitelist exception for {game_name}?",
game_name=game_name,
):
_echo("Aborted.")
sys.exit(1)
try:
msg = add_pending_exception(app_id, reason)
except ValueError as exc:
_echo(f"Error: {exc}")
sys.exit(1)
_echo(msg)

View File

@ -6,6 +6,8 @@ from typing import TYPE_CHECKING
from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games
from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
from steam_backlog_enforcer._progress import current_progress
from steam_backlog_enforcer._prompter import confirm_phrase
from steam_backlog_enforcer._steam_state import is_game_fully_installed from steam_backlog_enforcer._steam_state import is_game_fully_installed
from steam_backlog_enforcer.game_install import ( from steam_backlog_enforcer.game_install import (
_echo, _echo,
@ -60,8 +62,9 @@ def cmd_uninstall(_config: Config, state: State) -> None:
_echo(f" - {name} (AppID={aid})") _echo(f" - {name} (AppID={aid})")
_echo() _echo()
confirm = input("Type YES to confirm: ").strip() if not confirm_phrase(
if confirm != "YES": "uninstall", f"Uninstall {len(to_remove)} games?", count=len(to_remove)
):
_echo("Aborted.") _echo("Aborted.")
return return
@ -80,6 +83,9 @@ def cmd_install(config: Config, state: State) -> None:
return return
_echo(f"Installing {state.current_game_name} (AppID={state.current_app_id})...") _echo(f"Installing {state.current_game_name} (AppID={state.current_app_id})...")
# install_game only *starts* the download (fast installer or steam://);
# there is no download-progress loop to report on yet.
current_progress().phase(f"Starting install of {state.current_game_name}")
if install_game( if install_game(
state.current_app_id, state.current_app_id,
state.current_game_name, state.current_game_name,

View File

@ -1,7 +1,7 @@
"""Commands that do not belong to a larger group. """Commands that do not belong to a larger group.
Store blocking, state reset, setup, whitelist exceptions, the web UI and the Store blocking, state reset, setup, the web UI and the total gaming block.
total gaming block. ``add-exception`` lives in :mod:`steam_backlog_enforcer.main.exception`.
""" """
from __future__ import annotations from __future__ import annotations
@ -10,8 +10,10 @@ import errno
import sys import sys
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from steam_backlog_enforcer._backups import create_backup
from steam_backlog_enforcer._config_setup import interactive_setup from steam_backlog_enforcer._config_setup import interactive_setup
from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
from steam_backlog_enforcer._prompter import confirm_phrase
from steam_backlog_enforcer._serve_startup import ( from steam_backlog_enforcer._serve_startup import (
ensure_port_available, ensure_port_available,
parse_serve_args, parse_serve_args,
@ -24,26 +26,12 @@ from steam_backlog_enforcer._store_window import (
from steam_backlog_enforcer._total_block import start_total_block from steam_backlog_enforcer._total_block import start_total_block
from steam_backlog_enforcer._web_build import build_frontend, frontend_is_stale from steam_backlog_enforcer._web_build import build_frontend, frontend_is_stale
from steam_backlog_enforcer._web_server import serve from steam_backlog_enforcer._web_server import serve
from steam_backlog_enforcer._whitelist import validate_reason
from steam_backlog_enforcer._whitelist_locking import add_pending_exception
from steam_backlog_enforcer.game_install import _echo from steam_backlog_enforcer.game_install import _echo
from steam_backlog_enforcer.library_hider import unhide_all_games from steam_backlog_enforcer.library_hider import unhide_all_games
from steam_backlog_enforcer.store_blocker import unblock_store
if TYPE_CHECKING: if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config, State from steam_backlog_enforcer.config import Config, State
_MIN_ADD_EXCEPTION_ARGS = 3
_ADD_EXCEPTION_USAGE = (
'Usage: add-exception <app_id> --reason "<justification>"\n'
" app_id : numeric Steam application ID\n"
" --reason : genuine justification (>= 5 words)\n\n"
"Example:\n"
" add-exception 440 --reason "
'"TF2 is needed for a community event this weekend"\n\n'
"Exceptions become active immediately."
)
_BLOCK_GAMING_USAGE = ( _BLOCK_GAMING_USAGE = (
"Usage: block-gaming <days>\n" "Usage: block-gaming <days>\n"
" days : whole number of days to block ALL gaming:\n" " days : whole number of days to block ALL gaming:\n"
@ -84,8 +72,22 @@ def cmd_buy_dlc(config: Config, state: State) -> None:
def cmd_reset(config: Config, state: State) -> None: def cmd_reset(config: Config, state: State) -> None:
"""Reset all state (unblock, unhide, clear assignment).""" """Reset all state (unhide, clear assignment), backed up first.
unblock_store()
The store block is deliberately left alone. The daemon only re-blocks the
store at startup or when a timed window ends, so lifting it here would be
an open-ended window that skips ``unblock``'s 1-30 minute cap and phrase.
Args:
config: Loaded configuration.
state: State to wipe.
"""
if not confirm_phrase("reset", "Wipe all enforcer state?"):
_echo("Aborted.")
return
backup = create_backup("before reset")
if backup is not None:
_echo(f"State backed up as {backup.id}.")
# Unhide all games in the library. # Unhide all games in the library.
try: try:
@ -108,7 +110,7 @@ def cmd_reset(config: Config, state: State) -> None:
state.last_assigned_at = {} state.last_assigned_at = {}
state.released_at = {} state.released_at = {}
state.save() state.save()
_echo("State reset. Store unblocked.") _echo("State reset. Store left blocked: open a timed window with 'unblock'.")
def cmd_setup(_config: Config, _state: State) -> None: def cmd_setup(_config: Config, _state: State) -> None:
@ -144,49 +146,6 @@ def cmd_serve(args: list[str]) -> None:
sys.exit(1) sys.exit(1)
def cmd_add_exception(args: list[str]) -> None:
"""Add a whitelist exception, active immediately.
Usage: add-exception <app_id> --reason "<text>"
The exception becomes active right away (no cooldown). The reason must be
a genuine justification of at least 5 words with sufficient entropy.
Args:
args: CLI argument list after the command name.
"""
if len(args) < _MIN_ADD_EXCEPTION_ARGS or "--reason" not in args:
_echo(_ADD_EXCEPTION_USAGE)
sys.exit(1)
try:
app_id = int(args[0])
except ValueError:
_echo(f"Error: app_id must be a number, got '{args[0]}'.")
sys.exit(1)
reason_idx = args.index("--reason")
reason_parts = args[reason_idx + 1 :]
if not reason_parts:
_echo("Error: --reason requires a value.")
sys.exit(1)
reason = " ".join(reason_parts)
# Show validation feedback before attempting to add.
err = validate_reason(reason)
if err is not None:
_echo(f"Invalid reason: {err}")
sys.exit(1)
try:
msg = add_pending_exception(app_id, reason)
except ValueError as exc:
_echo(f"Error: {exc}")
sys.exit(1)
_echo(msg)
def cmd_block_gaming(args: list[str]) -> None: def cmd_block_gaming(args: list[str]) -> None:
"""Start a total gaming block for a fixed number of days. """Start a total gaming block for a fixed number of days.
@ -220,8 +179,9 @@ def cmd_block_gaming(args: list[str]) -> None:
f"\nsystem administration outside this tool." f"\nsystem administration outside this tool."
) )
_echo() _echo()
confirm = input(f"Type YES to confirm a {days}-day total gaming block: ").strip() if not confirm_phrase(
if confirm != "YES": "block-gaming", f"Block all gaming for {days} day(s)?", days=days
):
_echo("Aborted.") _echo("Aborted.")
return return

View File

@ -20,6 +20,8 @@ from steam_backlog_enforcer._pick_completion import (
report_completion, report_completion,
warn_stale_assignment, warn_stale_assignment,
) )
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 from steam_backlog_enforcer._snapshot import load_snapshot, snapshot_game_name
from steam_backlog_enforcer._steam_state import is_game_fully_installed from steam_backlog_enforcer._steam_state import is_game_fully_installed
from steam_backlog_enforcer.game_install import ( from steam_backlog_enforcer.game_install import (
@ -129,7 +131,8 @@ def _apply_allowed_set(config: Config, state: State) -> None:
if count: if count:
_echo(f" Uninstalled {count} non-allowed game(s)") _echo(f" Uninstalled {count} non-allowed game(s)")
for app_id, name in allowed_games(state): games = allowed_games(state)
for app_id, name in tracked(games, "Installing allowed games", lambda g: g[1]):
if is_game_fully_installed(app_id): if is_game_fully_installed(app_id):
_echo(f" {name} is already installed.") _echo(f" {name} is already installed.")
continue continue
@ -149,7 +152,7 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None:
state: Current enforcer state. state: Current enforcer state.
args: Remaining CLI args (first element should be the app_id). args: Remaining CLI args (first element should be the app_id).
""" """
raw_id = args[0] if args else input("Enter Steam app_id: ").strip() raw_id = args[0] if args else current_prompter().text("Enter Steam app_id")
try: try:
app_id = int(raw_id) app_id = int(raw_id)
@ -189,11 +192,11 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None:
" playable until you confirm)" " playable until you confirm)"
) )
_echo() _echo()
confirm = input( if not confirm_phrase(
f"Type YES to confirm you will play {game_name} until you earn a new" "pick-manual",
" achievement: " f"Confirm you will play {game_name} until you earn a new achievement.",
).strip() game_name=game_name,
if confirm != "YES": ):
_echo("Aborted.") _echo("Aborted.")
warn_stale_assignment(state, retired) warn_stale_assignment(state, retired)
return return

View File

@ -17,6 +17,7 @@ from typing import Any
import aiohttp import aiohttp
from steam_backlog_enforcer._progress import gather_tracked
from steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write from steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -155,7 +156,7 @@ async def _fetch_batch(app_ids: list[int]) -> list[ProtonDBRating]:
sem = asyncio.Semaphore(MAX_CONCURRENT) sem = asyncio.Semaphore(MAX_CONCURRENT)
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
tasks = [_fetch_one(session, sem, aid) for aid in app_ids] tasks = [_fetch_one(session, sem, aid) for aid in app_ids]
results = await asyncio.gather(*tasks) results = await gather_tracked("Fetching ProtonDB ratings", tasks)
return [r for r in results if r is not None] return [r for r in results if r is not None]

View File

@ -16,6 +16,7 @@ from steam_backlog_enforcer._hltb_types import (
load_hltb_polls_cache, load_hltb_polls_cache,
) )
from steam_backlog_enforcer._pick_completion import report_completion from steam_backlog_enforcer._pick_completion import report_completion
from steam_backlog_enforcer._progress import current_progress
from steam_backlog_enforcer._scanning_assign import ( from steam_backlog_enforcer._scanning_assign import (
_NO_CONF_MSG, _NO_CONF_MSG,
_assign_chosen_game, _assign_chosen_game,
@ -151,7 +152,7 @@ def pick_next_game(
state: State, state: State,
config: Config, config: Config,
*, *,
on_select: Callable[[GameInfo], bool] | None = None, on_select: Callable[[GameInfo], bool | GameInfo] | None = None,
) -> None: ) -> None:
"""Present a ranked list of eligible games and let the user pick one. """Present a ranked list of eligible games and let the user pick one.
@ -181,8 +182,8 @@ def pick_next_game(
_clear_assignment(state, _no_pick_message(confidence_skipped, linux_skipped)) _clear_assignment(state, _no_pick_message(confidence_skipped, linux_skipped))
return return
idx = _prompt_user_pick(qualified) chosen = _prompt_user_pick(qualified, games, state)
_assign_chosen_game(qualified[idx], games, state, config) _assign_chosen_game(chosen, games, state, config)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
@ -200,7 +201,10 @@ def do_check(config: Config, state: State) -> None:
client = SteamAPIClient(config.steam_api_key, config.steam_id) client = SteamAPIClient(config.steam_api_key, config.steam_id)
_echo(f"Checking {state.current_game_name} (AppID={state.current_app_id})...") _echo(f"Checking {state.current_game_name} (AppID={state.current_app_id})...")
progress = current_progress()
progress.phase("Fetching achievements from Steam", 1)
game = client.refresh_single_game(state.current_app_id, state.current_game_name) game = client.refresh_single_game(state.current_app_id, state.current_game_name)
progress.advance(state.current_game_name)
if game is None: if game is None:
_echo(" Could not fetch achievement data.") _echo(" Could not fetch achievement data.")
return return

View File

@ -0,0 +1,64 @@
"""Shared builders for the control-socket tests."""
from __future__ import annotations
from contextlib import contextmanager, suppress
from datetime import UTC, datetime
from pathlib import Path
import socket
import tempfile
import threading
from typing import TYPE_CHECKING
from steam_backlog_enforcer._ctl_context import CtlContext
from steam_backlog_enforcer.config import Config
if TYPE_CHECKING:
from collections.abc import Iterator
def make_ctx(*, supervised: bool = True) -> CtlContext:
"""A fresh op context over a default config."""
return CtlContext(
config=Config(),
tick_lock=threading.Lock(),
restart_event=threading.Event(),
started_at=datetime(2026, 1, 1, tzinfo=UTC),
supervised=supervised,
)
@contextmanager
def short_socket_dir() -> Iterator[Path]:
"""A short-path temp dir: AF_UNIX paths are capped near 108 bytes."""
with tempfile.TemporaryDirectory(prefix="ctl") as name:
yield Path(name)
def serve_once(path: Path, reply: bytes) -> threading.Thread:
"""Answer exactly one connection on *path* with *reply*, then close.
The listener is bound before this returns, so a client may connect at once.
"""
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
listener.bind(str(path))
listener.listen(1)
def run() -> None:
conn, _ = listener.accept()
with conn, listener:
conn.settimeout(5)
data = b""
while b"\n" not in data:
chunk = conn.recv(1024)
if not chunk:
break
data += chunk
# The client may hang up mid-reply (the oversize case).
if reply:
with suppress(OSError):
conn.sendall(reply)
thread = threading.Thread(target=run, daemon=True)
thread.start()
return thread

View File

@ -0,0 +1,78 @@
"""A scripted prompter for tests that drive the questions a command asks.
Answers are consumed in order; asking more questions than were scripted is a
test bug and raises ``IndexError``. :class:`FakePicker` adds the optional
``game`` question a browser front end can answer (``GamePicker``).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
from steam_backlog_enforcer._prompter import PromptOption
class FakePrompter:
"""Answers ``choice`` / ``confirm`` / ``phrase`` / ``text`` from scripts."""
def __init__(
self,
*,
interactive: bool = True,
choices: Sequence[str] = (),
confirms: Sequence[bool] = (),
phrases: Sequence[bool] = (),
texts: Sequence[str] = (),
) -> None:
"""Store the scripted answers and an empty question log."""
self.interactive = interactive
self._choices = list(choices)
self._confirms = list(confirms)
self._phrases = list(phrases)
self._texts = list(texts)
self.asked: list[tuple[str, object]] = []
def choice(self, message: str, options: Sequence[PromptOption]) -> str:
"""Log the offered values and return the next scripted choice."""
self.asked.append((message, [option.value for option in options]))
return self._choices.pop(0)
def confirm(self, message: str, *, default: bool) -> bool:
"""Log the question and return the next scripted yes/no."""
self.asked.append((message, default))
return self._confirms.pop(0)
def phrase(self, message: str, expected: str) -> bool:
"""Log the expected phrase and return the next scripted verdict."""
self.asked.append((message, expected))
return self._phrases.pop(0)
def text(self, message: str) -> str:
"""Log the question and return the next scripted line."""
self.asked.append((message, None))
return self._texts.pop(0)
class FakePicker(FakePrompter):
"""A prompter that can also show the library (a ``GamePicker``)."""
def __init__(
self,
*,
picks: Sequence[int | None] = (),
choices: Sequence[str] = (),
texts: Sequence[str] = (),
) -> None:
"""Store the scripted library picks next to the other answers."""
super().__init__(choices=choices, texts=texts)
self._picks = list(picks)
self.offered: list[list[int]] = []
def game(self, message: str, app_ids: Sequence[int]) -> int | None:
"""Log the offered app ids and return the next scripted pick."""
self.asked.append((message, None))
self.offered.append(list(app_ids))
return self._picks.pop(0)

View File

@ -0,0 +1,89 @@
"""Autouse isolation of the control plane and the HLTB endpoint memo.
Redirects the web cover-art caches, the daemon control socket, restart
markers, backups and web job store (all under ``/var/lib``, ``/run`` or the
real ``~/.local/state``) into ``tmp_path``, and forgets the remembered HLTB
search URL around every test.
Split out of ``conftest.py`` to keep every file inside the 250-line cap;
``conftest`` imports the fixtures by name, which is what registers them.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from steam_backlog_enforcer._hltb_search_api import forget_hltb_search_url
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
@pytest.fixture(autouse=True)
def _isolate_control_plane(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> Iterator[None]:
"""Redirect the daemon control socket, restart state, backups and job dirs.
Without it a test that reaches the restart-exit marker or a job store
touches ``/var/lib/steam-backlog-enforcer`` or the real
``~/.local/state`` (and the web token lives under ``XDG_RUNTIME_DIR``).
Default arguments bound at import (``path=SOCKET_PATH``) cannot be
redirected here: tests pass an explicit path for those.
"""
state = tmp_path / "ctl_state"
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path / "xdg_runtime"))
with (
patch("steam_backlog_enforcer._ctl_gap.RESTART_EXIT_FILE", state / "exit.json"),
patch(
"steam_backlog_enforcer._ctl_restart.RESTART_STATE_FILE",
state / "restart.json",
),
patch("steam_backlog_enforcer._ctl_protocol.SOCKET_PATH", state / "ctl.sock"),
patch("steam_backlog_enforcer._ctl_client.SOCKET_PATH", state / "ctl.sock"),
patch("steam_backlog_enforcer._ctl_server.SOCKET_PATH", state / "ctl.sock"),
patch("steam_backlog_enforcer._backups.BACKUPS_DIR", tmp_path / "backups"),
patch("steam_backlog_enforcer.jobs._store.JOBS_DIR", tmp_path / "jobs"),
):
yield
@pytest.fixture(autouse=True)
def _isolate_cover_art(tmp_path: Path) -> Iterator[None]:
"""Cover art: Steam's librarycache is read, the art cache is written."""
with (
patch(
"steam_backlog_enforcer._web_art.LIBRARYCACHE",
tmp_path / "fake_home" / "librarycache",
),
patch("steam_backlog_enforcer._web_art.ART_CACHE_DIR", tmp_path / "art"),
):
yield
@pytest.fixture(autouse=True)
def _forget_hltb_search_url() -> Iterator[None]:
"""Start every test without a remembered HLTB search endpoint.
``_get_hltb_search_url`` memoises a discovered URL for half an hour; a
value left behind by one test would hide the discovery path in the next.
"""
forget_hltb_search_url()
yield
forget_hltb_search_url()
@pytest.fixture(autouse=True)
def _forget_hltb_search_url() -> Iterator[None]:
"""Start every test without a remembered HLTB search endpoint.
``_get_hltb_search_url`` memoises a discovered URL for half an hour; a
value left behind by one test would hide the discovery path in the next.
"""
forget_hltb_search_url()
yield
forget_hltb_search_url()

View File

@ -0,0 +1,53 @@
"""Shared builders for the ``test_jobs_*`` files: fake job dirs on disk.
The autouse ``_isolate_control_plane`` fixture points ``_store.JOBS_DIR`` at
``tmp_path``; everything here reaches it through the module attribute.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from steam_backlog_enforcer.jobs import _store
from steam_backlog_enforcer.jobs._events import (
PID_NAME,
REQUEST_NAME,
RESULT_NAME,
EventWriter,
)
if TYPE_CHECKING:
from pathlib import Path
JOB_ID = "20261010T120000000000Z-abcdef"
def make_job(
command: str = "scan",
*,
job_id: str = JOB_ID,
states: tuple[str, ...] = ("queued", "running"),
pid: str | None = None,
params: dict[str, object] | None = None,
) -> Path:
"""Write a job dir with a request and one ``state`` event per state."""
path = _store.JOBS_DIR / job_id
path.mkdir(parents=True)
request = {
"command": command,
"params": params or {},
"created_at": "2026-10-10T12:00:00Z",
}
(path / REQUEST_NAME).write_text(json.dumps(request) + "\n", encoding="utf-8")
writer = EventWriter(path)
for state in states:
writer.emit("state", state=state)
if pid is not None:
(path / PID_NAME).write_text(pid, encoding="utf-8")
return path
def write_result(path: Path, **fields: object) -> None:
"""Write ``result.json`` the way a finished job does."""
(path / RESULT_NAME).write_text(json.dumps(fields), encoding="utf-8")

View File

@ -0,0 +1,28 @@
"""Helpers shared by the ``test_web_*`` files: build a ``Request``."""
from __future__ import annotations
from email.message import Message
from typing import Any
from steam_backlog_enforcer._web_io import Reply, Request
def make_request(
*args: str,
body: dict[str, Any] | None = None,
query: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
method: str = "GET",
) -> Request:
"""A ``Request`` with the captured path *args* and optional extras."""
message = Message()
for name, value in (headers or {}).items():
message[name] = value
return Request(method, "/", query or {}, args, message, body or {})
def payload_of(reply: Reply) -> dict[str, Any]:
"""The JSON-object payload of *reply* (fails the test if it is not one)."""
assert isinstance(reply.payload, dict)
return reply.payload

View File

@ -9,8 +9,7 @@ to temporary directories. This stops tests from accidentally:
- Modifying /etc/hosts via the store blocker - Modifying /etc/hosts via the store blocker
- Corrupting the HLTB cache on disk - Corrupting the HLTB cache on disk
- Launching real Steam or calling real subprocess commands - Launching real Steam or calling real subprocess commands
- Deleting real ~/.steam, ~/.local/share/Steam, etc. via the total-block - Deleting real ~/.steam, ~/.local/share/Steam via the remnant purge
Steam/Proton remnant purge
""" """
from __future__ import annotations from __future__ import annotations
@ -20,7 +19,12 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
# Imported for its autouse side effect: naming it here registers it. # Imported for the autouse side effect (naming them registers them).
from steam_backlog_enforcer.tests._isolate_control_plane import (
_forget_hltb_search_url,
_isolate_control_plane,
_isolate_cover_art,
)
from steam_backlog_enforcer.tests._isolate_incidents import _isolate_incidents from steam_backlog_enforcer.tests._isolate_incidents import _isolate_incidents
from steam_backlog_enforcer.tests._isolate_playtime import _isolate_playtime from steam_backlog_enforcer.tests._isolate_playtime import _isolate_playtime
from steam_backlog_enforcer.tests._isolate_reading import _isolate_reading from steam_backlog_enforcer.tests._isolate_reading import _isolate_reading
@ -33,6 +37,9 @@ from steam_backlog_enforcer.tests._no_workout_http import _no_workout_http
# registers autouse fixtures by name, so they look unused to the linter. # registers autouse fixtures by name, so they look unused to the linter.
__all__ = [ __all__ = [
"_block_real_subprocesses", "_block_real_subprocesses",
"_forget_hltb_search_url",
"_isolate_control_plane",
"_isolate_cover_art",
"_isolate_incidents", "_isolate_incidents",
"_isolate_playtime", "_isolate_playtime",
"_isolate_reading", "_isolate_reading",
@ -185,16 +192,15 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]:
def _no_real_sleep() -> Iterator[None]: def _no_real_sleep() -> Iterator[None]:
"""No-op every ``time.sleep`` used by the package. """No-op every ``time.sleep`` used by the package.
Several modules call ``time.sleep`` for Steam-launch / install-retry / Modules call ``time.sleep`` to pace Steam launch / install retries /
rate-limit pacing. Individual tests that need to observe sleep rate limits; tests that observe sleep override these patches.
behaviour can override these patches inside their own ``with`` block.
""" """
noop = MagicMock() noop = MagicMock()
with ( with (
patch("steam_backlog_enforcer._steam_client.time.sleep", noop), patch("steam_backlog_enforcer._steam_client.time.sleep", noop),
patch("steam_backlog_enforcer._steam_launch.time.sleep", noop), patch("steam_backlog_enforcer._steam_launch.time.sleep", noop),
patch("steam_backlog_enforcer._steam_api_client.time.sleep", noop), patch("steam_backlog_enforcer._steam_api_client.time.sleep", noop),
# The loop waits on an inotify wake, not time.sleep: stub its factory. # The loop waits on an inotify wake, not time.sleep.
patch("steam_backlog_enforcer._enforce_loop.open_file_wake", noop), patch("steam_backlog_enforcer._enforce_loop.open_file_wake", noop),
): ):
yield yield

View File

@ -0,0 +1,133 @@
"""Tests for _achievement_skip: remembering games with no achievements."""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from steam_backlog_enforcer import _achievement_skip as skip
from steam_backlog_enforcer import config
_PKG = "steam_backlog_enforcer._achievement_skip"
_NOW = 1_000_000_000.0
_STEAM_ID = "sid"
def _store(payload: object) -> None:
skip._cache_file().write_text(json.dumps(payload), encoding="utf-8")
def _game(app_id: int, *, stats: bool = False) -> dict[str, Any]:
return {"appid": app_id, "has_community_visible_stats": stats}
class _HttpError(Exception):
"""What ``requests`` raises: an error that carries its response."""
def __init__(self, response: MagicMock) -> None:
super().__init__("http error")
self.response = response
def _failure(status: int | None, body: object = None) -> Exception:
"""An exception whose cause carries an HTTP response (or none)."""
exc = RuntimeError("achievements failed")
if status is not None:
response = MagicMock(status_code=status)
if isinstance(body, Exception):
response.json.side_effect = body
else:
response.json.return_value = body
exc.__cause__ = _HttpError(response)
return exc
class TestCacheFile:
def test_lives_under_the_config_dir(self) -> None:
assert skip._cache_file() == config.CONFIG_DIR / "no_achievements_cache.json"
class TestLoadChecked:
def test_missing_file_is_empty(self) -> None:
assert skip._load_checked(_STEAM_ID) == {}
def test_corrupt_file_is_empty(self) -> None:
skip._cache_file().write_text("{nope", encoding="utf-8")
assert skip._load_checked(_STEAM_ID) == {}
@pytest.mark.parametrize("payload", [["list"], {"steam_id": "other"}])
def test_other_account_or_shape_is_empty(self, payload: object) -> None:
_store(payload)
assert skip._load_checked(_STEAM_ID) == {}
def test_skips_unparsable_entries(self) -> None:
_store({"steam_id": _STEAM_ID, "checked": {"1": 5, "x": 6, "2": None}})
assert skip._load_checked(_STEAM_ID) == {1: 5.0}
class TestIsNoStatsAnswer:
def test_steams_no_stats_answer(self) -> None:
body = {"playerstats": {"error": "no stats", "success": False}}
assert skip.is_no_stats_answer(_failure(400, body)) is True
def test_no_response_is_not_an_answer(self) -> None:
assert skip.is_no_stats_answer(_failure(None)) is False
def test_other_status_is_not_an_answer(self) -> None:
assert skip.is_no_stats_answer(_failure(500, {})) is False
def test_unparsable_body_is_not_an_answer(self) -> None:
assert skip.is_no_stats_answer(_failure(400, ValueError("html"))) is False
def test_body_without_failure_flag_is_not_an_answer(self) -> None:
assert skip.is_no_stats_answer(_failure(400, {"playerstats": {}})) is False
def test_non_dict_playerstats_is_not_an_answer(self) -> None:
assert skip.is_no_stats_answer(_failure(400, {"playerstats": 3})) is False
class TestSkippableAppIds:
def test_nothing_recorded_skips_nothing(self) -> None:
assert skip.skippable_app_ids(_STEAM_ID, [_game(1)], _NOW) == set()
def test_recent_flagless_game_is_skipped(self) -> None:
_store({"steam_id": _STEAM_ID, "checked": {"1": _NOW - 10}})
with patch(f"{_PKG}.load_snapshot", return_value=None):
found = skip.skippable_app_ids(_STEAM_ID, [_game(1)], _NOW)
assert found == {1}
def test_flagged_snapshotted_and_stale_games_are_scanned(self) -> None:
stale = _NOW - skip.RECHECK_AFTER_SECONDS - 1
_store(
{
"steam_id": _STEAM_ID,
"checked": {"1": _NOW - 10, "2": _NOW - 10, "3": stale, "4": _NOW},
}
)
owned = [_game(1, stats=True), _game(2), _game(3), _game(4), _game(5)]
with patch(f"{_PKG}.load_snapshot", return_value=[{"app_id": 2}]):
found = skip.skippable_app_ids(_STEAM_ID, owned, _NOW)
assert found == {4}
class TestRecordScan:
def _saved(self) -> dict[str, float]:
return json.loads(skip._cache_file().read_text(encoding="utf-8"))["checked"]
def test_records_games_that_came_back_empty(self) -> None:
skip.record_scan(_STEAM_ID, [_game(1), _game(2)], {2}, _NOW)
assert self._saved() == {"1": _NOW}
def test_forgets_games_that_gained_achievements_or_the_flag(self) -> None:
_store({"steam_id": _STEAM_ID, "checked": {"1": 5, "2": 5, "3": 5}})
fetched = [_game(1), _game(2, stats=True), _game(3)]
skip.record_scan(_STEAM_ID, fetched, {1}, _NOW)
assert self._saved() == {"3": _NOW}
def test_a_failed_write_is_swallowed(self) -> None:
with patch(f"{_PKG}._atomic_write", side_effect=OSError("disk full")):
skip.record_scan(_STEAM_ID, [_game(1)], set(), _NOW)
assert not skip._cache_file().exists()

Some files were not shown because too many files have changed in this diff Show More