Files
Sam Rolfe 1275b1baaa 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.
2026-10-14 15:00:22 +11:00

148 lines
5.4 KiB
Python

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