"""Enforcement daemon loop and related helpers.""" from __future__ import annotations import json import logging 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, ) from steam_backlog_enforcer._file_wake import open_file_wake # Re-exported: five modules and the test suite import get_all_owned_app_ids # from here, so splitting it into _owned_apps_cache stays invisible to them. from steam_backlog_enforcer._owned_apps_cache import get_all_owned_app_ids from steam_backlog_enforcer._pick_completion import ( retire_completed_manual_picks_throttled, ) from steam_backlog_enforcer._playtime import playtime_tick from steam_backlog_enforcer._playtime_session import PlaytimeSession, new_session from steam_backlog_enforcer._steam_launch import steam_is_installed from steam_backlog_enforcer._store_window import store_window_tick from steam_backlog_enforcer._total_block import ( end_total_block_cleanup, enforce_total_block_tick, is_total_block_active, total_block_needs_cleanup, ) from steam_backlog_enforcer._whitelist_locking import lock_enforcement_files from steam_backlog_enforcer._workout_budget import workout_log_path from steam_backlog_enforcer.config import ( CONFIG_FILE, Config, State, ) from steam_backlog_enforcer.enforcer import ( enforce_allowed_game, send_notification, ) logger = logging.getLogger(__name__) # Seconds between enforce passes. ENFORCE_INTERVAL = 3 __all__ = [ "ENFORCE_INTERVAL", "do_enforce", "get_all_owned_app_ids", ] 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: The daemon's state object, updated in place. Returns: False (after logging) if the file could not be read; *state* is then left as it was. """ 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( config: Config, state: State, *, session: PlaytimeSession, demo: bool = False, ) -> None: """Perform one iteration of the enforcement loop. Args: config: Enforcer configuration. state: Current enforcer state. session: Cross-tick logging state. demo: Run the gaming budget on a 60-second demo budget. """ # Daily gaming budget runs FIRST and unconditionally. Every guard below # returns early in situations where gaming time is still being spent (total # block active, Steam absent, nothing assigned) and where a 06:00 release # still has to happen — gating this on any of them would strand the block. playtime_tick(config, interval=ENFORCE_INTERVAL, session=session, demo=demo) # Total block takes priority over the assigned-game enforcement below - # while active, don't fight ourselves (e.g. installing the assigned # game while total-block tries to keep Steam uninstalled). if is_total_block_active(): enforce_total_block_tick() return if total_block_needs_cleanup(): end_total_block_cleanup() # Before the Steam-installed guard: an expired window must re-block even # when Steam is gone. store_window_tick(config, state) # With no Steam client there is no library, no installs and no game # processes, so every branch below is a no-op at best - and at worst a # 3s-interval error loop trying to write manifests into a steamapps # directory that a total block deleted. The total-block tick above still # runs: keeping Steam uninstalled is exactly what it is for. if not steam_is_installed(): return # Release manual picks that earned a new achievement, at most once every # MANUAL_PICK_RECHECK_TTL_SECONDS - this loop ticks every 3s. retire_completed_manual_picks_throttled(config, state) # Releasing never evicts: a released pick stays in allowed_app_ids until # the user explicitly chooses another game, so this is safe mid-session. allowed = allowed_app_ids(state) if not allowed: return # A) Kill unauthorized game processes. if config.kill_unauthorized_games: violations = enforce_allowed_game(allowed, kill_unauthorized=True) for pid, app_id in violations: _echo(f" Killed unauthorized game: AppID={app_id} (PID={pid})") send_notification( "Game Blocked!", f"Killed unauthorized game (AppID={app_id}). " f"Focus on {allowed_names(state)}!", ) # B) Remove any newly-installed unauthorized games. if config.uninstall_other_games: removed = guard_installed_games(allowed) if removed > 0: _echo(f" Guard removed {removed} unauthorized game(s)") # C) Re-install any allowed game that was somehow removed. _reinstall_missing_allowed(config, state) # D) Re-apply immutable flag so config cannot be edited without root. lock_enforcement_files(CONFIG_FILE) def do_enforce(config: Config, state: State, *, demo: bool = False) -> None: """Run the enforcer: block store, uninstall other games, kill processes. This is a persistent loop that continuously: 1. Keeps the Steam store blocked. 2. Removes any newly-installed unauthorized games. 3. Auto-installs the assigned game if missing. 4. Kills any running unauthorized game processes. 5. Accounts for the daily gaming budget and enforces its cutoff. Args: config: Enforcer configuration. state: Current enforcer state. demo: Run the gaming budget on a 60-second demo budget. """ if is_total_block_active(): _echo( "Total gaming block ACTIVE - enforcing that instead of any assigned game." ) elif state.current_app_id is None: # Fall through to the idle loop for the same reason as the Steam # branch below, plus one of its own: the daily gaming budget is # enforced from this loop, so returning here would silently stop # counting playtime whenever a game is finished but not yet rescanned. _echo("No game assigned. Run 'scan' first.") _echo(" (Daily gaming budget is still enforced.)") elif not steam_is_installed(): # Fall through to the idle loop rather than returning: returning exits # the process, and under Restart=always that is just the crash loop # again by another name. Staying alive also means a later Steam # reinstall is picked up without needing a restart. _echo("Steam is not installed — nothing to enforce.") _echo(" (Reinstall Steam to resume backlog enforcement.)") else: _echo(f"Enforcing: {state.current_game_name} (AppID={state.current_app_id})") _enforce_setup(config, state) _echo(f" Enforce loop: ACTIVE (every {ENFORCE_INTERVAL}s)") _echo(" Guarding: processes + installs + store") _echo(" Press Ctrl+C to stop.\n") # One session for the whole daemon: the audit journal needs to see the # previous tick's record, so it cannot be rebuilt per iteration. 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: 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()