"""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})