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.
148 lines
5.4 KiB
Python
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)}")
|