Commit Graph

12 Commits

Author SHA1 Message Date
0c05d71008 Add a daily gaming-time budget with a binary-level cutoff
Accumulates time spent in Steam games and other launchers, and once the
daily budget (default 8h) is spent, shuts Steam down, kills the games, and
makes them unlaunchable until the next 06:00.

The gaming day starts at 06:00 local rather than midnight so a late session
counts against the day it began in; rollover is checked lazily against a
stored day key, so it survives suspend, reboot and daemon restarts with no
timer of its own. Elapsed time comes from wall-clock deltas clamped to two
tick intervals, which keeps suspends, missed ticks and NTP steps honest.

The block is a read-only bind mount of a refusal stub over all four launcher
entry points -- /usr/bin/steam is only a two-line exec wrapper, so masking it
alone leaves bin_steam.sh, Steam's own steam.sh and lutris live. A bind mount
mutates no bytes, umount restores exactly, and a reboot clears it
unconditionally; chmod/rename would leave Steam broken if the daemon died
mid-block. /proc/self/mountinfo is the authority on what is masked, never the
saved state, which is what makes a stale block release even if the state file
was deleted. The mount is deferred one tick past the cutoff so
`steam -shutdown` still works and games close cleanly.

Launchers are matched by cmdline, not comm: /usr/bin/lutris is a python3
script, so the kernel records its comm as "python3" and the existing scan
never sees it. The kill set is wider than the budget predicate, walking down
from each launcher, because a Lutris-launched Wine game carries no SteamAppId.

State is chattr +i'd -- ~/.config is user-writable, so root ownership alone
would not stop `rm` from resetting the counter -- and a missing state file
while mounts are live fails closed rather than lifting the block.

Also fixes a pre-existing bug: do_enforce returned when no game was assigned,
exiting the process. Since the budget is accounted for from inside that loop,
it silently stopped enforcing whenever a game was finished but not rescanned.

Verified on the desktop with a real game: AoE2 counted 18 processes, tripped
at the 60s demo budget, all processes killed, all four paths masked and
refusing, then gaming-unblock restored them byte-identical with
`pacman -Qkk steam lutris` reporting 0 altered files.

1322 tests pass at 100.00% branch coverage; pacman validated the ALPM hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbEaXapjMSkxyzgvbdkUFb
2026-08-04 20:00:08 +02:00
05e070ca27 feat(enforcer): allow two concurrent manual picks
The enforcer allowed exactly one game: `current_app_id` was the single
"allowed" id threaded through the uninstall guard, the process killer, the
library hider and the auto-installer. Picking a second game manually meant
the first was uninstalled and hidden.

Manual picks are now a set, capped by `Config.max_manual_picks` (default 2).
Every active pick stays installed, visible and un-killed; the lock releases
only when all of them are finished or past their own 14-day deadline.

- `State.manual_picks` holds `{app_id, game_name, started_at}` entries. The
  legacy single-slot `manual_pick_*` fields are still read on load, migrated
  into the list and cleared, so a live lock survives the upgrade.
- `_actions.allowed_app_ids` / `allowed_games` are the single source of truth
  for "may exist". `uninstall_other_games`, `hide_other_games` (incl. its
  injected JS), `enforce_allowed_game` and `_guard_installed_games` now take
  that set instead of one app id; every call site passes it.
- `pick-manual` joins `_MANUAL_LOCK_EXEMPT_COMMANDS` so a second game can be
  added while the first holds the lock — the cap is what limits it, not the
  lock check. Its post-pick cascade operates on the whole allowed set, so
  adding a pick never tears down an earlier one.
- `abandon-pick <app_id>` drops only that pick; a survivor inherits the
  assignment and keeps the lock. Grace and deadline are per pick.
- `cmd_done` is unchanged: it finishes `current_app_id` and auto-picks a
  replacement, so two games stay queued. A finished pick leaves the active
  set automatically via `finished_app_ids`.

Deployment note, learned the hard way during development: the daemon holds
the allowed set in code, so it MUST be restarted after upgrading. A
pre-upgrade daemon knows only `current_app_id` and uninstalled the other pick
as "unauthorized" within seconds of the second pick being made.

Verified live: legacy KCD2 lock migrated on load; a second pick (Vampire
Survivors) was accepted while the first was locked, with both kept by the
uninstall pass; a third was refused at the cap; abandoning one pick left the
other locked and handed it the assignment; the restarted daemon reports
"KEEPING allowed game" for both. 1060 tests pass at 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:28:23 +02:00
eb1472a433 feat(enforcer): add 4-day grace period to undo a manual pick
A manual pick had no escape hatch: once locked in, every command except
done/check/status/enforce/setup/serve exited 1 for 14 days or until 100%
achievements. A wrong pick meant two weeks stuck with it.

Adds a short mistake-correction window:

- MANUAL_GRACE_DAYS = 4 — within 4 days, 'abandon-pick <app_id>' backs out
  of the pick. Outside it the command refuses and exits 1.
- The app_id must be passed explicitly and must match the active pick, so
  an abandon cannot be triggered by muscle memory.
- 'abandon-pick' joins _MANUAL_LOCK_EXEMPT_COMMANDS — the pre-dispatch
  lock check would otherwise block the only way out. It is dropped from
  the advertised command list once the window closes.
- Abandoning clears the lock and the assignment, uninstalls the game, and
  reuses the existing skipped_until cooldown for ABANDON_COOLDOWN_DAYS=30
  so 'scan' does not hand it straight back.
- _actions.abandon_manual_pick is state-only (no uninstall cascade),
  matching apply_manual_pick, so the gated MCP abandon_pick tool reuses it.

Also fixes a crash surfaced while picking a replacement game: the
SteamUnavailableError family (including SteamUpdateInProgressError, raised
when a game update defers a Steam restart) was only caught in the enforce
loop. cmd_hide, cmd_pick, cmd_pick_manual and both cmd_done reconciliation
points called hide_other_games bare, so an undrivable Steam aborted the
command with a traceback — after the pick had already been saved. All six
sites now go through the new stdout-free try_hide_other_games wrapper and
report "Library hiding: skipped (<reason>)" instead.

Verified live: lock message advertises the window, expired/wrong-id/no-arg
paths exit 1, and abandoning released the AoE2 lock, uninstalled it and
recorded the 30-day cooldown. 1030 tests pass at 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:04:08 +02:00
d54df3a1ad fix(enforcer): idle when Steam is absent instead of crash-looping
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
A total gaming block on 2026-07-04 purged the Steam client (packages and
all data dirs, per total_block_purge_log.json) and it was never
reinstalled. The enforcer kept trying to drive it anyway: every start
called ensure_steam_debug_port() -> _launch_steam_with_debug(), waited
45s for a CDP port no process would ever open, raised, and exited 1 into
Restart=always. That ran to 1050+ restarts.

Each attempt also left a zombie behind: _run_as_user() launches
fire-and-forget via Popen and never reaps, so a launch that dies on exec
lingers as a dead process still named "steam". The focus-mode daemon
scans /proc for process names and read that zombie as a live Steam,
entering GAMING mode and killing the user's browsers every few seconds.

- steam_is_installed(): probe the real /usr/bin/steam. Deliberately not
  shutil.which("steam") - a launcher wrapper on $PATH keeps `which`
  truthy long after the package is gone, which is precisely how a
  deleted Steam went on looking installed.
- SteamUnavailableError (a RuntimeError, so existing handlers still
  catch it) raised on both "not installed" and CDP timeout, and caught
  at the _enforce_hide_games boundary: no library to hide is not fatal,
  the store block and install guard still work.
- Guard _enforce_loop_iteration and game_install._ensure_steam_running,
  whose 15s sleep was the source of the observed flapping interval.
- do_enforce falls through to the idle loop rather than returning:
  returning exits the process, which under Restart=always is the same
  crash loop by another name.
- _reap_spawned() retires exited launches so no zombie named "steam"
  outlives them.

Verified live: NRestarts 1050 -> 0, service active/running for 60s+ with
zero errors and zero steam zombies, logging "Steam is not installed -
nothing to enforce."

Also removed the orphaned /usr/local/bin/steam wrapper, a leftover of
the purge that pointed at the deleted binary. Its body was:
  #!/bin/sh
  exec /usr/bin/steam -cef-disable-gpu -cef-disable-gpu-compositing "$@"
Restore those flags if a Steam reinstall brings back GPU trouble.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rN2usTS3pYLJqeVg2ZpgR
2026-07-17 13:53:24 +02:00
de76c6541e Remove whitelist exception cooldown; approve immediately
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Whitelist exceptions previously entered a 24h pending queue and were
promoted to approved by the enforce loop once the cooldown elapsed.
add_pending_exception now approves immediately, so the entire pending/
promote/cooldown mechanism is dead: drop WHITELIST_COOLDOWN_SECONDS,
PENDING_EXCEPTIONS_FILE, _load_pending/_save_pending,
promote_pending_exceptions, and list_pending_exceptions, plus the
enforce-loop promotion step and the CLI's now-empty pending display.
Usage text and docstrings updated to state exceptions are active
immediately. Tests for the removed machinery deleted; add-exception
tests assert immediate approval. Coverage remains 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
2026-07-10 22:57:39 +02:00
7554b58ab7 feat: add block-gaming command (Stage 4) + guard-lib migration cleanup
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Adds `block-gaming <days>`: uninstalls Steam, kills/uninstalls known game
launchers, and blocks Steam + game-website domains (hosts + iptables) for a
fixed number of days with no in-app way to lift it early. Enforcement is
tamper-resistant via guard-lib's package-block (bind-mounted lock file) and
re-asserted every enforce tick.

Also migrates store_blocker.py's hosts-file locking from raw chattr/mount
calls to guard-lib's file-guard, using the new `sync` subcommand (not
`pacman-relock`) so our own legitimate edits aren't reverted as drift.

Fixes found during live verification:
- iptables never blocked real IPs because DNS was resolved after /etc/hosts
  already redirected every blocked domain to 0.0.0.0 locally - reordered so
  iptables resolves first.
- Game-website blocks only covered bare apex domains; sites that
  301-redirect to www (e.g. newgrounds.com) sailed right through - added
  automatic www. variant generation.
- Launchers (e.g. prismlauncher) were only killed, never uninstalled -
  added best-effort pacman-package removal keyed off /proc/<pid>/exe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFNiYQQgSLAkiBXswyimPq
2026-07-04 11:45:54 +02:00
551b8a4f95 chore: set up as standalone repo
Extracted from testsAndMisc monorepo. Changes:
- Rewrote imports from python_pkg.steam_backlog_enforcer.* → steam_backlog_enforcer.*
- Moved run.sh, install.sh, README.md, service file to repo root
- Added standalone pyproject.toml, requirements.txt, .pre-commit-config.yaml, .gitignore
- Added GitHub Actions CI workflows (tests + pre-commit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 07:21:29 +02:00
d0d1037e1b feat(steam_backlog_enforcer): harden whitelist against circumvention
- Remove skip_app_ids from user-editable Config; callers updated
- Split PROTECTED_APP_IDS: only Steam infra/Proton IDs remain; game
  IDs moved to a new time-locked exception system
- Add _whitelist.py: 24-hour cooldown on new exceptions, entropy-
  checked justification (>= 5 words), append-only audit log,
  chattr +i immutability on enforcement-critical config files
- Add is_protected_app() in game_install.py; used everywhere
  instead of direct PROTECTED_APP_IDS membership checks
- Add 'add-exception' CLI command (cmd_add_exception in main.py)
- Call promote_pending_exceptions() and lock_enforcement_files()
  in each _enforce_loop_iteration
- 590 tests, 100% branch coverage on all steam_backlog_enforcer modules
- Add .worktrees to .gitignore
2026-05-17 20:44:05 +02:00
b9f31a159c steam_backlog_enforcer: reduce repeated cache refetches 2026-05-08 15:13:25 +02:00
14d706ee16 Apply focus-mode, screen-locker, and steam backlog updates 2026-05-03 22:41:53 +02:00
61096eded3 fix(steam_backlog_enforcer): prevent enforce daemon from deleting assigned game
- Guard enforce_allowed_game() and _guard_installed_games() against
  current_app_id=None so they never treat all games as unauthorized
- Add early return in _enforce_loop_iteration when no game is assigned
- Wrap State.load() in enforce loop with error handling for corrupt files
- Switch all config/cache file writes to atomic (tmpfile + rename)
- Add robust error handling to State.load() for corrupt JSON
- Update tests for new behavior and add coverage for atomic writes
2026-03-25 19:19:52 +01:00
e618cabf0e refactor: enforce 500-line limit on all Python source files
Split 18+ Python files that exceeded 500 lines into smaller modules
with helper files (prefixed with _). All functions are re-exported
from the original modules to maintain backward compatibility with
test patches and external imports.

Files split:
- moviepy_showcase.py (1212 -> 302 + 3 helpers)
- anki_generator.py (1174 -> 473 + 4 helpers)
- test_analyze_chess_game.py (1152 -> 361 + 2 parts)
- poker_modifier_app.py (1024 -> 263 + 2 helpers)
- transcribe_fw.py (1007 -> 342 + 3 helpers)
- music_generator.py (1002 -> 319 + 2 helpers)
- translator.py (951 -> 442 + 2 helpers)
- cinema_planner.py (893 -> 369 + 2 helpers)
- lichess_bot/main.py (757 -> 495 + _game_logic.py)
- test_translator.py (725 -> 289 + part2 + conftest)
- test_lichess_api.py (680 -> 475 + part2)
- learning_pipe.py (668 -> 375 + 2 helpers)
- cache.py (655 -> 360 + _cache_decks.py)
- analyze_chess_game.py (632 -> 463 + _move_analysis.py)
- visualize_q02.py (609 -> 371 + helper)
- repo_explorer.py (602 -> 347 + 2 helpers)
- keyboard_coop/main.py (515 -> 416 + _dictionary.py)
- scanning.py (501 -> 314 + _enforce_loop.py)

All tests pass: 144 lichess_bot (100% branch coverage), 243 others.
No new lint errors introduced.
2026-03-17 22:47:42 +01:00