mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 16:48:37 +01:00
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:
parent
fcca4e946b
commit
341bc784ac
@ -110,7 +110,7 @@ repos:
|
||||
- --disable-error-code=list-item
|
||||
additional_dependencies:
|
||||
- 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
|
||||
@ -130,7 +130,7 @@ repos:
|
||||
# (steam_backlog_enforcer._mcp_server).
|
||||
additional_dependencies:
|
||||
- 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
|
||||
- mcp==2.3.0
|
||||
- pytest
|
||||
|
||||
75
DOCS-ctl-socket.md
Normal file
75
DOCS-ctl-socket.md
Normal 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
206
DOCS-web-control-api.md
Normal 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
99
TODO-web-control-ui.md
Normal 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)
|
||||
23
install.sh
23
install.sh
@ -64,6 +64,25 @@ 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).
|
||||
#
|
||||
# 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 enable steam-backlog-enforcer
|
||||
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 " Check: sudo systemctl status steam-backlog-enforcer"
|
||||
echo " Logs: sudo journalctl -u steam-backlog-enforcer -f"
|
||||
|
||||
@ -16,7 +16,7 @@ dependencies = [
|
||||
"websockets==17.2",
|
||||
# The earner registry (gaming budget terms); stdlib-only, so the root
|
||||
# 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
|
||||
|
||||
@ -5,7 +5,7 @@ aiohttp==3.14.4
|
||||
bandit==1.9.4
|
||||
codespell==2.4.3
|
||||
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
|
||||
mcp==2.3.0
|
||||
mypy==2.4.0
|
||||
|
||||
5
run.sh
5
run.sh
@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
if [[ $# -eq 0 ]]; then
|
||||
exec python -m steam_backlog_enforcer.main "done"
|
||||
exec scripts/open_ui.sh
|
||||
else
|
||||
exec python -m steam_backlog_enforcer.main "$@"
|
||||
fi
|
||||
|
||||
61
scripts/open_ui.sh
Executable file
61
scripts/open_ui.sh
Executable 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 "$@"
|
||||
9
steam-backlog-enforcer.desktop
Normal file
9
steam-backlog-enforcer.desktop
Normal 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
|
||||
@ -16,6 +16,19 @@ Environment=STEAM_ENFORCER_DESKTOP_USER=__DESKTOP_USER__
|
||||
Environment=DISPLAY=:0
|
||||
# Hardening: enforcer must not be easily killed.
|
||||
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
|
||||
# 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.
|
||||
|
||||
138
steam_backlog_enforcer/_achievement_skip.py
Normal file
138
steam_backlog_enforcer/_achievement_skip.py
Normal 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)
|
||||
143
steam_backlog_enforcer/_backups.py
Normal file
143
steam_backlog_enforcer/_backups.py
Normal 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
|
||||
@ -23,7 +23,10 @@ import json
|
||||
import logging
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -79,7 +82,10 @@ def _last_record(*, demo: bool) -> dict[str, Any] | None:
|
||||
record = json.loads(line)
|
||||
except ValueError:
|
||||
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 None
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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 (
|
||||
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.game_install import (
|
||||
_echo,
|
||||
@ -24,38 +29,42 @@ if TYPE_CHECKING:
|
||||
|
||||
_REASSIGN_REFRESH_LIMIT = 50
|
||||
_SKIP_DAYS = 7
|
||||
_KEEP = "keep"
|
||||
_SKIP = "skip"
|
||||
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``.
|
||||
|
||||
Returns ``True`` to accept the pick, ``False`` to skip it (which the
|
||||
caller will translate into a 7-day skip entry on ``State``). When
|
||||
stdin is not a TTY (e.g. background daemon, piped invocation), the
|
||||
pick is accepted silently to preserve the legacy non-interactive
|
||||
behaviour.
|
||||
caller will translate into a 7-day skip entry on ``State``), or the game
|
||||
the user searched for when they pick their own instead. When no human
|
||||
can answer (a CLI whose stdin is not a TTY: background daemon, piped
|
||||
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
|
||||
hours_str = ""
|
||||
if game.completionist_hours > 0:
|
||||
hours_str = f" (~{game.completionist_hours:.1f}h leisure+dlc)"
|
||||
_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:
|
||||
try:
|
||||
answer = (
|
||||
input(f" Keep this game? [Y/n] (n = skip for {_SKIP_DAYS} days): ")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
except EOFError:
|
||||
return True
|
||||
if answer in {"", "y", "yes"}:
|
||||
return True
|
||||
if answer in {"n", "no"}:
|
||||
return False
|
||||
_echo(" Please answer 'y' or 'n'.")
|
||||
answer = prompter.choice(" Keep this game?", options)
|
||||
if answer != OWN_PICK_VALUE:
|
||||
return answer == _KEEP
|
||||
own = prompt_own_pick(games, state)
|
||||
if own is not None:
|
||||
return own
|
||||
|
||||
|
||||
def _backfill_polls_for_finished(
|
||||
|
||||
@ -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_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._steam_state import is_game_fully_installed
|
||||
from steam_backlog_enforcer.enforcer import (
|
||||
@ -70,7 +71,12 @@ def _finalize_completion(
|
||||
skip = set(state.finished_app_ids) | state.active_skipped_ids()
|
||||
_refresh_uncached_shortlist_hours(games, hltb_cache, skip)
|
||||
_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}:
|
||||
# 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
|
||||
|
||||
_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)
|
||||
progress.advance(game_name)
|
||||
if game is None:
|
||||
_echo(" Could not fetch achievement data from Steam.")
|
||||
return
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""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
|
||||
: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
|
||||
@ -9,24 +9,22 @@ the JSON-RPC channel.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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 (
|
||||
block_targets,
|
||||
mounted_targets,
|
||||
release_block,
|
||||
)
|
||||
from steam_backlog_enforcer._playtime_state import (
|
||||
PlaytimeState,
|
||||
load_state,
|
||||
rules_for,
|
||||
save_state,
|
||||
state_path,
|
||||
)
|
||||
from steam_backlog_enforcer._prompter import confirm_phrase
|
||||
from steam_backlog_enforcer.game_install import _echo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -92,11 +90,11 @@ def cmd_gaming_status(config: Config, _state: State) -> None:
|
||||
_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.
|
||||
|
||||
Args:
|
||||
config: Enforcer configuration.
|
||||
_config: Unused; required by the command dispatch signature.
|
||||
_state: Unused; required by the command dispatch signature.
|
||||
|
||||
Returns:
|
||||
@ -107,17 +105,12 @@ def cmd_gaming_reset(config: Config, _state: State) -> int:
|
||||
return 1
|
||||
|
||||
_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.")
|
||||
return 1
|
||||
|
||||
released = release_block()
|
||||
now = datetime.now(UTC).astimezone()
|
||||
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).")
|
||||
outcome = reset_today(source="cli")
|
||||
_echo(f"Gaming counter reset. Released {len(outcome.released)} mount(s).")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
141
steam_backlog_enforcer/_command_catalog.py
Normal file
141
steam_backlog_enforcer/_command_catalog.py
Normal 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()
|
||||
]
|
||||
64
steam_backlog_enforcer/_command_gate.py
Normal file
64
steam_backlog_enforcer/_command_gate.py
Normal 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
|
||||
50
steam_backlog_enforcer/_command_help.py
Normal file
50
steam_backlog_enforcer/_command_help.py
Normal 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
|
||||
)
|
||||
45
steam_backlog_enforcer/_command_locks.py
Normal file
45
steam_backlog_enforcer/_command_locks.py
Normal 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"}
|
||||
)
|
||||
149
steam_backlog_enforcer/_command_params.py
Normal file
149
steam_backlog_enforcer/_command_params.py
Normal 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
|
||||
@ -8,26 +8,44 @@ already treated it as its own unit.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import sys
|
||||
|
||||
from steam_backlog_enforcer import config as config_mod
|
||||
from steam_backlog_enforcer._prompter import current_prompter
|
||||
from steam_backlog_enforcer.config import Config
|
||||
|
||||
|
||||
def interactive_setup() -> Config:
|
||||
"""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:
|
||||
sys.exit(1)
|
||||
|
||||
steam_id = input("Enter your Steam64 ID: ").strip()
|
||||
steam_id = prompter.text("Enter your Steam64 ID")
|
||||
if not steam_id:
|
||||
sys.exit(1)
|
||||
|
||||
config = Config(steam_api_key=api_key, steam_id=steam_id)
|
||||
config.save()
|
||||
return save_credentials(api_key, steam_id)
|
||||
|
||||
|
||||
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
|
||||
# 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.
|
||||
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
|
||||
|
||||
214
steam_backlog_enforcer/_ctl_client.py
Normal file
214
steam_backlog_enforcer/_ctl_client.py
Normal 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
|
||||
142
steam_backlog_enforcer/_ctl_context.py
Normal file
142
steam_backlog_enforcer/_ctl_context.py
Normal 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")
|
||||
117
steam_backlog_enforcer/_ctl_control.py
Normal file
117
steam_backlog_enforcer/_ctl_control.py
Normal 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)
|
||||
89
steam_backlog_enforcer/_ctl_gap.py
Normal file
89
steam_backlog_enforcer/_ctl_gap.py
Normal 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
|
||||
)
|
||||
161
steam_backlog_enforcer/_ctl_ops.py
Normal file
161
steam_backlog_enforcer/_ctl_ops.py
Normal 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,
|
||||
}
|
||||
182
steam_backlog_enforcer/_ctl_pending.py
Normal file
182
steam_backlog_enforcer/_ctl_pending.py
Normal 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
|
||||
95
steam_backlog_enforcer/_ctl_protocol.py
Normal file
95
steam_backlog_enforcer/_ctl_protocol.py
Normal 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)
|
||||
104
steam_backlog_enforcer/_ctl_reset.py
Normal file
104
steam_backlog_enforcer/_ctl_reset.py
Normal 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,
|
||||
}
|
||||
109
steam_backlog_enforcer/_ctl_restart.py
Normal file
109
steam_backlog_enforcer/_ctl_restart.py
Normal 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}
|
||||
204
steam_backlog_enforcer/_ctl_server.py
Normal file
204
steam_backlog_enforcer/_ctl_server.py
Normal 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)
|
||||
56
steam_backlog_enforcer/_ctl_types.py
Normal file
56
steam_backlog_enforcer/_ctl_types.py
Normal 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]
|
||||
89
steam_backlog_enforcer/_ctl_wire.py
Normal file
89
steam_backlog_enforcer/_ctl_wire.py
Normal 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
|
||||
@ -1,26 +1,55 @@
|
||||
"""User-facing CLI output.
|
||||
|
||||
A one-function leaf module so every command can print without importing the
|
||||
install machinery. Split out of :mod:`steam_backlog_enforcer.game_install` to
|
||||
keep that module under the 250-line cap.
|
||||
A leaf module so every command can print without importing the install
|
||||
machinery. Split out of :mod:`steam_backlog_enforcer.game_install` to keep
|
||||
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 contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
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:
|
||||
"""Write user-facing CLI output to stdout.
|
||||
"""Write user-facing CLI output to stdout, or to the active sink.
|
||||
|
||||
Args:
|
||||
msg: Text to output.
|
||||
end: String appended after the message.
|
||||
flush: Whether to flush stdout immediately.
|
||||
"""
|
||||
sink = _SINK.get()
|
||||
if sink is not None:
|
||||
sink(msg + end)
|
||||
return
|
||||
sys.stdout.write(msg + end)
|
||||
if 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)
|
||||
|
||||
68
steam_backlog_enforcer/_enforce_guards.py
Normal file
68
steam_backlog_enforcer/_enforce_guards.py
Normal 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
|
||||
@ -5,8 +5,11 @@ from __future__ import annotations
|
||||
import json
|
||||
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._enforce_guards import allowed_names, guard_installed_games
|
||||
from steam_backlog_enforcer._enforce_steps import (
|
||||
_enforce_setup,
|
||||
_reinstall_missing_allowed,
|
||||
@ -40,11 +43,6 @@ from steam_backlog_enforcer.enforcer import (
|
||||
enforce_allowed_game,
|
||||
send_notification,
|
||||
)
|
||||
from steam_backlog_enforcer.game_uninstall import (
|
||||
get_installed_games,
|
||||
is_protected_app,
|
||||
uninstall_game,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -58,49 +56,32 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def _allowed_names(state: State) -> str:
|
||||
"""Return a human-readable list of the games the user may play.
|
||||
def _reload_state(state: State) -> bool:
|
||||
"""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:
|
||||
state: Current enforcer state.
|
||||
state: The daemon's state object, updated in place.
|
||||
|
||||
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]
|
||||
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
|
||||
try:
|
||||
fresh = State.load()
|
||||
except (json.JSONDecodeError, OSError, ValueError) as exc:
|
||||
logger.warning("Failed to reload state: %s", exc)
|
||||
return False
|
||||
# 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))
|
||||
return True
|
||||
|
||||
|
||||
def _enforce_loop_iteration(
|
||||
@ -164,12 +145,12 @@ def _enforce_loop_iteration(
|
||||
send_notification(
|
||||
"Game Blocked!",
|
||||
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.
|
||||
if config.uninstall_other_games:
|
||||
removed = _guard_installed_games(allowed)
|
||||
removed = guard_installed_games(allowed)
|
||||
if removed > 0:
|
||||
_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)
|
||||
# A credited workout wakes the wait, so the budget moves within the tick.
|
||||
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:
|
||||
while True:
|
||||
# Reload state from disk so CLI changes (e.g. new game
|
||||
# assignment via ``done`` / ``scan``) take effect immediately
|
||||
# without needing to restart the daemon.
|
||||
try:
|
||||
fresh = State.load()
|
||||
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)
|
||||
with control.tick_lock:
|
||||
# Reloaded under the lock: a control-socket op that saved
|
||||
# state a moment ago must not be overwritten by a copy read
|
||||
# before it ran.
|
||||
if _reload_state(state):
|
||||
_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)
|
||||
except KeyboardInterrupt:
|
||||
_echo("\nEnforcer stopped.")
|
||||
finally:
|
||||
control.close()
|
||||
|
||||
68
steam_backlog_enforcer/_friction.py
Normal file
68
steam_backlog_enforcer/_friction.py
Normal 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
|
||||
123
steam_backlog_enforcer/_gaming_reset.py
Normal file
123
steam_backlog_enforcer/_gaming_reset.py
Normal 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)
|
||||
@ -18,11 +18,36 @@ from steam_backlog_enforcer._hltb_types import (
|
||||
load_hltb_rush_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
|
||||
|
||||
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)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@ -55,7 +80,10 @@ def fetch_hltb_times_cached(
|
||||
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.
|
||||
save_hltb_cache(cache, polls, extras)
|
||||
|
||||
@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import time
|
||||
|
||||
from steam_backlog_enforcer._hltb_cached import reporting_progress_cb
|
||||
from steam_backlog_enforcer._hltb_types import (
|
||||
HLTB_BASE_URL,
|
||||
ProgressCb,
|
||||
@ -82,7 +83,9 @@ def fetch_hltb_confidence_cached(
|
||||
uncached,
|
||||
cache=cache,
|
||||
polls=polls,
|
||||
progress_cb=progress_cb,
|
||||
progress_cb=reporting_progress_cb(
|
||||
"Fetching HLTB poll counts", len(uncached), progress_cb
|
||||
),
|
||||
count_comp=count_comp,
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
@ -169,7 +172,8 @@ def fetch_hltb_detail_missing(
|
||||
)
|
||||
else:
|
||||
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)
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@ -27,13 +26,10 @@ from steam_backlog_enforcer._hltb_types import (
|
||||
_HLTBExtras,
|
||||
save_hltb_cache,
|
||||
)
|
||||
from steam_backlog_enforcer._progress import gather_tracked
|
||||
|
||||
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:
|
||||
"""Extract base-game leisure hours from game detail data.
|
||||
@ -183,7 +179,8 @@ async def _fetch_leisure_times(
|
||||
connector=connector,
|
||||
) as session:
|
||||
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_hours_by_id = await _fetch_dlc_leisure_hours(sem, session, dlc_ids)
|
||||
@ -237,7 +234,7 @@ async def _fetch_dlc_leisure_hours(
|
||||
return {}
|
||||
|
||||
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] = {}
|
||||
for dlc_id, dlc_data in zip(dlc_ids, dlc_details, strict=False):
|
||||
|
||||
@ -22,6 +22,7 @@ from steam_backlog_enforcer._hltb_search_api import (
|
||||
_get_auth_info,
|
||||
_get_hltb_search_url,
|
||||
_pick_best_hltb_entry,
|
||||
forget_hltb_search_url,
|
||||
)
|
||||
from steam_backlog_enforcer._hltb_types import (
|
||||
_SAVE_INTERVAL,
|
||||
@ -168,6 +169,7 @@ async def _search_batch(
|
||||
auth = await _get_auth_info(search_url, init_session)
|
||||
if auth is None:
|
||||
logger.warning("Could not get HLTB auth info, aborting fetch.")
|
||||
forget_hltb_search_url()
|
||||
return []
|
||||
logger.info("HLTB auth token acquired.")
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Scrapes the homepage for JS bundles containing the fetch URL, walking
|
||||
@ -60,7 +68,24 @@ def _get_hltb_search_url() -> str:
|
||||
return url
|
||||
except OSError, RuntimeError, ValueError, TypeError:
|
||||
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(
|
||||
|
||||
131
steam_backlog_enforcer/_own_pick.py
Normal file
131
steam_backlog_enforcer/_own_pick.py
Normal 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
|
||||
@ -15,7 +15,7 @@ from typing import Any
|
||||
|
||||
from steam_backlog_enforcer._snapshot import load_snapshot
|
||||
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__)
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
def _save_owned_app_ids_cache(steam_id: str, app_ids: list[int]) -> None:
|
||||
"""Persist owned app IDs cache for this steam_id."""
|
||||
payload = {
|
||||
def _owned_record(game: dict[str, Any]) -> dict[str, Any]:
|
||||
"""The part of a ``GetOwnedGames`` entry the web library shows."""
|
||||
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,
|
||||
"fetched_at": time.time(),
|
||||
"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")
|
||||
|
||||
|
||||
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
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@ -92,7 +156,7 @@ def get_all_owned_app_ids(config: Config) -> list[int]:
|
||||
client = SteamAPIClient(config.steam_api_key, config.steam_id)
|
||||
owned = client.get_owned_games()
|
||||
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] = []
|
||||
seen: set[int] = set()
|
||||
|
||||
125
steam_backlog_enforcer/_progress.py
Normal file
125
steam_backlog_enforcer/_progress.py
Normal 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)))
|
||||
168
steam_backlog_enforcer/_prompter.py
Normal file
168
steam_backlog_enforcer/_prompter.py
Normal 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)
|
||||
@ -15,6 +15,12 @@ from steam_backlog_enforcer._assignment_progress import (
|
||||
last_assigned_epoch,
|
||||
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 (
|
||||
_pick_next_shortest_candidate,
|
||||
_sort_key,
|
||||
@ -45,24 +51,28 @@ _NO_CONF_MSG = (
|
||||
)
|
||||
|
||||
|
||||
def _prompt_user_pick(qualified: list[GameInfo]) -> int:
|
||||
"""Present numbered list, return 0-based index of user's choice."""
|
||||
for i, g in enumerate(qualified, 1):
|
||||
def _prompt_user_pick(
|
||||
qualified: list[GameInfo], games: list[GameInfo], state: State
|
||||
) -> 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 = (
|
||||
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:
|
||||
raw = input("Select game number: ")
|
||||
try:
|
||||
idx = int(raw)
|
||||
except ValueError:
|
||||
_echo(f"Invalid input: {raw!r}")
|
||||
continue
|
||||
if idx < 1 or idx > len(qualified):
|
||||
_echo(f"Out of range: {idx}")
|
||||
continue
|
||||
return idx - 1
|
||||
picked = current_prompter().choice("Select game number", options)
|
||||
if picked != OWN_PICK_VALUE:
|
||||
return qualified[int(picked)]
|
||||
own = prompt_own_pick(games, state)
|
||||
if own is not None:
|
||||
return own
|
||||
|
||||
|
||||
def _assign_chosen_game(
|
||||
@ -145,13 +155,14 @@ def _pick_next_game_sequential(
|
||||
games: list[GameInfo],
|
||||
state: State,
|
||||
config: Config,
|
||||
on_select: Callable[[GameInfo], bool],
|
||||
on_select: Callable[[GameInfo], bool | GameInfo],
|
||||
) -> None:
|
||||
"""Pick the next-shortest playable game, asking the user per candidate.
|
||||
|
||||
``on_select`` is called with each prospective pick. Returning ``True``
|
||||
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:
|
||||
candidates = _open_candidates(games, state)
|
||||
@ -168,11 +179,14 @@ def _pick_next_game_sequential(
|
||||
)
|
||||
return
|
||||
|
||||
if not on_select(chosen):
|
||||
verdict = on_select(chosen)
|
||||
if verdict is False:
|
||||
state.skip_for_days(chosen.app_id, 7)
|
||||
state.save()
|
||||
_echo(f"\n Skipped {chosen.name} for 7 days; picking next...")
|
||||
continue
|
||||
|
||||
_assign_chosen_game(chosen, games, state, config)
|
||||
_assign_chosen_game(
|
||||
chosen if verdict is True else verdict, games, state, config
|
||||
)
|
||||
return
|
||||
|
||||
@ -6,23 +6,34 @@ the 250-line cap. Leaf helpers: nothing here calls back into ``scanning``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from contextlib import closing
|
||||
from itertools import islice
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from steam_backlog_enforcer._allowed_games import allowed_games
|
||||
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._steam_models import MAX_WORKERS
|
||||
from steam_backlog_enforcer._whitelist_locking import get_approved_exception_ids
|
||||
from steam_backlog_enforcer.enforcer import send_notification
|
||||
from steam_backlog_enforcer.game_install import _echo
|
||||
from steam_backlog_enforcer.steam_api import SteamAPIClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_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]:
|
||||
@ -51,6 +62,51 @@ def _legitimately_played(state: State) -> set[int]:
|
||||
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(
|
||||
client: SteamAPIClient,
|
||||
entry: dict[str, Any],
|
||||
@ -66,30 +122,39 @@ def _check_game_tampering(
|
||||
Returns:
|
||||
Tuple of (name, app_id, diff) if tampering detected, else None.
|
||||
"""
|
||||
app_id = entry["app_id"]
|
||||
if app_id in _legitimately_played(state):
|
||||
if not _needs_refetch(entry, _legitimately_played(state)):
|
||||
return None
|
||||
if entry["unlocked_achievements"] >= entry["total_achievements"]:
|
||||
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)
|
||||
)
|
||||
game = _refetch(client, entry)
|
||||
if game is None:
|
||||
return None
|
||||
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
|
||||
return _unexpected_unlocks(entry, game, state)
|
||||
|
||||
|
||||
def _refetch_in_order(
|
||||
client: SteamAPIClient, entries: list[dict[str, Any]]
|
||||
) -> Generator[tuple[dict[str, Any], GameInfo | None]]:
|
||||
"""Yield ``(entry, fresh game)`` in *entries* order, fetching ahead.
|
||||
|
||||
A sliding window keeps :data:`_TAMPER_WINDOW` requests in flight (the
|
||||
client's own rate limiter still caps requests per second), so the caller
|
||||
can stop early without having asked Steam about the whole library. Closing
|
||||
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:
|
||||
@ -99,15 +164,22 @@ def detect_tampering(config: Config, state: State) -> None:
|
||||
return
|
||||
|
||||
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]] = []
|
||||
for entry in old_snapshot:
|
||||
result = _check_game_tampering(client, entry, state)
|
||||
if result:
|
||||
suspicious.append(result)
|
||||
if len(suspicious) >= _TAMPER_CHECK_LIMIT:
|
||||
break
|
||||
progress = current_progress()
|
||||
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:
|
||||
suspicious.append(result)
|
||||
if len(suspicious) >= _TAMPER_CHECK_LIMIT:
|
||||
progress.advance(step=len(to_check))
|
||||
break
|
||||
|
||||
if suspicious:
|
||||
_echo("\n TAMPERING DETECTED:")
|
||||
|
||||
@ -13,6 +13,12 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
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 (
|
||||
MAX_WORKERS,
|
||||
STEAM_API_BASE,
|
||||
@ -52,6 +58,8 @@ class SteamAPIClient:
|
||||
self._rate_lock = threading.Lock()
|
||||
self._request_times: list[float] = []
|
||||
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:
|
||||
"""Enforce rate limit across threads."""
|
||||
@ -95,8 +103,13 @@ class SteamAPIClient:
|
||||
logger.info("Found %d owned games.", len(games))
|
||||
return games
|
||||
|
||||
def get_achievement_details(self, app_id: int) -> list[AchievementInfo]:
|
||||
"""Fetch per-achievement detail for a game."""
|
||||
def get_achievement_details(
|
||||
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/"
|
||||
try:
|
||||
data = self._get(
|
||||
@ -108,7 +121,9 @@ class SteamAPIClient:
|
||||
"format": "json",
|
||||
},
|
||||
)
|
||||
except SteamAPIError:
|
||||
except SteamAPIError as exc:
|
||||
if strict and not is_no_stats_answer(exc):
|
||||
raise
|
||||
return []
|
||||
|
||||
stats = data.get("playerstats", {})
|
||||
@ -130,8 +145,9 @@ class SteamAPIClient:
|
||||
"""Fetch achievement data for one game. Thread-safe."""
|
||||
app_id = game_dict["appid"]
|
||||
|
||||
achievements = self.get_achievement_details(app_id)
|
||||
achievements = self.get_achievement_details(app_id, strict=True)
|
||||
if not achievements:
|
||||
self._no_stats.add(app_id) # set.add is atomic under the GIL
|
||||
return None
|
||||
|
||||
name = game_dict.get("name", f"Unknown ({app_id})")
|
||||
@ -151,34 +167,68 @@ class SteamAPIClient:
|
||||
self,
|
||||
progress_callback: Callable[[int, int], None] | None = None,
|
||||
) -> 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()
|
||||
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] = []
|
||||
done_count = 0
|
||||
total = len(owned)
|
||||
lock = threading.Lock()
|
||||
progress = current_progress()
|
||||
progress.phase("Scanning Steam achievements", total)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
||||
futures = {pool.submit(self._fetch_one_game, g): g for g in owned}
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
result = future.result()
|
||||
except (
|
||||
KeyError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
SteamAPIError,
|
||||
requests.RequestException,
|
||||
):
|
||||
result = None
|
||||
with lock:
|
||||
done_count += 1
|
||||
if progress_callback:
|
||||
progress_callback(done_count, total)
|
||||
if result is not None:
|
||||
games.append(result)
|
||||
|
||||
games.sort(key=lambda g: g.name.lower())
|
||||
try:
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
result = future.result()
|
||||
except (
|
||||
KeyError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
SteamAPIError,
|
||||
requests.RequestException,
|
||||
):
|
||||
result = None
|
||||
with lock:
|
||||
done_count += 1
|
||||
progress.advance(
|
||||
str(futures[future].get("name", "")), step=done_count
|
||||
)
|
||||
if progress_callback:
|
||||
progress_callback(done_count, total)
|
||||
if result is not None:
|
||||
games.append(result)
|
||||
except BaseException:
|
||||
# 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
|
||||
|
||||
def refresh_single_game(
|
||||
|
||||
139
steam_backlog_enforcer/_web_art.py
Normal file
139
steam_backlog_enforcer/_web_art.py
Normal 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)
|
||||
129
steam_backlog_enforcer/_web_auth.py
Normal file
129
steam_backlog_enforcer/_web_auth.py
Normal 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() :]
|
||||
153
steam_backlog_enforcer/_web_daemon.py
Normal file
153
steam_backlog_enforcer/_web_daemon.py
Normal 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))
|
||||
105
steam_backlog_enforcer/_web_errors.py
Normal file
105
steam_backlog_enforcer/_web_errors.py
Normal 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")
|
||||
163
steam_backlog_enforcer/_web_io.py
Normal file
163
steam_backlog_enforcer/_web_io.py
Normal 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()},
|
||||
)
|
||||
151
steam_backlog_enforcer/_web_jobs.py
Normal file
151
steam_backlog_enforcer/_web_jobs.py
Normal 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))
|
||||
169
steam_backlog_enforcer/_web_library.py
Normal file
169
steam_backlog_enforcer/_web_library.py
Normal 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())})
|
||||
141
steam_backlog_enforcer/_web_privileged.py
Normal file
141
steam_backlog_enforcer/_web_privileged.py
Normal 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))
|
||||
116
steam_backlog_enforcer/_web_process.py
Normal file
116
steam_backlog_enforcer/_web_process.py
Normal 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)
|
||||
93
steam_backlog_enforcer/_web_routes.py
Normal file
93
steam_backlog_enforcer/_web_routes.py
Normal 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
|
||||
@ -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
|
||||
bundle (``web/dist``) as static files. Binds to localhost only and never
|
||||
exposes secrets: the payload comes from :func:`build_web_dataset`, which reads
|
||||
the data caches but never ``config.json``.
|
||||
A thin router. Per request it, in order: checks ``Host`` (every route — the
|
||||
DNS-rebinding guard, :mod:`._web_auth`), fails closed on stale code
|
||||
(:mod:`._serve_stale`), checks ``Origin`` and the per-launch token on
|
||||
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
|
||||
``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.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import parse_qs, 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._web_dataset import build_web_dataset, dataset_to_payload
|
||||
from steam_backlog_enforcer.config import State
|
||||
from steam_backlog_enforcer._web_errors import ApiError, not_found
|
||||
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
|
||||
|
||||
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_PORT = 8000
|
||||
_API_DATASET = "/api/dataset"
|
||||
_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"}
|
||||
)
|
||||
_INDEX = "index.html"
|
||||
_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 = (
|
||||
b"This server is running outdated code and has stopped answering rather "
|
||||
b"than report numbers the enforcer is not applying. Restart it: "
|
||||
b"./run.sh serve"
|
||||
"This server is running outdated code and has stopped answering rather "
|
||||
"than report numbers the enforcer is not applying. It restarts on "
|
||||
"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):
|
||||
"""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:
|
||||
"""Route the default request log to ``logging`` at debug level."""
|
||||
logger.debug("%s - %s", self.address_string(), fmt % args)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
"""Dispatch a GET to one of the APIs or to a static file."""
|
||||
stale = outdated_source(_STARTED_AT)
|
||||
if stale is not None:
|
||||
# Fail closed on every route, static included: a page served from a
|
||||
# fresh bundle that then fetches numbers from stale code is the
|
||||
# same lie with extra steps.
|
||||
self._send(HTTPStatus.SERVICE_UNAVAILABLE, _STALE_MSG, "text/plain")
|
||||
self._retire()
|
||||
return
|
||||
"""Handle a GET."""
|
||||
self._handle("GET")
|
||||
|
||||
def do_POST(self) -> None:
|
||||
"""Handle a POST."""
|
||||
self._handle("POST")
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
"""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)
|
||||
if split.path == _API_DATASET:
|
||||
self._serve_dataset()
|
||||
elif split.path == _API_BUDGET:
|
||||
# ``?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.
|
||||
self._serve_budget(demo="demo=1" in split.query)
|
||||
else:
|
||||
self._serve_static(split.path)
|
||||
try:
|
||||
host = _web_auth.check_host(self.headers, self.server.port)
|
||||
if self._refused_as_stale(split.path):
|
||||
return
|
||||
if method != "GET":
|
||||
_web_auth.check_origin(self.headers, host)
|
||||
token = self.headers.get(_web_auth.AUTH_HEADER)
|
||||
_web_auth.check_token(token, self.server.token)
|
||||
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:
|
||||
"""Answer this request, then stop the server so it can be replaced.
|
||||
def _dispatch(self, method: str, path: str, query: dict[str, str]) -> None:
|
||||
"""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
|
||||
honest and useless. Exiting hands the problem to the supervisor, and
|
||||
``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.
|
||||
def _refused_as_stale(self, path: str) -> bool:
|
||||
"""Fail closed when our own source changed since this process started.
|
||||
|
||||
``shutdown`` blocks until ``serve_forever`` returns and cannot be
|
||||
called from the thread handling a request, so it goes to its own.
|
||||
Every route refuses, static included: a page served from a fresh
|
||||
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():
|
||||
return
|
||||
_RETIRING.set()
|
||||
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
||||
|
||||
def _serve_budget(self, *, demo: bool) -> None:
|
||||
"""Build and send the gaming-budget snapshot as JSON."""
|
||||
try:
|
||||
body = json.dumps(build_budget_snapshot(demo=demo)).encode("utf-8")
|
||||
except OSError, ValueError, KeyError:
|
||||
logger.exception("Failed to build budget snapshot")
|
||||
self._send(HTTPStatus.INTERNAL_SERVER_ERROR, b"budget error", "text/plain")
|
||||
return
|
||||
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")
|
||||
if outdated_source(_web_process.STARTED_AT) is None:
|
||||
return False
|
||||
if path == "/api/server":
|
||||
send_reply(self, _web_process.health_view(_no_request(path, self)))
|
||||
elif path.startswith("/api/"):
|
||||
send_error(self, ApiError(_STALE_MSG, code="server_stale"))
|
||||
else:
|
||||
body = _STALE_MSG.encode()
|
||||
send_bytes(
|
||||
self, HTTPStatus.SERVICE_UNAVAILABLE, body, "text/plain; charset=utf-8"
|
||||
)
|
||||
_web_process.retire()
|
||||
return True
|
||||
|
||||
def _serve_static(self, path: str) -> None:
|
||||
"""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()
|
||||
# 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():
|
||||
candidate = WEB_DIST / "index.html"
|
||||
candidate = WEB_DIST / _INDEX
|
||||
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
|
||||
ctype, _ = mimetypes.guess_type(candidate.name)
|
||||
self._send(HTTPStatus.OK, candidate.read_bytes(), ctype or "text/plain")
|
||||
|
||||
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 = ctype or "text/plain"
|
||||
if ctype.startswith("text/") or ctype in _TEXT_TYPES:
|
||||
ctype = f"{ctype}; charset=utf-8"
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
body = candidate.read_bytes()
|
||||
headers: dict[str, str] = {}
|
||||
if candidate.name == _INDEX and candidate.parent == WEB_DIST:
|
||||
# Carries this launch's token: never cache it.
|
||||
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(
|
||||
host: str = DEFAULT_HOST, port: int = DEFAULT_PORT
|
||||
) -> ThreadingHTTPServer:
|
||||
# Content types that are text but not under the ``text/`` prefix.
|
||||
_TEXT_TYPES = frozenset({"application/javascript", "application/json", "image/svg+xml"})
|
||||
|
||||
|
||||
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."""
|
||||
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:
|
||||
"""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)
|
||||
token_file = _web_auth.write_token_file(server.token, server.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.")
|
||||
try:
|
||||
server.serve_forever()
|
||||
|
||||
91
steam_backlog_enforcer/_web_setup.py
Normal file
91
steam_backlog_enforcer/_web_setup.py
Normal 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)))
|
||||
114
steam_backlog_enforcer/_web_sse.py
Normal file
114
steam_backlog_enforcer/_web_sse.py
Normal 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.
|
||||
131
steam_backlog_enforcer/_web_views.py
Normal file
131
steam_backlog_enforcer/_web_views.py
Normal 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"))
|
||||
@ -71,6 +71,11 @@ def _atomic_write(path: Path, data: str, *, mode: int | None = None) -> None:
|
||||
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]:
|
||||
"""The dataclass's own field names, for filtering a loaded JSON object."""
|
||||
return frozenset(f.name for f in fields(cls))
|
||||
|
||||
@ -14,6 +14,7 @@ import shutil
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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._steam_state import STEAMAPPS_PATH, _assert_not_real_steam
|
||||
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()
|
||||
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:
|
||||
logger.info("KEEPING allowed game: %s (AppID=%d)", name, app_id)
|
||||
continue
|
||||
|
||||
13
steam_backlog_enforcer/jobs/__init__.py
Normal file
13
steam_backlog_enforcer/jobs/__init__.py
Normal 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.
|
||||
"""
|
||||
27
steam_backlog_enforcer/jobs/__main__.py
Normal file
27
steam_backlog_enforcer/jobs/__main__.py
Normal 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())
|
||||
90
steam_backlog_enforcer/jobs/_control.py
Normal file
90
steam_backlog_enforcer/jobs/_control.py
Normal 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)
|
||||
88
steam_backlog_enforcer/jobs/_daemon_jobs.py
Normal file
88
steam_backlog_enforcer/jobs/_daemon_jobs.py
Normal 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
|
||||
48
steam_backlog_enforcer/jobs/_errors.py
Normal file
48
steam_backlog_enforcer/jobs/_errors.py
Normal 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."""
|
||||
117
steam_backlog_enforcer/jobs/_events.py
Normal file
117
steam_backlog_enforcer/jobs/_events.py
Normal 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
|
||||
143
steam_backlog_enforcer/jobs/_job_io.py
Normal file
143
steam_backlog_enforcer/jobs/_job_io.py
Normal 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)
|
||||
172
steam_backlog_enforcer/jobs/_job_prompter.py
Normal file
172
steam_backlog_enforcer/jobs/_job_prompter.py
Normal 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}")
|
||||
166
steam_backlog_enforcer/jobs/_registry.py
Normal file
166
steam_backlog_enforcer/jobs/_registry.py
Normal 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",
|
||||
)
|
||||
}
|
||||
215
steam_backlog_enforcer/jobs/_runner.py
Normal file
215
steam_backlog_enforcer/jobs/_runner.py
Normal 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
|
||||
147
steam_backlog_enforcer/jobs/_spawn.py
Normal file
147
steam_backlog_enforcer/jobs/_spawn.py
Normal 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
|
||||
73
steam_backlog_enforcer/jobs/_specs.py
Normal file
73
steam_backlog_enforcer/jobs/_specs.py
Normal 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
|
||||
199
steam_backlog_enforcer/jobs/_store.py
Normal file
199
steam_backlog_enforcer/jobs/_store.py
Normal 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
|
||||
149
steam_backlog_enforcer/jobs/_view.py
Normal file
149
steam_backlog_enforcer/jobs/_view.py
Normal 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)
|
||||
@ -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._echo import _echo
|
||||
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 (
|
||||
SteamUnavailableError,
|
||||
)
|
||||
@ -177,10 +178,15 @@ def try_hide_other_games(
|
||||
pass ran; otherwise it explains why hiding was skipped and the count
|
||||
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:
|
||||
return hide_other_games(owned_app_ids, allowed_app_ids), None
|
||||
except SteamUnavailableError as exc:
|
||||
return 0, str(exc)
|
||||
finally:
|
||||
progress.advance(step=1)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
# 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()
|
||||
|
||||
json.dumps(sorted(owned_app_ids))
|
||||
@ -203,6 +212,7 @@ def unhide_all_games(owned_app_ids: list[int]) -> int:
|
||||
"""
|
||||
|
||||
result = _evaluate_js(js)
|
||||
progress.advance(step=1)
|
||||
value = _cdp_result_value(result)
|
||||
parsed = json.loads(value)
|
||||
count: int = parsed["count"]
|
||||
|
||||
@ -23,9 +23,14 @@ from steam_backlog_enforcer._cmd_playtime import (
|
||||
cmd_gaming_status,
|
||||
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.config import Config, State
|
||||
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 (
|
||||
_print_usage as _print_usage_for,
|
||||
@ -45,6 +50,7 @@ from steam_backlog_enforcer.main._shared import (
|
||||
_show_total_block_lock_message,
|
||||
)
|
||||
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 (
|
||||
cmd_hide,
|
||||
cmd_install,
|
||||
@ -53,7 +59,6 @@ from steam_backlog_enforcer.main.install import (
|
||||
cmd_uninstall,
|
||||
)
|
||||
from steam_backlog_enforcer.main.misc import (
|
||||
cmd_add_exception,
|
||||
cmd_block_gaming,
|
||||
cmd_buy_dlc,
|
||||
cmd_reset,
|
||||
@ -117,41 +122,34 @@ __all__ = [
|
||||
"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]]] = {
|
||||
"scan": ("Scan library & assign a game", do_scan),
|
||||
"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),
|
||||
name: (desc, _HANDLERS[name]) for name, desc in COMMAND_DESCRIPTIONS.items()
|
||||
}
|
||||
|
||||
# Extra commands with non-standard arg handling (shown in help but not in COMMANDS).
|
||||
_EXTRA_COMMAND_DESCRIPTIONS: 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_COMMANDS: dict[str, str] = _make_all_commands(
|
||||
COMMANDS, _EXTRA_COMMAND_DESCRIPTIONS
|
||||
)
|
||||
# Plus the commands with non-standard arg handling (help only, not COMMANDS).
|
||||
_ALL_COMMANDS: dict[str, str] = _make_all_commands(COMMANDS, EXTRA_COMMAND_DESCRIPTIONS)
|
||||
|
||||
|
||||
def _resolve_command(raw: str) -> str | None:
|
||||
@ -215,19 +213,8 @@ def main() -> None:
|
||||
_echo(f"Note: treating '{sys.argv[1]}' as '{command}'.")
|
||||
|
||||
config = Config.load()
|
||||
|
||||
if command not in {"setup", "add-exception"} and not config.steam_api_key:
|
||||
_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)
|
||||
# Not-configured, total-block and manual-pick checks; web jobs share it.
|
||||
state = enforce_gate(command, config)
|
||||
|
||||
if _dispatch_extra_command(command, config, state):
|
||||
return
|
||||
|
||||
51
steam_backlog_enforcer/main/_gate.py
Normal file
51
steam_backlog_enforcer/main/_gate.py
Normal 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
|
||||
@ -18,6 +18,10 @@ from steam_backlog_enforcer._actions import (
|
||||
from steam_backlog_enforcer._allowed_games import (
|
||||
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._total_block import (
|
||||
TotalBlockStatus,
|
||||
@ -32,42 +36,6 @@ if TYPE_CHECKING:
|
||||
_LIST_DISPLAY_LIMIT = 50
|
||||
_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
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@ -13,6 +13,7 @@ from steam_backlog_enforcer._actions import (
|
||||
find_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 (
|
||||
_echo,
|
||||
is_game_installed,
|
||||
@ -98,8 +99,9 @@ def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None:
|
||||
else:
|
||||
_echo("\n - Leaves you with no assigned game (run 'scan' to get one)")
|
||||
_echo()
|
||||
confirm = input(f"Type YES to abandon {game_name}: ").strip()
|
||||
if confirm != "YES":
|
||||
if not confirm_phrase(
|
||||
"abandon-pick", f"Abandon manual pick {game_name}?", game_name=game_name
|
||||
):
|
||||
_echo("Aborted.")
|
||||
return
|
||||
|
||||
|
||||
79
steam_backlog_enforcer/main/exception.py
Normal file
79
steam_backlog_enforcer/main/exception.py
Normal 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)
|
||||
@ -6,6 +6,8 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games
|
||||
from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
|
||||
from steam_backlog_enforcer._progress import current_progress
|
||||
from steam_backlog_enforcer._prompter import confirm_phrase
|
||||
from steam_backlog_enforcer._steam_state import is_game_fully_installed
|
||||
from steam_backlog_enforcer.game_install import (
|
||||
_echo,
|
||||
@ -60,8 +62,9 @@ def cmd_uninstall(_config: Config, state: State) -> None:
|
||||
_echo(f" - {name} (AppID={aid})")
|
||||
|
||||
_echo()
|
||||
confirm = input("Type YES to confirm: ").strip()
|
||||
if confirm != "YES":
|
||||
if not confirm_phrase(
|
||||
"uninstall", f"Uninstall {len(to_remove)} games?", count=len(to_remove)
|
||||
):
|
||||
_echo("Aborted.")
|
||||
return
|
||||
|
||||
@ -80,6 +83,9 @@ def cmd_install(config: Config, state: State) -> None:
|
||||
return
|
||||
|
||||
_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(
|
||||
state.current_app_id,
|
||||
state.current_game_name,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""Commands that do not belong to a larger group.
|
||||
|
||||
Store blocking, state reset, setup, whitelist exceptions, the web UI and the
|
||||
total gaming block.
|
||||
Store blocking, state reset, setup, the web UI and the total gaming block.
|
||||
``add-exception`` lives in :mod:`steam_backlog_enforcer.main.exception`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -10,8 +10,10 @@ import errno
|
||||
import sys
|
||||
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._enforce_loop import get_all_owned_app_ids
|
||||
from steam_backlog_enforcer._prompter import confirm_phrase
|
||||
from steam_backlog_enforcer._serve_startup import (
|
||||
ensure_port_available,
|
||||
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._web_build import build_frontend, frontend_is_stale
|
||||
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.library_hider import unhide_all_games
|
||||
from steam_backlog_enforcer.store_blocker import unblock_store
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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 = (
|
||||
"Usage: block-gaming <days>\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:
|
||||
"""Reset all state (unblock, unhide, clear assignment)."""
|
||||
unblock_store()
|
||||
"""Reset all state (unhide, clear assignment), backed up first.
|
||||
|
||||
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.
|
||||
try:
|
||||
@ -108,7 +110,7 @@ def cmd_reset(config: Config, state: State) -> None:
|
||||
state.last_assigned_at = {}
|
||||
state.released_at = {}
|
||||
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:
|
||||
@ -144,49 +146,6 @@ def cmd_serve(args: list[str]) -> None:
|
||||
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:
|
||||
"""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."
|
||||
)
|
||||
_echo()
|
||||
confirm = input(f"Type YES to confirm a {days}-day total gaming block: ").strip()
|
||||
if confirm != "YES":
|
||||
if not confirm_phrase(
|
||||
"block-gaming", f"Block all gaming for {days} day(s)?", days=days
|
||||
):
|
||||
_echo("Aborted.")
|
||||
return
|
||||
|
||||
|
||||
@ -20,6 +20,8 @@ from steam_backlog_enforcer._pick_completion import (
|
||||
report_completion,
|
||||
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._steam_state import is_game_fully_installed
|
||||
from steam_backlog_enforcer.game_install import (
|
||||
@ -129,7 +131,8 @@ def _apply_allowed_set(config: Config, state: State) -> None:
|
||||
if count:
|
||||
_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):
|
||||
_echo(f" {name} is already installed.")
|
||||
continue
|
||||
@ -149,7 +152,7 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None:
|
||||
state: Current enforcer state.
|
||||
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:
|
||||
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)"
|
||||
)
|
||||
_echo()
|
||||
confirm = input(
|
||||
f"Type YES to confirm you will play {game_name} until you earn a new"
|
||||
" achievement: "
|
||||
).strip()
|
||||
if confirm != "YES":
|
||||
if not confirm_phrase(
|
||||
"pick-manual",
|
||||
f"Confirm you will play {game_name} until you earn a new achievement.",
|
||||
game_name=game_name,
|
||||
):
|
||||
_echo("Aborted.")
|
||||
warn_stale_assignment(state, retired)
|
||||
return
|
||||
|
||||
@ -17,6 +17,7 @@ from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from steam_backlog_enforcer._progress import gather_tracked
|
||||
from steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -155,7 +156,7 @@ async def _fetch_batch(app_ids: list[int]) -> list[ProtonDBRating]:
|
||||
sem = asyncio.Semaphore(MAX_CONCURRENT)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
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]
|
||||
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ from steam_backlog_enforcer._hltb_types import (
|
||||
load_hltb_polls_cache,
|
||||
)
|
||||
from steam_backlog_enforcer._pick_completion import report_completion
|
||||
from steam_backlog_enforcer._progress import current_progress
|
||||
from steam_backlog_enforcer._scanning_assign import (
|
||||
_NO_CONF_MSG,
|
||||
_assign_chosen_game,
|
||||
@ -151,7 +152,7 @@ def pick_next_game(
|
||||
state: State,
|
||||
config: Config,
|
||||
*,
|
||||
on_select: Callable[[GameInfo], bool] | None = None,
|
||||
on_select: Callable[[GameInfo], bool | GameInfo] | None = None,
|
||||
) -> None:
|
||||
"""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))
|
||||
return
|
||||
|
||||
idx = _prompt_user_pick(qualified)
|
||||
_assign_chosen_game(qualified[idx], games, state, config)
|
||||
chosen = _prompt_user_pick(qualified, games, state)
|
||||
_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)
|
||||
_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)
|
||||
progress.advance(state.current_game_name)
|
||||
if game is None:
|
||||
_echo(" Could not fetch achievement data.")
|
||||
return
|
||||
|
||||
64
steam_backlog_enforcer/tests/_ctl_fixtures.py
Normal file
64
steam_backlog_enforcer/tests/_ctl_fixtures.py
Normal 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
|
||||
78
steam_backlog_enforcer/tests/_fake_prompter.py
Normal file
78
steam_backlog_enforcer/tests/_fake_prompter.py
Normal 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)
|
||||
89
steam_backlog_enforcer/tests/_isolate_control_plane.py
Normal file
89
steam_backlog_enforcer/tests/_isolate_control_plane.py
Normal 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()
|
||||
53
steam_backlog_enforcer/tests/_jobs_helpers.py
Normal file
53
steam_backlog_enforcer/tests/_jobs_helpers.py
Normal 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")
|
||||
28
steam_backlog_enforcer/tests/_web_request.py
Normal file
28
steam_backlog_enforcer/tests/_web_request.py
Normal 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
|
||||
@ -9,8 +9,7 @@ to temporary directories. This stops tests from accidentally:
|
||||
- Modifying /etc/hosts via the store blocker
|
||||
- Corrupting the HLTB cache on disk
|
||||
- Launching real Steam or calling real subprocess commands
|
||||
- Deleting real ~/.steam, ~/.local/share/Steam, etc. via the total-block
|
||||
Steam/Proton remnant purge
|
||||
- Deleting real ~/.steam, ~/.local/share/Steam via the remnant purge
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -20,7 +19,12 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
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_playtime import _isolate_playtime
|
||||
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.
|
||||
__all__ = [
|
||||
"_block_real_subprocesses",
|
||||
"_forget_hltb_search_url",
|
||||
"_isolate_control_plane",
|
||||
"_isolate_cover_art",
|
||||
"_isolate_incidents",
|
||||
"_isolate_playtime",
|
||||
"_isolate_reading",
|
||||
@ -185,16 +192,15 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]:
|
||||
def _no_real_sleep() -> Iterator[None]:
|
||||
"""No-op every ``time.sleep`` used by the package.
|
||||
|
||||
Several modules call ``time.sleep`` for Steam-launch / install-retry /
|
||||
rate-limit pacing. Individual tests that need to observe sleep
|
||||
behaviour can override these patches inside their own ``with`` block.
|
||||
Modules call ``time.sleep`` to pace Steam launch / install retries /
|
||||
rate limits; tests that observe sleep override these patches.
|
||||
"""
|
||||
noop = MagicMock()
|
||||
with (
|
||||
patch("steam_backlog_enforcer._steam_client.time.sleep", noop),
|
||||
patch("steam_backlog_enforcer._steam_launch.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),
|
||||
):
|
||||
yield
|
||||
|
||||
133
steam_backlog_enforcer/tests/test_achievement_skip.py
Normal file
133
steam_backlog_enforcer/tests/test_achievement_skip.py
Normal 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
Loading…
Reference in New Issue
Block a user