Phase 4: kids' Windows computer client (AUD-97)
Adds windows/, the Python client for the kids' Windows computers, and proves both halves of the Phase 4 definition of done on the real box (DESKTOP-TE2RHV2). The client: - subscribes, retained, to its own config on the 8883 TLS device plane with its own broker account (FR-3, FR-6); - keeps the last valid config on disk and loads it before connecting, so a broker outage changes nothing (FR-14); - terminates every process on the blocklist while the device is effectively locked, with the allowlist winning and the client's own tree plus critical system processes protected (FR-4, FR-16); - publishes presence and daily per-app usage; the day's totals are held on disk and republished when the link returns (FR-7); - shows an always-on-top warning window naming what is blocked, why and until when (FR-9). install-scheduled-task.ps1 registers the logon Scheduled Task for the kid's account with an interactive token, and grants that account Modify on the state directory: a file created from an elevated shell inherits Administrators only, and the limited task is otherwise refused with Access denied. Evidence (windows/EVIDENCE.md, from the machine's own log): notepad.exe killed inside a scheduled window (pid 16600, then 9724), the device online in the Control Service with locked/until/reason, a telemetry row for the device (app=690s), enforcement continuing with the broker unreachable (192.168.20.13:8884), the warning window on the interactive desktop, and the Scheduled Task registered at logon as New Comp. Domains are deliberately not reported: Windows has no cheap per-process DNS attribution, so the client sends "domains": [] and the gap is documented rather than guessed. 54 unit tests, run with `make windows-test` (no broker, no Windows, no psutil). AGENTS.md §11 is now 10 of 10.
This commit is contained in:
12
windows/webrestrict/__init__.py
Normal file
12
windows/webrestrict/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Kids' Windows computer client for Internet Use Control (AGENTS.md §11 Phase 4).
|
||||
|
||||
The client is a device, not a control plane. It subscribes to its own retained
|
||||
config on the MQTT 8883 plane, blocks listed processes while the device is
|
||||
locked, reports presence and daily usage, warns the child, and keeps enforcing
|
||||
its last config when the broker is unreachable (FR-14). It never changes its own
|
||||
rules (AGENTS.md §10 rule 7).
|
||||
|
||||
Run it as ``python -m webrestrict`` (see ``windows/README.md``).
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0-phase4"
|
||||
96
windows/webrestrict/__main__.py
Normal file
96
windows/webrestrict/__main__.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Command line entry point: ``python -m webrestrict`` (AGENTS.md §11 Phase 4).
|
||||
|
||||
Usage::
|
||||
|
||||
python -m webrestrict run # the client (the Scheduled Task runs this)
|
||||
python -m webrestrict check # one-shot: cached config, effective state, victims
|
||||
python -m webrestrict version
|
||||
|
||||
``--config PATH`` overrides the config file (default
|
||||
``%PROGRAMDATA%\\WebRestrict\\client.json``, or ``WEBRESTRICT_CONFIG``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import sys
|
||||
|
||||
from . import __version__
|
||||
from .agent import Agent, check_once, install_signal_handlers
|
||||
from .settings import Settings, default_config_path
|
||||
|
||||
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="webrestrict", description="Kids' Windows computer client")
|
||||
parser.add_argument("--config", help="path to client.json (default: %(default)s)")
|
||||
parser.add_argument("--verbose", action="store_true", help="debug logging")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
sub.add_parser("run", help="run the client (default)")
|
||||
sub.add_parser("check", help="print the cached config and what would be blocked, then exit")
|
||||
sub.add_parser("version", help="print the client version")
|
||||
return parser
|
||||
|
||||
|
||||
def configure_logging(settings: Settings, verbose: bool) -> None:
|
||||
"""Log to the console and to a rotating file next to the state.
|
||||
|
||||
A Scheduled Task runs with no visible console, so the file is the record
|
||||
that survives: it holds the config that arrived and every process blocked.
|
||||
"""
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.DEBUG if verbose else logging.INFO)
|
||||
root.handlers.clear()
|
||||
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setFormatter(logging.Formatter(LOG_FORMAT))
|
||||
root.addHandler(console)
|
||||
|
||||
try:
|
||||
settings.state_path.mkdir(parents=True, exist_ok=True)
|
||||
rotating = logging.handlers.RotatingFileHandler(
|
||||
settings.log_path(), maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8"
|
||||
)
|
||||
rotating.setFormatter(logging.Formatter(LOG_FORMAT))
|
||||
root.addHandler(rotating)
|
||||
except OSError as exc: # pragma: no cover - depends on the machine
|
||||
root.warning("cannot write the log file %s: %s", settings.log_path(), exc)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
command = args.command or "run"
|
||||
|
||||
if command == "version":
|
||||
print(f"webrestrict-windows {__version__}")
|
||||
return 0
|
||||
|
||||
settings_path = args.config or str(default_config_path())
|
||||
try:
|
||||
settings = Settings.load(settings_path)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"webrestrict: cannot load {settings_path}: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
configure_logging(settings, args.verbose)
|
||||
|
||||
if command == "check":
|
||||
print(json.dumps(check_once(settings), indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
agent = Agent(settings)
|
||||
install_signal_handlers(agent)
|
||||
try:
|
||||
return agent.run()
|
||||
except KeyboardInterrupt: # pragma: no cover - Ctrl-C
|
||||
agent.shutdown()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
317
windows/webrestrict/agent.py
Normal file
317
windows/webrestrict/agent.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""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],
|
||||
}
|
||||
87
windows/webrestrict/blocker.py
Normal file
87
windows/webrestrict/blocker.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Pure block-selection logic (AGENTS.md FR-3, FR-4, FR-16).
|
||||
|
||||
No psutil and no Windows here, so the decision can be unit tested anywhere.
|
||||
:mod:`webrestrict.process_watch` supplies the running process list and does the
|
||||
killing; this module only decides **which** processes are blocked.
|
||||
|
||||
The rules are deliberately small, because the client must never widen or narrow
|
||||
them on its own (AGENTS.md §10 rule 7):
|
||||
|
||||
* nothing is blocked while the config is not effectively locked;
|
||||
* a process is blocked when its normalised name is on the blocklist;
|
||||
* the allowlist always wins (FR-4);
|
||||
* the client never kills itself, its parent chain, or a protected system
|
||||
process, whatever the blocklist says.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Iterable
|
||||
|
||||
from .config import DeviceConfig, bare
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Victim:
|
||||
"""One process that must be terminated."""
|
||||
|
||||
pid: int
|
||||
name: str # as reported by the OS, e.g. "notepad.exe"
|
||||
matched: str # the normalised blocklist entry that matched
|
||||
|
||||
|
||||
def select_victims(
|
||||
processes: Iterable[tuple[int, str]],
|
||||
config: DeviceConfig,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
protected_names: Iterable[str] = (),
|
||||
protected_pids: Iterable[int] = (),
|
||||
) -> list[Victim]:
|
||||
"""Choose the processes to terminate for one enforcement tick.
|
||||
|
||||
``processes`` is an iterable of ``(pid, name)`` pairs, so the caller can pass
|
||||
a psutil snapshot, a fake list in a test, or a single process. The result is
|
||||
ordered by pid and contains no duplicates, so the caller can log it.
|
||||
"""
|
||||
if not config.is_blocking(now):
|
||||
return []
|
||||
|
||||
blocked = config.blocked_names()
|
||||
if not blocked:
|
||||
return []
|
||||
|
||||
allowed = config.allowed_names()
|
||||
protected = {bare(item) for item in protected_names if item and str(item).strip()}
|
||||
do_not_touch = {int(pid) for pid in protected_pids}
|
||||
|
||||
victims: list[Victim] = []
|
||||
seen: set[int] = set()
|
||||
for pid, name in processes:
|
||||
try:
|
||||
pid = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
value = bare(name)
|
||||
if not value or pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
if pid in do_not_touch or value in protected or value in allowed:
|
||||
continue
|
||||
if value in blocked:
|
||||
victims.append(Victim(pid=pid, name=name, matched=value))
|
||||
victims.sort(key=lambda victim: (victim.matched, victim.pid))
|
||||
return victims
|
||||
|
||||
|
||||
def self_protection(pid: int, image_names: Iterable[str], parents: Iterable[int]) -> tuple[set[int], set[str]]:
|
||||
"""The pids and names the enforcer must never touch for its own process tree.
|
||||
|
||||
The client runs as ``python.exe`` on the kid's machine, so a blocklist that
|
||||
named Python would otherwise make the client kill itself and stop reporting.
|
||||
"""
|
||||
pids = {int(pid)} | {int(parent) for parent in parents}
|
||||
names = {str(name) for name in image_names if str(name).strip()}
|
||||
return pids, names
|
||||
268
windows/webrestrict/config.py
Normal file
268
windows/webrestrict/config.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""The device config and its on-disk cache (AGENTS.md FR-14).
|
||||
|
||||
``restrict/<device>/config`` carries the desired state, exactly as
|
||||
``internal/mqtt/payload.go`` publishes it. This module parses that payload and
|
||||
keeps the last valid copy on disk, so a device that cannot reach the broker
|
||||
still knows its rules. Only a valid, not-older config replaces the cache:
|
||||
a malformed or stale message never leaves the device without rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
# The schema version this client understands. A payload with a higher version is
|
||||
# rejected rather than guessed at; the cached config stays in force.
|
||||
CONFIG_VERSION = 1
|
||||
|
||||
|
||||
def parse_time(value: str | None) -> datetime | None:
|
||||
"""Parse an RFC 3339 timestamp as the Control Service writes it.
|
||||
|
||||
Accepts a trailing ``Z`` and an optional fractional part. Returns ``None``
|
||||
for an empty or unparseable value, so a bad timestamp downgrades to "no end
|
||||
time" instead of raising.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith("Z") or text.endswith("z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
moment = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
return moment
|
||||
|
||||
|
||||
def bare(name: str) -> str:
|
||||
"""Normalise a process or list entry for comparison.
|
||||
|
||||
``notepad.exe``, ``Notepad.EXE`` and ``notepad`` are the same app. The
|
||||
Windows process list reports ``.exe``; a blocklist entry may or may not.
|
||||
"""
|
||||
value = (name or "").strip().casefold()
|
||||
if value.endswith(".exe"):
|
||||
value = value[:-4]
|
||||
return value
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceConfig:
|
||||
"""The desired state for one device (TRANSPORT_DESIGN.md, Phase 3)."""
|
||||
|
||||
v: int = CONFIG_VERSION
|
||||
device_id: int = 0
|
||||
name: str = ""
|
||||
platform: str = "windows"
|
||||
locked: bool = False
|
||||
until: str | None = None
|
||||
allow: list[str] = field(default_factory=list)
|
||||
block: list[str] = field(default_factory=list)
|
||||
reason: str = ""
|
||||
grant_until: str | None = None
|
||||
generated_at: str | None = None
|
||||
|
||||
# -- parsing -----------------------------------------------------------
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, Any]) -> "DeviceConfig":
|
||||
"""Parse a payload, ignoring unknown fields (forward compatible).
|
||||
|
||||
Raises ``ValueError`` on a payload this client cannot honour, so the
|
||||
caller keeps the last good config.
|
||||
"""
|
||||
version = raw.get("v", CONFIG_VERSION)
|
||||
try:
|
||||
version = int(version)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"config v is not an integer: {version!r}") from exc
|
||||
if version > CONFIG_VERSION:
|
||||
raise ValueError(f"config v={version} is newer than this client ({CONFIG_VERSION})")
|
||||
|
||||
return cls(
|
||||
v=version,
|
||||
device_id=int(raw.get("device_id") or 0),
|
||||
name=str(raw.get("name") or ""),
|
||||
platform=str(raw.get("platform") or "windows"),
|
||||
locked=bool(raw.get("locked")),
|
||||
until=_str_or_none(raw.get("until")),
|
||||
allow=_string_list(raw.get("allow")),
|
||||
block=_string_list(raw.get("block")),
|
||||
reason=str(raw.get("reason") or ""),
|
||||
grant_until=_str_or_none(raw.get("grant_until")),
|
||||
generated_at=_str_or_none(raw.get("generated_at")),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, payload: str | bytes) -> "DeviceConfig":
|
||||
if isinstance(payload, (bytes, bytearray)):
|
||||
payload = payload.decode("utf-8", "replace")
|
||||
data = json.loads(payload)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("config payload is not a JSON object")
|
||||
return cls.from_dict(data)
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.to_dict(), sort_keys=True)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"v": self.v,
|
||||
"device_id": self.device_id,
|
||||
"name": self.name,
|
||||
"platform": self.platform,
|
||||
"locked": self.locked,
|
||||
"until": self.until,
|
||||
"allow": list(self.allow),
|
||||
"block": list(self.block),
|
||||
"reason": self.reason,
|
||||
"grant_until": self.grant_until,
|
||||
"generated_at": self.generated_at,
|
||||
}
|
||||
|
||||
# -- effective state --------------------------------------------------
|
||||
def until_at(self) -> datetime | None:
|
||||
return parse_time(self.until)
|
||||
|
||||
def grant_at(self) -> datetime | None:
|
||||
return parse_time(self.grant_until)
|
||||
|
||||
def is_blocking(self, now: datetime | None = None) -> bool:
|
||||
"""Whether the device must block right now.
|
||||
|
||||
This mirrors the Control Service's *effective* state
|
||||
(``internal/store/schedules.go`` ``EffectiveState``): the payload already
|
||||
carries the resolved ``locked`` flag, so the client only has to apply
|
||||
the clock. An expired ``until`` lifts the block locally — the same
|
||||
instant the Control Service would clear it — a live ``grant_until``
|
||||
never blocks, and an indefinite lock (``until`` absent) never lifts.
|
||||
"""
|
||||
if not self.locked:
|
||||
return False
|
||||
moment = now or datetime.now(timezone.utc)
|
||||
grant = self.grant_at()
|
||||
if grant is not None and grant > moment:
|
||||
return False
|
||||
until = self.until_at()
|
||||
if until is not None and moment >= until:
|
||||
return False
|
||||
return True
|
||||
|
||||
def blocked_names(self) -> set[str]:
|
||||
"""The blocklist as normalised names, minus the allowlist (FR-4)."""
|
||||
allowed = {bare(item) for item in self.allow}
|
||||
return {bare(item) for item in self.block if bare(item)} - allowed
|
||||
|
||||
def allowed_names(self) -> set[str]:
|
||||
return {bare(item) for item in self.allow if bare(item)}
|
||||
|
||||
def is_blocked_name(self, process_name: str) -> bool:
|
||||
"""Whether one process name is blocked, with the allowlist winning."""
|
||||
value = bare(process_name)
|
||||
if not value:
|
||||
return False
|
||||
if value in self.allowed_names():
|
||||
return False
|
||||
return value in self.blocked_names()
|
||||
|
||||
@property
|
||||
def reason_text(self) -> str:
|
||||
"""A short human reason for the overlay (FR-9)."""
|
||||
return {
|
||||
"schedule": "it is inside a scheduled screen-time window",
|
||||
"manual": "a parent locked this computer",
|
||||
"grant": "",
|
||||
}.get(self.reason, self.reason or "screen-time rules are active")
|
||||
|
||||
def generation_key(self) -> tuple[str, int]:
|
||||
"""A sortable key for "is this config newer?".
|
||||
|
||||
The Control Service republishes a config whenever the state changes, so
|
||||
``generated_at`` is normally enough. The fallback keeps a payload with a
|
||||
missing timestamp acceptable rather than dropping a real update.
|
||||
"""
|
||||
return (self.generated_at or "", self.device_id)
|
||||
|
||||
|
||||
def _str_or_none(value: Any) -> str | None:
|
||||
return None if value is None else str(value)
|
||||
|
||||
|
||||
def _string_list(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, Iterable):
|
||||
return [str(item) for item in value if str(item).strip()]
|
||||
return []
|
||||
|
||||
|
||||
class ConfigCache:
|
||||
"""The last valid config on disk (FR-14 "keep last state").
|
||||
|
||||
A device that cannot reach the broker keeps enforcing what it already has.
|
||||
The cache is written atomically, so a power cut cannot leave a half-written
|
||||
file, and a cache that cannot be read is treated as empty rather than fatal.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str | os.PathLike[str]):
|
||||
self.path = Path(path)
|
||||
self._current: DeviceConfig | None = None
|
||||
|
||||
@property
|
||||
def current(self) -> DeviceConfig | None:
|
||||
"""The in-memory config, loading from disk on first use."""
|
||||
if self._current is None:
|
||||
self._current = self._read()
|
||||
return self._current
|
||||
|
||||
def load(self) -> DeviceConfig | None:
|
||||
return self.current
|
||||
|
||||
def save(self, config: DeviceConfig) -> None:
|
||||
"""Persist a config and make it current."""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
with open(temp, "w", encoding="utf-8") as handle:
|
||||
handle.write(config.to_json())
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp, self.path)
|
||||
self._current = config
|
||||
|
||||
def accept(self, payload: str | bytes) -> DeviceConfig | None:
|
||||
"""Validate and store a payload.
|
||||
|
||||
Returns the stored config, or ``None`` when the payload is rejected (a
|
||||
malformed message, a newer schema, or an older ``generated_at`` than the
|
||||
cache). A rejection leaves the current config untouched.
|
||||
"""
|
||||
try:
|
||||
candidate = DeviceConfig.from_json(payload)
|
||||
except (ValueError, TypeError):
|
||||
# json.JSONDecodeError is a ValueError. A broker bug or a hostile
|
||||
# payload must never leave the device without rules (FR-14).
|
||||
return None
|
||||
existing = self.current
|
||||
if existing is not None and candidate.generation_key() < existing.generation_key():
|
||||
return None
|
||||
self.save(candidate)
|
||||
return candidate
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
def _read(self) -> DeviceConfig | None:
|
||||
try:
|
||||
with open(self.path, "r", encoding="utf-8") as handle:
|
||||
return DeviceConfig.from_json(handle.read())
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
241
windows/webrestrict/mqtt_link.py
Normal file
241
windows/webrestrict/mqtt_link.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""The MQTT link to the 8883 device plane (AGENTS.md §10 rule 6, FR-6).
|
||||
|
||||
The client is one device account on the TLS listener. Its ACL lets it read only
|
||||
``restrict/<token>/config`` and write only its own status and telemetry, so the
|
||||
broker itself stops a child publishing a fake "unlocked" (TRANSPORT_DESIGN.md
|
||||
"Security").
|
||||
|
||||
Presence is the broker's Last Will and Testament: the client publishes a
|
||||
retained ``{"online": true}`` when it connects and registers the will
|
||||
``{"online": false}`` on the same topic, so the Control Service learns the
|
||||
device is gone the moment the connection drops without a clean goodbye (FR-6).
|
||||
|
||||
A broker that is down is not fatal: the cached config stays in force (FR-14),
|
||||
paho retries on a backoff, and the retained config is delivered again on
|
||||
reconnect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Mapping
|
||||
|
||||
from .settings import Settings
|
||||
|
||||
log = logging.getLogger("webrestrict.mqtt")
|
||||
|
||||
QOS = 1
|
||||
KEEPALIVE_SECONDS = 30
|
||||
|
||||
|
||||
def iso_now(moment: datetime | None = None) -> str:
|
||||
"""RFC 3339 in UTC, as the Control Service writes timestamps."""
|
||||
current = moment or datetime.now(timezone.utc)
|
||||
if current.tzinfo is None:
|
||||
current = current.replace(tzinfo=timezone.utc)
|
||||
return current.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def status_payload(locked: bool, app: str = "", tamper: dict[str, str] | None = None) -> str:
|
||||
"""The status report the dashboard reads (FR-6, FR-9, FR-17)."""
|
||||
body: dict[str, object] = {"online": True, "locked": locked, "app": app, "at": iso_now()}
|
||||
if tamper:
|
||||
body["tamper"] = {**tamper, "at": iso_now()}
|
||||
return json.dumps(body, sort_keys=True)
|
||||
|
||||
|
||||
def telemetry_payload(day: str, apps: Mapping[str, int]) -> str:
|
||||
"""A day's per-app totals. Not retained: it is a report, not a rule."""
|
||||
return json.dumps(
|
||||
{
|
||||
"day": day,
|
||||
"apps": [
|
||||
{"name": name, "seconds": int(seconds)}
|
||||
for name, seconds in sorted(apps.items())
|
||||
if name and int(seconds) > 0
|
||||
],
|
||||
"domains": [],
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
class MqttLink:
|
||||
"""A reconnecting paho client wrapper with a small, testable surface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
on_config: Callable[[str], None],
|
||||
on_connected: Callable[[], None] | None = None,
|
||||
on_lost: Callable[[str], None] | None = None,
|
||||
):
|
||||
self.settings = settings
|
||||
self.on_config = on_config
|
||||
self.on_connected = on_connected or (lambda: None)
|
||||
self.on_lost = on_lost or (lambda reason: None)
|
||||
self.connected = False
|
||||
self.last_error = ""
|
||||
self._client = None
|
||||
self._stopped = False
|
||||
self._unreachable_logged = False
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
def start(self) -> None:
|
||||
"""Create the client and begin connecting in the background."""
|
||||
import paho.mqtt.client as mqtt # noqa: PLC0415 - keep the import local
|
||||
|
||||
client = self._new_client(mqtt)
|
||||
self._client = client
|
||||
client.on_connect = self._handle_connect
|
||||
client.on_connect_fail = self._handle_connect_fail
|
||||
client.on_disconnect = self._handle_disconnect
|
||||
client.on_message = self._handle_message
|
||||
|
||||
client.username_pw_set(self.settings.mqtt_username, self.settings.mqtt_password or None)
|
||||
if self.settings.ca_file:
|
||||
client.tls_set(
|
||||
ca_certs=self.settings.ca_file,
|
||||
cert_reqs=ssl.CERT_REQUIRED,
|
||||
tls_version=ssl.PROTOCOL_TLS_CLIENT,
|
||||
)
|
||||
else: # pragma: no cover - the lab always pins the CA
|
||||
client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS_CLIENT)
|
||||
|
||||
# Presence (FR-6): the broker publishes this the moment the link drops
|
||||
# without a clean DISCONNECT.
|
||||
client.will_set(
|
||||
self.settings.status_topic,
|
||||
json.dumps({"online": False}),
|
||||
qos=QOS,
|
||||
retain=True,
|
||||
)
|
||||
client.reconnect_delay_set(min_delay=1, max_delay=30)
|
||||
log.info(
|
||||
"mqtt connecting to %s:%s as %s (topic %s)",
|
||||
self.settings.broker_host,
|
||||
self.settings.broker_port,
|
||||
self.settings.mqtt_username,
|
||||
self.settings.device_topic,
|
||||
)
|
||||
client.connect_async(
|
||||
self.settings.broker_host,
|
||||
self.settings.broker_port,
|
||||
keepalive=KEEPALIVE_SECONDS,
|
||||
)
|
||||
client.loop_start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Disconnect cleanly, which suppresses the will."""
|
||||
self._stopped = True
|
||||
client = self._client
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.disconnect()
|
||||
client.loop_stop()
|
||||
except Exception as exc: # noqa: BLE001 - shutting down anyway
|
||||
log.debug("mqtt stop: %s", exc)
|
||||
|
||||
# -- publishing --------------------------------------------------------
|
||||
def publish_status(self, locked: bool, app: str = "", tamper: dict[str, str] | None = None) -> bool:
|
||||
return self._publish(self.settings.status_topic, status_payload(locked, app, tamper), retain=True)
|
||||
|
||||
def publish_telemetry(self, day: str, apps: Mapping[str, int]) -> bool:
|
||||
if not apps:
|
||||
return False
|
||||
return self._publish(self.settings.telemetry_topic, telemetry_payload(day, apps), retain=False)
|
||||
|
||||
def publish_offline(self) -> bool:
|
||||
"""Retained offline status, published on a clean shutdown (FR-6)."""
|
||||
return self._publish(self.settings.status_topic, json.dumps({"online": False}), retain=True)
|
||||
|
||||
def _publish(self, topic: str, payload: str, *, retain: bool) -> bool:
|
||||
client = self._client
|
||||
if client is None or not self.connected:
|
||||
return False
|
||||
try:
|
||||
info = client.publish(topic, payload, qos=QOS, retain=retain)
|
||||
if info.rc != 0:
|
||||
log.warning("publish to %s rejected (rc=%s)", topic, info.rc)
|
||||
return False
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001 - a lost link retries
|
||||
log.warning("publish to %s failed: %s", topic, exc)
|
||||
return False
|
||||
|
||||
# -- paho plumbing -----------------------------------------------------
|
||||
def _new_client(self, mqtt):
|
||||
"""paho 2.x takes an explicit callback API version; 1.x does not."""
|
||||
try:
|
||||
return mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=self.settings.effective_client_id,
|
||||
clean_session=True,
|
||||
protocol=mqtt.MQTTv311,
|
||||
)
|
||||
except TypeError: # pragma: no cover - paho 1.x
|
||||
return mqtt.Client(
|
||||
client_id=self.settings.effective_client_id,
|
||||
clean_session=True,
|
||||
protocol=mqtt.MQTTv311,
|
||||
)
|
||||
|
||||
def _handle_connect(self, client, _userdata, _flags, reason_code, _properties=None):
|
||||
code = getattr(reason_code, "value", reason_code)
|
||||
try:
|
||||
code = int(code)
|
||||
except (TypeError, ValueError):
|
||||
code = 1
|
||||
if code != 0:
|
||||
self.connected = False
|
||||
self.last_error = f"connect refused: {reason_code}"
|
||||
log.warning("mqtt connect refused: %s", reason_code)
|
||||
return
|
||||
self.connected = True
|
||||
self.last_error = ""
|
||||
self._unreachable_logged = False
|
||||
log.info("mqtt connected, subscribing to %s", self.settings.device_topic)
|
||||
client.subscribe(self.settings.device_topic, qos=QOS)
|
||||
# on_connected publishes the retained online status with the real
|
||||
# locked state, and flushes any telemetry held while offline.
|
||||
self.on_connected()
|
||||
|
||||
def _handle_connect_fail(self, *_args):
|
||||
"""The broker could not be reached at all (FR-14).
|
||||
|
||||
Logged once per outage so the record shows the client is running on its
|
||||
cached config rather than sitting idle, and not flooded by paho's retry.
|
||||
"""
|
||||
self.connected = False
|
||||
self.last_error = "broker unreachable"
|
||||
if not self._unreachable_logged:
|
||||
self._unreachable_logged = True
|
||||
log.warning(
|
||||
"broker %s:%s is unreachable; enforcing the cached config only (FR-14)",
|
||||
self.settings.broker_host,
|
||||
self.settings.broker_port,
|
||||
)
|
||||
self.on_lost("broker unreachable")
|
||||
|
||||
def _handle_disconnect(self, _client, _userdata, _flags, reason_code, _properties=None):
|
||||
self.connected = False
|
||||
message = str(reason_code)
|
||||
self.last_error = message
|
||||
if self._stopped:
|
||||
log.info("mqtt disconnected (client stopped): %s", message)
|
||||
return
|
||||
log.warning("mqtt connection lost: %s — keeping the last config (FR-14)", message)
|
||||
self.on_lost(message)
|
||||
|
||||
def _handle_message(self, _client, _userdata, message):
|
||||
if message.topic != self.settings.device_topic:
|
||||
return
|
||||
payload = message.payload.decode("utf-8", "replace")
|
||||
try:
|
||||
self.on_config(payload)
|
||||
except Exception as exc: # noqa: BLE001 - a bad config must not kill the link
|
||||
log.warning("config rejected, keeping the last one: %s", exc)
|
||||
147
windows/webrestrict/overlay.py
Normal file
147
windows/webrestrict/overlay.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""The child's warning window (AGENTS.md FR-9).
|
||||
|
||||
A small always-on-top Tkinter window: what is blocked, why, and until when. It
|
||||
is an *alert*, not a defence in depth — the child can close it, and on Windows
|
||||
the enforcement is the process block itself (FR-3) plus the presence and
|
||||
offline reports that reach Sam (FR-6, FR-13).
|
||||
|
||||
Tkinter wants one thread that owns the interpreter, so the window runs on its
|
||||
own daemon thread and the agent talks to it through a queue. If Tk is missing,
|
||||
or there is no interactive desktop (a service session, a headless test), the
|
||||
overlay is simply unavailable and blocking continues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence
|
||||
|
||||
log = logging.getLogger("webrestrict.overlay")
|
||||
|
||||
TITLE = "Screen time"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Notice:
|
||||
"""What the window shows while a block is active."""
|
||||
|
||||
reason: str
|
||||
until: str
|
||||
names: Sequence[str]
|
||||
|
||||
@property
|
||||
def blocked_text(self) -> str:
|
||||
if not self.names:
|
||||
return "This computer is blocked while the window is active."
|
||||
shown = ", ".join(self.names[:8])
|
||||
if len(self.names) > 8:
|
||||
shown += f", and {len(self.names) - 8} more"
|
||||
return f"Blocked now: {shown}"
|
||||
|
||||
|
||||
class Overlay:
|
||||
"""A queue-fed Tkinter window with a tiny, testable interface."""
|
||||
|
||||
def __init__(self, enabled: bool = True):
|
||||
self.enabled = enabled
|
||||
self.available = False
|
||||
self._queue: "queue.Queue[tuple[str, object]]" = queue.Queue()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._shown: Notice | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if not self.enabled:
|
||||
log.info("overlay disabled by configuration")
|
||||
return
|
||||
self._thread = threading.Thread(target=self._run, name="webrestrict-overlay", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def update(self, notice: Notice | None) -> None:
|
||||
"""Show the notice, hide on ``None``. Repeat calls with the same content are dropped."""
|
||||
if notice == self._shown:
|
||||
return
|
||||
self._shown = notice
|
||||
if notice is None:
|
||||
self._queue.put(("hide", None))
|
||||
else:
|
||||
self._queue.put(("show", notice))
|
||||
|
||||
def stop(self) -> None:
|
||||
self._queue.put(("stop", None))
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
def _run(self) -> None: # pragma: no cover - needs a desktop
|
||||
try:
|
||||
import tkinter as tk
|
||||
except ImportError as exc:
|
||||
log.warning("tkinter is unavailable, no warning window: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
root = tk.Tk()
|
||||
except Exception as exc: # noqa: BLE001 - no interactive desktop
|
||||
log.warning("the warning window is unavailable: %s", exc)
|
||||
return
|
||||
|
||||
root.withdraw()
|
||||
root.title(TITLE)
|
||||
root.attributes("-topmost", True)
|
||||
root.resizable(False, False)
|
||||
|
||||
frame = tk.Frame(root, padx=18, pady=16)
|
||||
frame.pack(fill="both", expand=True)
|
||||
heading = tk.Label(frame, text="Screen time is over", font=("Segoe UI", 14, "bold"), anchor="w")
|
||||
heading.pack(fill="x")
|
||||
reason_label = tk.Label(frame, text="", wraplength=380, justify="left", anchor="w")
|
||||
reason_label.pack(fill="x", pady=(8, 0))
|
||||
until_label = tk.Label(frame, text="", anchor="w", font=("Segoe UI", 9))
|
||||
until_label.pack(fill="x", pady=(6, 0))
|
||||
blocked_label = tk.Label(frame, text="", wraplength=380, justify="left", anchor="w")
|
||||
blocked_label.pack(fill="x", pady=(8, 0))
|
||||
tk.Button(frame, text="Close warning", command=root.withdraw).pack(anchor="e", pady=(12, 0))
|
||||
root.geometry("420x260")
|
||||
|
||||
self.available = True
|
||||
log.info("warning window is available")
|
||||
|
||||
def drain() -> None: # pragma: no cover - needs a desktop
|
||||
stop = False
|
||||
while True:
|
||||
try:
|
||||
action, payload = self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if action == "show" and isinstance(payload, Notice):
|
||||
reason_label.config(text=f"The rules say: {payload.reason}.")
|
||||
until_label.config(text=f"Until {payload.until}." if payload.until else "No end time is set.")
|
||||
blocked_label.config(text=payload.blocked_text)
|
||||
root.deiconify()
|
||||
root.lift()
|
||||
root.attributes("-topmost", True)
|
||||
place_bottom_right(root)
|
||||
elif action == "hide":
|
||||
root.withdraw()
|
||||
elif action == "stop":
|
||||
stop = True
|
||||
if stop:
|
||||
root.destroy()
|
||||
return
|
||||
root.after(200, drain)
|
||||
|
||||
root.after(200, drain)
|
||||
try:
|
||||
root.mainloop()
|
||||
except Exception as exc: # noqa: BLE001 - nothing left to do
|
||||
log.debug("warning window loop ended: %s", exc)
|
||||
|
||||
|
||||
def place_bottom_right(root) -> None: # pragma: no cover - needs a desktop
|
||||
"""Park the window bottom-right, out of the way of what it is covering."""
|
||||
root.update_idletasks()
|
||||
width = root.winfo_width() or 420
|
||||
height = root.winfo_height() or 260
|
||||
screen = root.winfo_screenwidth(), root.winfo_screenheight()
|
||||
root.geometry(f"{width}x{height}+{max(screen[0] - width - 24, 0)}+{max(screen[1] - height - 72, 0)}")
|
||||
263
windows/webrestrict/process_watch.py
Normal file
263
windows/webrestrict/process_watch.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""The Windows side of enforcement: process list, window list, foregound app.
|
||||
|
||||
Everything here touches the real machine, so it is kept apart from the pure
|
||||
decision in :mod:`webrestrict.blocker`. ``psutil`` is the only third-party
|
||||
dependency; the window queries go through ``ctypes`` and ``user32`` so the
|
||||
client needs no ``pywin32`` install.
|
||||
|
||||
Off Windows (unit tests, a lab box) every function degrades to an empty result
|
||||
instead of raising, so the client stays importable and testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Iterable
|
||||
|
||||
from .blocker import Victim, select_victims, self_protection
|
||||
from .config import DeviceConfig, bare
|
||||
|
||||
log = logging.getLogger("webrestrict.process")
|
||||
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
|
||||
# How long to give a polite terminate() before the hard kill(), in seconds.
|
||||
TERMINATE_GRACE_SECONDS = 3.0
|
||||
|
||||
|
||||
def _psutil():
|
||||
"""Import psutil on demand, with a readable error when it is missing."""
|
||||
try:
|
||||
import psutil # noqa: PLC0415 (deliberate: keeps the import cost local)
|
||||
except ImportError as exc: # pragma: no cover - depends on the machine
|
||||
raise RuntimeError(
|
||||
"psutil is not installed; run: python -m pip install -r windows/requirements.txt"
|
||||
) from exc
|
||||
return psutil
|
||||
|
||||
|
||||
def snapshot_processes() -> list[tuple[int, str]]:
|
||||
"""Every running process as ``(pid, name)``.
|
||||
|
||||
A process that exits while the list is built, or that denies access, is
|
||||
skipped: it is not a reason to fail an enforcement tick.
|
||||
"""
|
||||
psutil = _psutil()
|
||||
out: list[tuple[int, str]] = []
|
||||
for proc in psutil.process_iter(["pid", "name"]):
|
||||
try:
|
||||
info = proc.info
|
||||
name = info.get("name") or ""
|
||||
pid = int(info.get("pid") or 0)
|
||||
except Exception: # noqa: BLE001 - a dead process is normal
|
||||
continue
|
||||
if pid and name:
|
||||
out.append((pid, name))
|
||||
return out
|
||||
|
||||
|
||||
def kill_process(pid: int, name: str) -> str:
|
||||
"""Terminate one process. Returns ``"terminated"`` or ``"killed"``.
|
||||
|
||||
``terminate()`` asks the app to close, which is exactly what a scheduled
|
||||
window does to a game. If it is still alive after a short grace, ``kill()``
|
||||
ends it. Access denied is reported, not raised: the machine keeps running.
|
||||
"""
|
||||
psutil = _psutil()
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
except Exception as exc: # noqa: BLE001 - the process is already gone
|
||||
log.debug("process %s (%s) vanished: %s", pid, name, exc)
|
||||
return "gone"
|
||||
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception as exc: # noqa: BLE001 - e.g. AccessDenied for another user
|
||||
log.warning("terminate %s (%s) failed: %s", pid, name, exc)
|
||||
return "failed"
|
||||
|
||||
try:
|
||||
proc.wait(timeout=TERMINATE_GRACE_SECONDS)
|
||||
return "terminated"
|
||||
except Exception: # noqa: BLE001 - still running, escalate
|
||||
pass
|
||||
|
||||
try:
|
||||
proc.kill()
|
||||
proc.wait(timeout=TERMINATE_GRACE_SECONDS)
|
||||
return "killed"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("kill %s (%s) failed: %s", pid, name, exc)
|
||||
return "failed"
|
||||
|
||||
|
||||
# --- Windows window probes (ctypes; no pywin32) --------------------------
|
||||
|
||||
_WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) if IS_WINDOWS else None
|
||||
|
||||
|
||||
def _user32():
|
||||
return ctypes.windll.user32 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def window_pids() -> set[int]:
|
||||
"""Pids that own a visible, titled top-level window.
|
||||
|
||||
That is the closest cheap proxy Windows offers for "the child can see this
|
||||
app". A background app with a hidden window does not count.
|
||||
"""
|
||||
if not IS_WINDOWS: # pragma: no cover - platform dependent
|
||||
return set()
|
||||
|
||||
user32 = _user32()
|
||||
pids: set[int] = set()
|
||||
|
||||
def callback(hwnd, _lparam): # pragma: no cover - platform dependent
|
||||
try:
|
||||
if not user32.IsWindowVisible(hwnd):
|
||||
return True
|
||||
if user32.GetWindowTextLengthW(hwnd) == 0:
|
||||
return True
|
||||
pid = ctypes.c_ulong(0)
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
if pid.value:
|
||||
pids.add(int(pid.value))
|
||||
except Exception: # noqa: BLE001 - never let a callback break the enum
|
||||
return True
|
||||
return True
|
||||
|
||||
try:
|
||||
user32.EnumWindows(_WNDENUMPROC(callback), 0)
|
||||
except Exception as exc: # noqa: BLE001 - pragma: no cover
|
||||
log.debug("EnumWindows failed: %s", exc)
|
||||
return pids
|
||||
|
||||
|
||||
def foreground_pid() -> int | None:
|
||||
"""The pid of the window the child is using, or ``None`` off Windows."""
|
||||
if not IS_WINDOWS: # pragma: no cover - platform dependent
|
||||
return None
|
||||
try:
|
||||
hwnd = _user32().GetForegroundWindow()
|
||||
if not hwnd:
|
||||
return None
|
||||
pid = ctypes.c_ulong(0)
|
||||
_user32().GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
return int(pid.value) or None
|
||||
except Exception: # noqa: BLE001 - pragma: no cover
|
||||
return None
|
||||
|
||||
|
||||
def process_name(pid: int) -> str:
|
||||
"""The image name for one pid, or an empty string."""
|
||||
psutil = _psutil()
|
||||
try:
|
||||
return psutil.Process(pid).name() or ""
|
||||
except Exception: # noqa: BLE001 - the process ended
|
||||
return ""
|
||||
|
||||
|
||||
def foreground_app() -> str:
|
||||
"""The foreground app's image name, for the status report (FR-9)."""
|
||||
pid = foreground_pid()
|
||||
return process_name(pid) if pid else ""
|
||||
|
||||
|
||||
def visible_apps(exclude_pids: Iterable[int] | None = None) -> list[str]:
|
||||
"""Names of processes with a visible, titled window right now.
|
||||
|
||||
The client's own process is excluded by default: its warning window has a
|
||||
title and would otherwise be counted as the child's usage.
|
||||
"""
|
||||
pids = window_pids()
|
||||
if not pids:
|
||||
return []
|
||||
skip = {int(pid) for pid in (exclude_pids if exclude_pids is not None else (os.getpid(),))}
|
||||
pids -= skip
|
||||
return sorted({name for name in (process_name(pid) for pid in pids) if name})
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnforcementReport:
|
||||
"""What one tick did, for the log and the status report."""
|
||||
|
||||
blocking: bool
|
||||
blocked_names: tuple[str, ...]
|
||||
killed: tuple[Victim, ...]
|
||||
failed: tuple[Victim, ...]
|
||||
|
||||
@property
|
||||
def killed_names(self) -> list[str]:
|
||||
return sorted({victim.matched for victim in self.killed})
|
||||
|
||||
|
||||
class Enforcer:
|
||||
"""Terminates blocked processes while the device is effectively locked."""
|
||||
|
||||
def __init__(self, protected: Iterable[str], log_extra: logging.Logger | None = None):
|
||||
self.log = log_extra or log
|
||||
self.protected_names = {bare(name) for name in protected if name and name.strip()}
|
||||
self.protected_pids: set[int] = set()
|
||||
# The client's own process tree is never a victim, even if a blocklist
|
||||
# names python.exe: killing itself would end enforcement silently.
|
||||
parents = _parent_pids(os.getpid())
|
||||
pids, names = self_protection(os.getpid(), {os.path.basename(sys.executable), "python", "pythonw"}, parents)
|
||||
self.protected_pids |= pids
|
||||
self.protected_names |= {bare(name) for name in names}
|
||||
|
||||
def enforce(self, config: DeviceConfig, now: datetime | None = None) -> EnforcementReport:
|
||||
"""One enforcement tick: find the victims, kill them, report the result."""
|
||||
blocking = config.is_blocking(now)
|
||||
victims = select_victims(
|
||||
snapshot_processes(),
|
||||
config,
|
||||
now=now,
|
||||
protected_names=self.protected_names,
|
||||
protected_pids=self.protected_pids,
|
||||
)
|
||||
killed: list[Victim] = []
|
||||
failed: list[Victim] = []
|
||||
for victim in victims:
|
||||
outcome = kill_process(victim.pid, victim.name)
|
||||
if outcome in ("terminated", "killed"):
|
||||
self.log.info(
|
||||
"blocked %s (pid=%s, rule=%s): %s", victim.name, victim.pid, victim.matched, outcome
|
||||
)
|
||||
killed.append(victim)
|
||||
elif outcome == "failed":
|
||||
failed.append(victim)
|
||||
if killed:
|
||||
self.log.info("enforcement killed %d process(es): %s", len(killed), ", ".join(
|
||||
f"{v.name}({v.pid})" for v in killed
|
||||
))
|
||||
return EnforcementReport(
|
||||
blocking=blocking,
|
||||
blocked_names=tuple(sorted(config.blocked_names())),
|
||||
killed=tuple(killed),
|
||||
failed=tuple(failed),
|
||||
)
|
||||
|
||||
|
||||
def _parent_pids(pid: int) -> set[int]:
|
||||
"""The pid's ancestor chain, skipping failures."""
|
||||
psutil = _psutil()
|
||||
out: set[int] = set()
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
except Exception: # noqa: BLE001 - pragma: no cover
|
||||
return out
|
||||
for _ in range(10):
|
||||
try:
|
||||
parent = proc.parent()
|
||||
except Exception: # noqa: BLE001
|
||||
break
|
||||
if parent is None or parent.pid in out:
|
||||
break
|
||||
out.add(parent.pid)
|
||||
proc = parent
|
||||
return out
|
||||
178
windows/webrestrict/settings.py
Normal file
178
windows/webrestrict/settings.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Client settings, read from one JSON file (AGENTS.md §11 Phase 4).
|
||||
|
||||
Everything the client needs is local configuration: the broker address, the
|
||||
device's own topic and MQTT account, and where to keep its state. Nothing here
|
||||
is a second source of truth — the Control Service still owns the rules.
|
||||
|
||||
The file is provisioned on the machine and is **never** committed. It holds the
|
||||
device's MQTT password, which must stay off the repository (AGENTS.md §10 rule
|
||||
8). The path comes from ``--config``, then ``WEBRESTRICT_CONFIG``, then
|
||||
``%PROGRAMDATA%\\WebRestrict\\client.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Default location. ProgramData is machine-wide and readable by the New Comp
|
||||
# account, which is what the logon Scheduled Task runs as.
|
||||
DEFAULT_CONFIG_NAME = "client.json"
|
||||
DEFAULT_STATE_DIR_NAME = "WebRestrict"
|
||||
|
||||
# Never kill these, whatever a blocklist says. The client must not be able to
|
||||
# take the machine down, and it must not kill itself (it runs as python.exe).
|
||||
# The client's own interpreter and its parent chain are added at runtime.
|
||||
DEFAULT_PROTECTED: tuple[str, ...] = (
|
||||
"system",
|
||||
"system idle process",
|
||||
"registry",
|
||||
"smss",
|
||||
"csrss",
|
||||
"wininit",
|
||||
"winlogon",
|
||||
"services",
|
||||
"lsass",
|
||||
"svchost",
|
||||
"dwm",
|
||||
"explorer",
|
||||
"taskhostw",
|
||||
"sihost",
|
||||
"ctfmon",
|
||||
"conhost",
|
||||
"fontdrvhost",
|
||||
"audiodg",
|
||||
"msmpeng",
|
||||
"securityhealthservice",
|
||||
"searchindexer",
|
||||
"startmenuexperiencehost",
|
||||
"shellexperiencehost",
|
||||
"textinputhost",
|
||||
"applicationframehost",
|
||||
"python",
|
||||
"pythonw",
|
||||
)
|
||||
|
||||
|
||||
def default_program_data() -> Path:
|
||||
"""The machine-wide directory for client configuration and state."""
|
||||
base = os.environ.get("PROGRAMDATA") or os.environ.get("ALLUSERSPROFILE")
|
||||
if base:
|
||||
return Path(base) / DEFAULT_STATE_DIR_NAME
|
||||
# Not Windows (unit tests, a lab box): keep it under the user's home.
|
||||
return Path.home() / ".webrestrict"
|
||||
|
||||
|
||||
def default_config_path() -> Path:
|
||||
"""The config path: ``WEBRESTRICT_CONFIG`` wins, else ``%PROGRAMDATA%``."""
|
||||
override = os.environ.get("WEBRESTRICT_CONFIG")
|
||||
if override:
|
||||
return Path(override)
|
||||
return default_program_data() / DEFAULT_CONFIG_NAME
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
"""Everything the client needs to run, with safe lab defaults.
|
||||
|
||||
A missing field falls back to the default, so a hand-written config file
|
||||
only needs the values that differ. Unknown fields are ignored, so the file
|
||||
may grow without a client update.
|
||||
"""
|
||||
|
||||
# --- the link ---------------------------------------------------------
|
||||
broker_host: str = "192.168.20.13"
|
||||
broker_port: int = 8883
|
||||
ca_file: str = "" # empty = the platform trust store
|
||||
device_topic: str = ""
|
||||
mqtt_username: str = ""
|
||||
mqtt_password: str = ""
|
||||
client_id: str = "" # empty = derive from the topic token
|
||||
|
||||
# --- local state ------------------------------------------------------
|
||||
state_dir: str = ""
|
||||
|
||||
# --- timings (seconds) ------------------------------------------------
|
||||
poll_seconds: float = 5.0
|
||||
telemetry_seconds: float = 60.0
|
||||
status_seconds: float = 60.0
|
||||
connect_timeout_seconds: float = 10.0
|
||||
|
||||
# --- behaviour --------------------------------------------------------
|
||||
overlay: bool = True
|
||||
protected: list[str] = field(default_factory=lambda: list(DEFAULT_PROTECTED))
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
@property
|
||||
def state_path(self) -> Path:
|
||||
"""The directory holding the cached config, the usage roll-up and the log."""
|
||||
if self.state_dir:
|
||||
return Path(self.state_dir)
|
||||
if self.device_token:
|
||||
return default_program_data() / self.device_token
|
||||
return default_program_data()
|
||||
|
||||
@property
|
||||
def device_token(self) -> str:
|
||||
"""The middle topic level, which is also the broker account name."""
|
||||
base = self.device_topic.removesuffix("/config")
|
||||
return base.rsplit("/", 1)[-1] if "/" in base else base
|
||||
|
||||
@property
|
||||
def status_topic(self) -> str:
|
||||
return self.device_topic.removesuffix("/config") + "/status"
|
||||
|
||||
@property
|
||||
def telemetry_topic(self) -> str:
|
||||
return self.device_topic.removesuffix("/config") + "/telemetry"
|
||||
|
||||
@property
|
||||
def effective_client_id(self) -> str:
|
||||
return self.client_id or f"kid-{self.device_token or 'windows'}"
|
||||
|
||||
def config_path(self) -> Path:
|
||||
return self.state_path / "config-cache.json"
|
||||
|
||||
def telemetry_path(self) -> Path:
|
||||
return self.state_path / "telemetry.json"
|
||||
|
||||
def log_path(self) -> Path:
|
||||
return self.state_path / "client.log"
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Raise when the client cannot possibly work, instead of failing later."""
|
||||
if not self.device_topic:
|
||||
raise ValueError("device_topic is not set (e.g. restrict/finn_s_computer_2/config)")
|
||||
if not self.device_topic.endswith("/config"):
|
||||
raise ValueError(f"device_topic must end with /config: {self.device_topic!r}")
|
||||
if not self.broker_host:
|
||||
raise ValueError("broker_host is not set")
|
||||
if not 1 <= self.broker_port <= 65535:
|
||||
raise ValueError(f"broker_port is out of range: {self.broker_port}")
|
||||
if not self.mqtt_username:
|
||||
raise ValueError("mqtt_username is not set (it is the topic token)")
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, Any]) -> "Settings":
|
||||
"""Build settings from a parsed JSON object, ignoring unknown keys."""
|
||||
known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
|
||||
data = {k: v for k, v in raw.items() if k in known}
|
||||
settings = cls(**data)
|
||||
settings.broker_port = int(settings.broker_port)
|
||||
return settings
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | os.PathLike[str] | None = None) -> "Settings":
|
||||
"""Read the config file and validate it."""
|
||||
target = Path(path) if path else default_config_path()
|
||||
with open(target, "r", encoding="utf-8") as handle:
|
||||
raw = json.load(handle)
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{target}: expected a JSON object")
|
||||
settings = cls.from_dict(raw)
|
||||
settings.validate()
|
||||
return settings
|
||||
155
windows/webrestrict/telemetry.py
Normal file
155
windows/webrestrict/telemetry.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Daily usage roll-up and its local store (AGENTS.md FR-7).
|
||||
|
||||
The client publishes ``{"day":...,"apps":[{"name":...,"seconds":N}]}`` where the
|
||||
seconds are **totals for the day**, not deltas: the Control Service replaces the
|
||||
row for (device, day, app) on every report (``internal/store/migrations/002``).
|
||||
|
||||
Windows has no per-app foreground clock like Android's Usage Access, so the
|
||||
client builds its own total: every sample, the elapsed seconds are added to each
|
||||
app that currently owns a visible, titled top-level window. That total is kept
|
||||
on disk so a restart never loses the day, and it is republished when the link
|
||||
returns (FR-14).
|
||||
|
||||
**Domains are not attributed.** Windows gives no cheap per-process DNS answer —
|
||||
it needs a packet capture, an ETW consumer or the DNS Client event log, all of
|
||||
which are either a second dependency or an administrator-only channel. The
|
||||
client therefore publishes ``"domains": []`` and the gap is recorded in
|
||||
``windows/README.md`` rather than filled with a guess.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Mapping
|
||||
|
||||
log = logging.getLogger("webrestrict.telemetry")
|
||||
|
||||
# Keep two weeks of daily totals on the device, matching the Android client.
|
||||
MAX_DAYS = 14
|
||||
|
||||
# A sample longer than this is treated as this long, so a laptop that was asleep
|
||||
# does not credit one app with hours of "use".
|
||||
MAX_SAMPLE_SECONDS = 120.0
|
||||
|
||||
|
||||
def day_key(moment: datetime | None = None) -> str:
|
||||
"""The local ``YYYY-MM-DD`` the usage belongs to.
|
||||
|
||||
An aware timestamp is converted to local time first, so a day boundary is the
|
||||
child's midnight rather than UTC's.
|
||||
"""
|
||||
current = moment or datetime.now()
|
||||
if current.tzinfo is not None:
|
||||
current = current.astimezone()
|
||||
return current.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def to_entries(apps: Mapping[str, int]) -> list[dict[str, object]]:
|
||||
"""The list form the telemetry topic accepts, sorted and positive-only."""
|
||||
return [
|
||||
{"name": name, "seconds": int(seconds)}
|
||||
for name, seconds in sorted(apps.items())
|
||||
if name and int(seconds) > 0
|
||||
]
|
||||
|
||||
|
||||
def build_payload(day: str, apps: Mapping[str, int]) -> dict[str, object]:
|
||||
"""The Phase 3 telemetry payload (``internal/mqtt/payload.go``)."""
|
||||
return {"day": day, "apps": to_entries(apps), "domains": []}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TelemetryStore:
|
||||
"""day -> app -> seconds, kept on disk so a restart or outage loses nothing."""
|
||||
|
||||
path: Path
|
||||
days: dict[str, dict[str, int]] = field(default_factory=dict)
|
||||
loaded: bool = False
|
||||
|
||||
def load(self) -> "TelemetryStore":
|
||||
if self.loaded:
|
||||
return self
|
||||
self.loaded = True
|
||||
try:
|
||||
if not self.path.exists():
|
||||
return self
|
||||
with open(self.path, "r", encoding="utf-8") as handle:
|
||||
raw = json.load(handle)
|
||||
days = raw.get("days") if isinstance(raw, dict) else None
|
||||
if isinstance(days, dict):
|
||||
self.days = {
|
||||
str(day): {str(name): int(seconds) for name, seconds in row.items()}
|
||||
for day, row in days.items()
|
||||
if isinstance(row, dict)
|
||||
}
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
log.warning("telemetry store is unreadable, starting empty: %s", exc)
|
||||
self.days = {}
|
||||
return self
|
||||
|
||||
def save(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
with open(temp, "w", encoding="utf-8") as handle:
|
||||
json.dump({"days": self.days}, handle, sort_keys=True)
|
||||
os.replace(temp, self.path)
|
||||
|
||||
def day(self, day: str) -> dict[str, int]:
|
||||
self.load()
|
||||
return dict(self.days.get(day, {}))
|
||||
|
||||
def add_seconds(self, day: str, apps: Mapping[str, int]) -> dict[str, int]:
|
||||
"""Add a sample to a day and return that day's new totals."""
|
||||
self.load()
|
||||
row = self.days.setdefault(day, {})
|
||||
for name, seconds in apps.items():
|
||||
if not name or seconds <= 0:
|
||||
continue
|
||||
row[name] = row.get(name, 0) + int(seconds)
|
||||
self.prune()
|
||||
self.save()
|
||||
return dict(row)
|
||||
|
||||
def prune(self) -> None:
|
||||
"""Keep only the [MAX_DAYS] most recent days."""
|
||||
if len(self.days) <= MAX_DAYS:
|
||||
return
|
||||
for old in sorted(self.days)[:-MAX_DAYS]:
|
||||
self.days.pop(old, None)
|
||||
|
||||
|
||||
class UsageSampler:
|
||||
"""Turns "these apps had a window" ticks into per-app daily seconds."""
|
||||
|
||||
def __init__(self, store: TelemetryStore):
|
||||
self.store = store
|
||||
self.last_at: datetime | None = None
|
||||
|
||||
def sample(
|
||||
self,
|
||||
apps: Iterable[str],
|
||||
now: datetime | None = None,
|
||||
day: str | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Credit the elapsed time to each app in the sample.
|
||||
|
||||
Returns the day's totals. The first call only starts the clock: there is
|
||||
no way to know what ran before the client started.
|
||||
"""
|
||||
moment = now or datetime.now(timezone.utc)
|
||||
self.last_at = self.last_at or moment
|
||||
elapsed = (moment - self.last_at).total_seconds()
|
||||
self.last_at = moment
|
||||
if elapsed <= 0:
|
||||
return self.store.day(day or day_key(moment))
|
||||
elapsed = min(elapsed, MAX_SAMPLE_SECONDS)
|
||||
|
||||
wanted = sorted({name for name in apps if name})
|
||||
if not wanted:
|
||||
return self.store.day(day or day_key(moment))
|
||||
return self.store.add_seconds(day or day_key(moment), {name: int(elapsed) for name in wanted})
|
||||
Reference in New Issue
Block a user