# Kids' Windows computer client — Phase 4 The kids' **Windows** client (AGENTS.md §11 Phase 4, FR-3, FR-4, FR-6, FR-7, FR-9, FR-14). Python, one process, no service install and no admin requirement beyond what the account already has. It follows the Control Service's desired state over MQTT, blocks listed processes while the device is locked, warns the child, reports usage, and **keeps its last state when the broker is unreachable**. It is a device, not a control plane: it never changes its own rules (AGENTS.md §10 rule 7). Everything it enforces arrives on `restrict//config`. ## What it does | Piece | File | Job | |---|---|---| | Config transport | `webrestrict/mqtt_link.py` | Subscribes, retained, to `restrict//config` on the 8883 TLS plane with the device's own credentials; publishes presence and usage on `restrict//status` and `restrict//telemetry`. | | Desired state | `webrestrict/config.py` | Parses the Phase 3 payload and computes the effective lock (`locked` and the clock). | | Last state (FR-14) | `webrestrict/config.py` | The last valid config is cached on disk; a broker outage changes nothing, and only a newer valid config replaces it. | | Block rule | `webrestrict/blocker.py` | Blocked = effectively locked **and** on the blocklist **and** not allowlisted (FR-4). | | Process blocking (FR-3) | `webrestrict/process_watch.py` | Polls `psutil`, `terminate()`s a blocked process, escalates to `kill()` after a short grace. | | Warning (FR-9) | `webrestrict/overlay.py` | An always-on-top Tkinter window: what is blocked, why, until when. | | Usage (FR-7) | `webrestrict/telemetry.py` | Daily per-app seconds, rolled up on disk and published as day totals. | | The loop | `webrestrict/agent.py` | Wires the pieces together with bounded, isolated ticks. | | Install | `install-scheduled-task.ps1` | The logon Scheduled Task. | **No service, no driver, no filter at the network edge.** The child can end the process; that is accepted (see "Honest limits"). ## How the block works Every `poll_seconds` (default 5) the enforcer takes a process snapshot and selects victims: 1. the cached config is **not** effectively locked → nothing is blocked; 2. a process is a victim when its normalised name is on the blocklist; 3. the **allowlist always wins** (FR-4) — `spotify.exe` stays up while everything else is blocked; 4. the client never kills itself, its parent chain, or a protected system process (`explorer.exe`, `lsass.exe`, `svchost.exe`, …), whatever a blocklist says. The full default list is `DEFAULT_PROTECTED` in `webrestrict/settings.py`. Name matching is case-insensitive and ignores `.exe`, so `Notepad.EXE`, `notepad.exe` and `notepad` are the same app. "Effectively locked" mirrors `internal/store/schedules.go` `EffectiveState`: the payload already carries the resolved `locked` flag, so the client only applies the clock. An expired `until` lifts the block at the same instant the Control Service would clear it, a live `grant_until` never blocks (FR-10 grant), and an indefinite lock (`until` absent) never lifts. ## Keep last state (FR-14) The config is cached at `/config-cache.json`, written atomically. The client loads it **before** it tries to connect, so a device that never reaches the broker still applies the rules it was last given. A malformed payload, a newer schema version, or an older `generated_at` is rejected and the cache is left alone. ## Presence (FR-6) The client publishes a retained `{"online": true}` status when it connects and registers the will `{"online": false}` on the same topic. If the machine is switched off, the client is force-stopped, or the link dies, the broker publishes the will and the Control Service marks the device offline — that is the Windows tamper/offline signal (FR-17 here is "the client is gone", which `CONTROL_ALERT_OFFLINE_AFTER` turns into an Apprise alert, FR-13). A clean stop publishes the offline status explicitly. The status payload also carries `locked` and the foreground `app`, so the dashboard shows real state: ```json {"app":"notepad.exe","at":"2026-10-14T04:05:06Z","locked":true,"online":true} ``` ## Usage telemetry (FR-7), and the domain gap Windows has no `UsageStatsManager`. The client builds its own daily total: every `telemetry_seconds` (default 60) it lists the processes that own a **visible, titled top-level window** and adds the elapsed seconds to each. The roll-up is persisted per day (14 days kept) and published as **day totals**, so a repeated report replaces the Control Service row instead of double counting (`internal/store/migrations/002_telemetry_unique.sql`): ```json {"day":"2026-10-14","apps":[{"name":"notepad.exe","seconds":420}],"domains":[]} ``` * It counts "an app had a window on screen", sampled every minute, not Android's exact foreground clock. It is an approximation, and it is the honest one available without a second dependency. The client's own process is excluded, so its warning window is never counted as the child's usage. * **Domains are always `[]`.** Windows gives no cheap per-process DNS attribution: it needs a packet capture, an ETW consumer, or the DNS Client event log — a second dependency or an administrator-only channel. The gap is recorded here rather than filled with a guess. Site filtering for Windows is therefore **not** implemented in this phase; FR-18 is satisfied on the phone only (`android/`). Say so in any status report. ## Safety limits * Never kills a protected system process, the current process tree, or anything on the allowlist. * A missing or corrupt config never invents a lock: first run seeds an **unlocked** config, so a fresh install cannot lock a child out of their machine while the broker is unreachable. * Every tick is wrapped: a psutil or Tk failure is logged and the loop continues. ## Honest limits * The child can end the process (`taskkill /im pythonw.exe`). The enforcement is the tamper/offline signal and the parent's knowledge, not a fortress (AGENTS.md §4 FR-17 rationale, TRANSPORT_DESIGN.md "Anti-bypass"). * A process started **after** a tick is killed on the next tick; the window is `poll_seconds` (5 s by default). * The overlay is a warning only. Closing it does not lift the block. * Running the task as a limited (non-elevated) user blocks the child's own processes, which is the normal case. Blocking a process owned by **another** Windows account needs the task to run at highest privilege. ## Provisioning a machine Nothing secret is committed. The config file holds the device's MQTT password. 1. **Register the device** in the Control Service (dashboard or admin API) with platform `windows`, then read its topic token: ```bash docker exec webrestrict-control /app/control mqtt-clients ``` 2. **Create its broker account** on the 8883 plane and read the password from the generated file (both gitignored): ```bash deploy/mosquitto/provision-clients.sh grep -P '^finn_s_computer_2\t' deploy/mosquitto/clients.env ``` 3. **Copy the CA and the client** to the machine. `C:\ProgramData\WebRestrict` is machine-wide and readable by the kid's account: ```powershell New-Item -ItemType Directory -Force C:\ProgramData\WebRestrict\client # copy windows\webrestrict\* to C:\ProgramData\WebRestrict\client\webrestrict\ # copy deploy/mosquitto/tls/ca.crt to C:\ProgramData\WebRestrict\ca.crt python -m pip install --user -r requirements.txt ``` 4. **Write `C:\ProgramData\WebRestrict\client.json`** (mode: readable by the kid's account only) — `{ ` `}` replaced by the real values: ```json { "broker_host": "192.168.20.13", "broker_port": 8883, "ca_file": "C:\\ProgramData\\WebRestrict\\ca.crt", "device_topic": "restrict/finn_s_computer_2/config", "mqtt_username": "finn_s_computer_2", "mqtt_password": "", "state_dir": "C:\\ProgramData\\WebRestrict\\state" } ``` 5. **Install the Scheduled Task** so the client survives a reboot. The script also grants the account Modify on the state directory — a file created from an elevated shell inherits only `Administrators`, and the task's limited interactive token would otherwise get `Access denied`: ```powershell powershell -ExecutionPolicy Bypass -File C:\ProgramData\WebRestrict\client\install-scheduled-task.ps1 ` -ClientDir "C:\ProgramData\WebRestrict\client" ` -Config "C:\ProgramData\WebRestrict\client.json" ``` Or, the one-line form: ``` schtasks /Create /TN "WebRestrict Kids Client" /SC ONLOGON /F ^ /TR "\"C:\Users\New Comp\AppData\Local\Programs\Python\Python314\pythonw.exe\" -m webrestrict --config \"C:\ProgramData\WebRestrict\client.json\"" ``` Both register the task for the account that runs them, with an interactive logon token and no stored password, and both start the client at logon. 6. **Check it without waiting**: ``` python -m webrestrict check ``` That prints the cached config, whether it is effectively locked, and the processes it would terminate right now. It changes nothing. ## Running and reading the log ``` python -m webrestrict --config C:\ProgramData\WebRestrict\client.json # the client python -m webrestrict check # one-shot state python -m webrestrict version ``` The client logs to the console and to `/client.log` (2 MB, 3 rotations). It records every config it applied and every process it blocked, which is the evidence trail for a parent and for a phase check. ## Tests Pure logic only — no broker, no Windows, no psutil: ```bash cd windows && python -m unittest discover -s tests -t . # or, from the repository root: make windows-test ``` 53 tests cover the payload shape, the effective lock, name matching, the allowlist, the keep-last-state cache, victim selection (including the protected names), the usage roll-up, and the status and telemetry payloads. ## Evidence from the live run See `windows/EVIDENCE.md` for the transcript of the Definition of Done on the real machine (`DESKTOP-TE2RHV2`): a listed process killed inside a scheduled window, the dashboard showing the device online, a telemetry row landing in the Control Service, enforcement continuing with the broker unreachable, the warning window on screen, and the Scheduled Task registered at logon. It also records the honest gaps (no domain attribution, no elevated blocking, tamper = offline).