"""The client agent: one loop that enforces, warns, reports and never gives up. Wiring, in one place: * the cached config is loaded **before** the broker link is attempted, so the device is enforcing the moment the process starts, broker or no broker (FR-14); * the MQTT link subscribes to the retained config and publishes presence; * every few seconds the enforcer terminates blocked processes while the device is effectively locked (FR-3, FR-4); * the overlay shows the child what is blocked and until when (FR-9); * every minute the usage sample is rolled up and published (FR-7), and the status report keeps the dashboard's presence fresh (FR-6). Nothing here changes the rules. The client obeys and reports (AGENTS.md ยง10 rule 7). """ from __future__ import annotations import json import logging import signal import threading import time from dataclasses import dataclass, field from datetime import datetime, timezone from .config import ConfigCache, DeviceConfig from .mqtt_link import MqttLink, iso_now from .overlay import Notice, Overlay from .process_watch import EnforcementReport, Enforcer, foreground_app, visible_apps from .settings import Settings from .telemetry import TelemetryStore, UsageSampler, build_payload, day_key log = logging.getLogger("webrestrict") def format_until(config: DeviceConfig) -> str: """A short local rendering of the config's end time, for the log and overlay.""" for value in (config.grant_until, config.until): if not value: continue from .config import parse_time # noqa: PLC0415 - small helper, local use moment = parse_time(value) return moment.astimezone().strftime("%a %H:%M") if moment else value return "" @dataclass class AgentState: """The moving parts, so tests can drive them without a broker or a desktop.""" settings: Settings cache: ConfigCache config: DeviceConfig telemetry: TelemetryStore sampler: UsageSampler overlay: Overlay enforcer: Enforcer link: MqttLink | None = None last_report: EnforcementReport | None = None started_at: float = field(default_factory=time.monotonic) class Agent: """The long-running kids' client.""" def __init__(self, settings: Settings, logger: logging.Logger | None = None): self.settings = settings self.log = logger or log self.stop_event = threading.Event() telemetry = TelemetryStore(settings.telemetry_path()) self.state = AgentState( settings=settings, cache=ConfigCache(settings.config_path()), config=DeviceConfig(), telemetry=telemetry, sampler=UsageSampler(telemetry), overlay=Overlay(enabled=settings.overlay), enforcer=Enforcer(settings.protected, self.log), ) # -- setup ------------------------------------------------------------- def load_config(self) -> DeviceConfig: """Load the cached config, seeding a permissive first-run config. The seed is **unlocked with no lists**: a client that has never heard from the broker must never invent a lock. The first retained config replaces it. """ cached = self.state.cache.load() if cached is None: cached = DeviceConfig(platform="windows", locked=False) self.state.cache.save(cached) self.log.info("no cached config yet; seeded an unlocked config (FR-14)") self.state.config = cached self.log.info( "startup config locked=%s until=%s block=%d allow=%d cached_at=%s", cached.locked, cached.until, len(cached.block), len(cached.allow), cached.generated_at, ) return cached # -- callbacks --------------------------------------------------------- def apply_config(self, payload: str) -> None: """Handle a config message: store it if valid, then apply it at once.""" accepted = self.state.cache.accept(payload) if accepted is None: self.log.warning("config ignored (older or unreadable); keeping the cached one") return self.state.config = accepted self.log.info( "config applied locked=%s until=%s reason=%s block=%s allow=%s generated_at=%s", accepted.locked, accepted.until, accepted.reason or "-", accepted.block, accepted.allow, accepted.generated_at, ) # Act on it now instead of waiting for the next tick: a lock must land # within the poll interval, and the Android client behaves the same way. self.tick() def on_connected(self) -> None: config = self.state.config self.log.info("link up: publishing presence and today's usage") self.publish_status(config) self.flush_telemetry() def on_lost(self, reason: str) -> None: self.log.warning( "broker link lost (%s): enforcing the cached config (FR-14) block=%s until=%s", reason, self.state.config.block, self.state.config.until, ) # -- one tick ---------------------------------------------------------- def tick(self) -> EnforcementReport: config = self.state.config now = datetime.now(timezone.utc) blocking = config.is_blocking(now) report = self.state.enforcer.enforce(config, now) self.state.last_report = report self.state.overlay.update(self.notice(config, report, now) if blocking else None) return report def notice(self, config: DeviceConfig, report: EnforcementReport, now: datetime) -> Notice | None: if not report.blocked_names: return None return Notice( reason=config.reason_text or "screen-time rules are active", until=format_until(config), names=report.blocked_names, ) # -- reporting --------------------------------------------------------- def publish_status(self, config: DeviceConfig | None = None, app: str | None = None) -> None: link = self.state.link if link is None: return current = config or self.state.config link.publish_status(current.is_blocking(), app if app is not None else foreground_app()) def flush_telemetry(self) -> None: link = self.state.link if link is None: return day = day_key() apps = self.state.telemetry.day(day) if apps and link.publish_telemetry(day, apps): self.log.info("telemetry republished day=%s apps=%d", day, len(apps)) def sample_usage(self) -> None: """One usage sample: visible-window apps get the elapsed seconds.""" apps = visible_apps() totals = self.state.sampler.sample(apps) if not totals: return day = day_key() self.log.debug("usage sample day=%s apps=%d", day, len(totals)) link = self.state.link if link is not None and link.publish_telemetry(day, totals): self.log.info( "telemetry day=%s apps=%d top=%s", day, len(totals), json.dumps(build_payload(day, totals)["apps"][:3]), ) # -- the loop ---------------------------------------------------------- def run(self) -> int: self.load_config() self.state.overlay.start() link = MqttLink( self.settings, on_config=self.apply_config, on_connected=self.on_connected, on_lost=self.on_lost, ) self.state.link = link try: link.start() except Exception as exc: # noqa: BLE001 - run without a link, keep the rules self.log.error("mqtt could not start (%s); enforcing the cached config only", exc) self.state.started_at = time.monotonic() next_telemetry = time.monotonic() + self.settings.telemetry_seconds next_status = time.monotonic() + self.settings.status_seconds self.log.info( "client running: poll=%ss telemetry=%ss status=%ss device=%s state_dir=%s", self.settings.poll_seconds, self.settings.telemetry_seconds, self.settings.status_seconds, self.settings.device_token, self.settings.state_path, ) while not self.stop_event.is_set(): try: report = self.tick() if report.killed: self.publish_status(self.state.config) except Exception as exc: # noqa: BLE001 - one bad tick must not stop the client self.log.exception("enforcement tick failed: %s", exc) now = time.monotonic() if now >= next_telemetry: next_telemetry = now + self.settings.telemetry_seconds try: self.sample_usage() except Exception as exc: # noqa: BLE001 self.log.warning("usage sample failed: %s", exc) if now >= next_status: next_status = now + self.settings.status_seconds try: self.publish_status(self.state.config) self.log.info( "status published locked=%s blocking=%s broker=%s cached_config=%s", self.state.config.locked, self.state.config.is_blocking(), "connected" if (self.state.link and self.state.link.connected) else "unreachable", bool(self.state.config.generated_at), ) except Exception as exc: # noqa: BLE001 self.log.warning("status publish failed: %s", exc) self.stop_event.wait(self.settings.poll_seconds) self.shutdown() return 0 def shutdown(self) -> None: self.log.info("client stopping: publishing offline status") link = self.state.link if link is not None: try: link.publish_offline() time.sleep(0.2) # let the retained offline message leave link.stop() except Exception as exc: # noqa: BLE001 self.log.debug("shutdown: %s", exc) self.state.overlay.stop() def request_stop(self, *_args) -> None: self.stop_event.set() def install_signal_handlers(agent: Agent) -> None: """Stop cleanly on Ctrl-C and on a Scheduled Task 'End task'.""" for name in ("SIGINT", "SIGTERM"): handler = getattr(signal, name, None) if handler is None: continue try: signal.signal(handler, agent.request_stop) except (ValueError, OSError): # pragma: no cover - not the main thread pass def check_once(settings: Settings) -> dict[str, object]: """A one-shot report for the operator and for the phase proof. Prints the cached config, the effective block decision, and the processes it would terminate right now. It changes nothing. """ cache = ConfigCache(settings.config_path()) config = cache.load() or DeviceConfig(platform="windows", locked=False) enforcer = Enforcer(settings.protected) from .blocker import select_victims # noqa: PLC0415 - only needed here from .process_watch import snapshot_processes now = datetime.now(timezone.utc) victims = select_victims( snapshot_processes(), config, now=now, protected_names=enforcer.protected_names, protected_pids=enforcer.protected_pids, ) return { "device_topic": settings.device_topic, "state_dir": str(settings.state_path), "checked_at": iso_now(), "config": config.to_dict(), "blocking": config.is_blocking(now), "blocked_names": sorted(config.blocked_names()), "victims": [{"pid": v.pid, "name": v.name, "rule": v.matched} for v in victims], }