# Togen Windows Display Agent

Windows half of Togen signage displays (DVI-1230 / DVI-1227 P2). Turns a
Windows PC into a fullscreen Togen dashboard kiosk, the same way
[`togen/signage_agent/`](../signage_agent/) does for Linux -- plus
Windows-only window layer control (always-on-top / always-on-bottom /
windowed / experimental desktop-background).

This agent is deliberately **thin**, matching the Linux agent's design: it
does not know about dashboards, tiles, or reassignment. **All** of that --
dashboard render, per-display interface (theme/background/opacity/padding),
splash, layout fit/orientation, and Present -- is rendered by the WebView2
browser surface itself via the existing `/display?token=` page and its
`/display/config` poll (~15s). The agent's own responsibilities are:

1. One-time registration (exchanges an admin-minted enrollment token for a
   permanent `agent_token`).
2. Hosting a frameless window that renders `/display?token=<agent_token>`
   via WebView2 (Microsoft Edge's Chromium engine, embedded -- not a
   separate browser process to hunt down and manipulate).
3. Heartbeating `POST /signage/checkin` every ~30s.
4. **The one genuinely new capability**: polling `/display/config` itself
   (in addition to the WebView2 page's own poll) to read the `window` block
   and apply Windows window-layer control to its own window handle -- that
   is OS-level and must be agent-side.
5. Preventing the display from sleeping (`SetThreadExecutionState`).

## Why a Windows agent differs from the Linux one

The Linux kiosk agent runs Chromium in kiosk mode as a normal process on a
dedicated X/Wayland session with no window manager fighting it for layer
control. Windows has no equivalent of "just start X and grab the whole
screen" -- every window lives inside the desktop shell, so getting
always-on-bottom or a wallpaper-like layer requires direct Win32 window
manipulation. That's the one place this agent carries real logic; see
`winlayer.py`.

## Architecture

| File | Purpose |
|---|---|
| `display_agent.py` | CLI entry point, enrollment, the kiosk window lifecycle (create/respawn), heartbeat + config-poll threads |
| `winlayer.py` | Win32 window-layer engine: topmost/bottom/windowed/wallpaper, monitor targeting, screen-blanking prevention. All real Win32 calls are injectable so the fallback/policy logic is unit-tested without a live Windows desktop (`tests/test_winlayer.py`) |
| `server_client.py` | HTTP client for the frozen `/signage/agents/register`, `/signage/checkin`, `/display/config` contract; token storage via Windows Credential Manager (keyring) with an HKLM/HKCU registry fallback, mirroring the Vision agent's convention |
| `display_agent.spec` | PyInstaller spec -> single-file `display_agent.exe` |
| `build_agent.ps1` | Builds the exe and packages it + install/upgrade/swap scripts + `.py` source into `togen-display-agent.zip`, dropped directly in this directory (matches the server's `DISPLAY_AGENT_WIN_DIR`/`DISPLAY_AGENT_ZIP_NAME`) |
| `install.ps1` | Installer: WebView2 runtime bootstrap, enrollment, logon-triggered Scheduled Task, optional auto-logon |
| `upgrade.ps1` | This bundle's own staged `.new`-swap self-upgrade entry point (multi-file: exe + `.py` source) |
| `swap_agent.ps1` | The actual stop/rename/restart logic, run from a separate one-shot Scheduled Task. Installed alongside the agent so it is shared by **both** `upgrade.ps1` above and the server-rendered `GET /enroll/display-upgrade.ps1` route (DVI-1227 P1, single-file: exe only) -- see "Two upgrade paths" below |

Why Python + pywebview(WebView2) rather than C#/.NET: matches the existing
Vision agent's tooling (PyInstaller signed EXE, same build/deploy shape,
same team familiarity) per the board-approved plan (decision D1-A).

## Process model: logon task, not SYSTEM

Unlike the Vision agent's SYSTEM-scheduled check-in task, this agent runs as
a **logon-triggered Scheduled Task in the interactive user session**. It has
to -- it shows a window, and SYSTEM (Session 0) has no desktop to show one
on. Practical implications:

- The task's `Principal` uses `LogonType Interactive` + `RunLevel Limited`
  for a specific user (`-KioskUser`, default: whoever is logged in when
  `install.ps1` runs).
- If nobody is logged on, there is no display -- expected/acceptable for a
  signage/desk PC. For a **dedicated signage box**, pass `-AutoLogon` (see
  below) so it always has a session to run in after a reboot.
- The Scheduled Task's `RestartCount`/`RestartInterval` provide OS-level
  respawn; the agent process itself *also* runs its own respawn loop around
  the WebView2 window (mirrors the Linux `kiosk-launch.sh` `sleep 3` retry)
  so a crashed/closed window comes back without waiting for the task
  scheduler's own retry cadence.

## Install

Extract the ZIP (from `/enroll/display-agent.zip` -- see the P1
server-side sibling, DVI-1227) and run as Administrator:

```powershell
.\install.ps1 -Server https://togen.example.com -Enroll <enrollment_token>
```

This:
- unblocks and copies `display_agent.exe` (+ `.py` source, for the same
  Smart-App-Control/WDAC-safe fallback the Vision agent uses) to
  `%ProgramFiles%\Togen\Display`;
- bootstraps the WebView2 Evergreen runtime if it isn't already present
  (most managed/recent Win10/11 images already have it -- it ships with
  Edge and is serviced via Windows Update);
- registers with the Togen server (`POST /signage/agents/register`),
  storing the returned `agent_token` in Windows Credential Manager (HKLM
  registry fallback);
- registers the `TogenDisplayAgent` Scheduled Task (`AtLogOn`, restarts on
  failure) and starts it immediately if the kiosk user is already logged in.

### Dedicated signage PC (auto-logon)

```powershell
.\install.ps1 -Server https://togen.example.com -Enroll <token> `
    -KioskUser signage -AutoLogon -KioskPassword 'Str0ngPass!'
```

**Security note:** Windows auto-logon stores the password in the registry in
a weakly-obfuscated (not encrypted) form under
`HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon`. This is
standard practice for single-purpose kiosk/signage hardware with no other
local users and physical access control -- do not use `-AutoLogon` on a
shared or general-purpose PC.

### Re-running for an already-enrolled machine

Omit `-Enroll` to update the installed files/task without re-registering
(useful after a manual file drop, or to change `-KioskUser`).

## Layer modes

Set per-display in **Togen Admin -> Agents -> Display -> edit -> Window**
(Windows-only fieldset, per the frozen server contract's `window` block:
`{"layer", "monitor", "windowed": {"x","y","width","height"}}`). Applied
live by the agent's own `/display/config` poll (~15s) -- no reinstall or
restart needed to change layer/monitor.

| Layer | Behavior |
|---|---|
| `top` (default) | `HWND_TOPMOST`, reasserted every ~2s so another app fighting for topmost doesn't win. |
| `bottom` | `HWND_BOTTOM`, reasserted the same way. |
| `windowed` | A normal movable/resizable window at the given `{x,y,width,height}`. |
| `wallpaper` (**experimental**) | Re-parents the window into the desktop's `WorkerW` layer (the Progman `0x052C` message trick used by Wallpaper Engine/Lively) so it sits behind desktop icons. **Any failure (Progman not found, no WorkerW spawned, `SetParent` failure, or the WorkerW disappearing later e.g. after an Explorer restart) automatically falls back to `bottom`** and the effective layer is reported back on the next checkin (`window_layer_effective` -- see the note on frozen-contract coordination below). |

`monitor`: `"primary"` (default) or a 1-based index into the enumerated
monitor list (falls back to primary, then the first monitor, if the index
doesn't exist -- e.g. a monitor was unplugged).

## Screen-blanking prevention

The agent calls `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED
| ES_DISPLAY_REQUIRED)` once at window start and again every ~60s from a
keep-alive thread, so Windows never blanks the display or sleeps while the
kiosk is running (matches the Linux agent's DPMS-disable behavior).

## Revocation / unauthorized handling

A revoked or invalid `agent_token` makes both `/signage/checkin` and
`/display/config` return 401. Per the ticket's contract, the agent's own
poll threads react by **closing the kiosk window** and falling back to a
retry loop that keeps calling `/signage/checkin` (the Vision-agent-style
"keep trying, in case an admin un-revokes it" behavior) rather than spinning
up a dead window. To bring a revoked display back: mint a fresh enrollment
token in Admin -> Agents -> Display and re-run `install.ps1 -Enroll <token>`.

## Upgrade

```powershell
.\upgrade.ps1 -Server https://togen.example.com
```

Staged `.new`-swap, mirroring the Vision agent's DVI-920/DVI-1007-fixed
design exactly (same underlying Windows gotchas apply to any Scheduled-Task
-hosted Windows agent):

1. Downloads the latest `togen-display-agent.zip` (or accepts
   `-PreDownloadedZip`) and stages the new exe/source as `*.new` files
   alongside the running ones -- a different filename avoids the AV/OS file
   lock that broke the Vision agent's original "stop then copy" approach
   (DVI-920). Also refreshes the installed `swap_agent.ps1` from the bundle.
2. Writes a manifest (`%ProgramData%\Togen\Display\swap_manifest.json`) and
   registers a **separate** one-shot `TogenDisplayAgentSwap` Scheduled Task
   (its own Job Object) that fires ~20s later running the installed
   `swap_agent.ps1`, which stops `TogenDisplayAgent`, waits for file-lock
   release, renames `.new` -> live filenames per the manifest, restarts the
   kiosk task, and self-unregisters. `upgrade.ps1` itself never calls
   `Stop-ScheduledTask`/`Stop-Process` on the agent directly -- if this
   script is ever invoked from within the agent's own process tree (e.g. a
   future remote "update agent" command), a same-Job-Object stop call can
   silently fail or kill the wrong process (DVI-1007). A separate task
   sidesteps that entirely.
3. Falls back to running `swap_agent.ps1` directly and synchronously
   (documented, reduced-safety path) only if the swap task itself cannot be
   registered.

Watch `%ProgramData%\Togen\Display\Logs\agent.log` for
`[DisplayAgentSwap] swap start` -> `swap_complete` -> `agent_restarted ok`.

### Two upgrade paths, one shared swap script

The server (DVI-1227 P1, `togen/app.py`) also serves a self-contained
`GET /enroll/display-upgrade.ps1` (Admin-session- or device-token-authed,
mirrors the Vision agent's dynamically-rendered upgrade script). It stages
only `display_agent.exe.new` (no `.py` source) and registers
`TogenDisplayAgentSwap` pointing at `$InstallDir\swap_agent.ps1` **if that
file is already installed** -- falling back to its own minimal
single-exe-only inline swap script only when it is missing. Because
`install.ps1` installs `swap_agent.ps1` from the very first install (not
just on an `upgrade.ps1` run), both upgrade entry points end up running the
same manifest-aware, multi-file, lock-retrying swap logic -- verified by
reading that server route directly (`togen/app.py`,
`display_enroll_upgrade`).

## Files installed

| Path | Purpose |
|---|---|
| `%ProgramFiles%\Togen\Display\display_agent.exe` | The agent |
| `%ProgramFiles%\Togen\Display\{display_agent,winlayer,server_client}.py` | Source (WDAC/Smart-App-Control-safe fallback) |
| `%ProgramFiles%\Togen\Display\run_agent.ps1` | Hidden-window launcher the Scheduled Task invokes |
| `%ProgramData%\Togen\Display\Logs\agent.log` | Rotating agent log (2MB x2 backups) |
| `%ProgramData%\Togen\Display\swap_manifest.json` | Transient -- only exists mid-upgrade |
| HKLM/HKCU `Software\Togen\Display` | `MachineId`, `AgentToken` (registry fallback; Windows Credential Manager is primary) |
| Scheduled Task `TogenDisplayAgent` | Logon-triggered kiosk launcher |
| Scheduled Task `TogenDisplayAgentSwap` | Transient -- only exists mid-upgrade, self-unregisters |

## Uninstall

```powershell
Unregister-ScheduledTask -TaskName TogenDisplayAgent -Confirm:$false
Unregister-ScheduledTask -TaskName TogenDisplayAgentSwap -Confirm:$false -ErrorAction SilentlyContinue
Stop-Process -Name display_agent -Force -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force "$env:ProgramFiles\Togen\Display"
Remove-Item -Recurse -Force "$env:ProgramData\Togen\Display"
Remove-Item -Path HKLM:\SOFTWARE\Togen\Display -Recurse -ErrorAction SilentlyContinue
```

If `-AutoLogon` was used, also clear `AutoAdminLogon`/`DefaultPassword`
under `HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon`. Also
revoke or delete the display in Admin -> Agents -> Display so the
`agent_token` can't be reused.

## Coordination note for the P1 (Togen Developer) sibling

This P2 build was implemented against the frozen contract exactly as
written in the ticket, in parallel with P1 (no server-side `platform`/
`window` fields existed yet at the time this was written -- verified by
reading `togen/app.py`'s `/signage/agents/register`, `/signage/checkin`,
and `/display/config` routes directly). Two small items to reconcile when
P1 lands, flagged via a comment on the parent issue (DVI-1227):

1. **`window_layer_effective`** in the `checkin` body -- the ticket asks the
   agent to "report the effective layer in checkin" for the wallpaper
   -fallback case. Today's `/signage/checkin` route ignores unknown JSON
   keys, so this is forward-safe either way; P1 can pick this field name up
   or specify a different one.
2. **Delivery layout** -- this P2 build assembles as static files (like the
   Linux agent's `_SIGNAGE_AGENT_FILES` bundling), not a server-rendered
   script with an embedded token (like the Vision agent's `/enroll/
   upgrade.ps1`). `upgrade.ps1` takes `-Server` and auto-detects it from the
   installed task if omitted -- no token needs to be embedded server-side.

## Verification status

**Not yet verified on real Windows hardware** -- this environment has no
Windows/WebView2 box available. What *was* verified locally:
- `winlayer.py`'s policy logic (monitor resolution, windowed-geometry
  clamping, the wallpaper-reparent fallback state machine) via
  `tests/test_winlayer.py` -- all real Win32 calls are injected, so these
  run on any platform. **40/40 passing.**
- `server_client.py`'s HTTP contract handling (register/checkin/config,
  401 propagation, window-config sanitization) via
  `tests/test_server_client.py` with an injected fake HTTP session --
  **included in the same 40/40.**
- `python3 -m py_compile` on all three `.py` modules.
- `install.ps1`/`upgrade.ps1`/`build_agent.ps1` parse cleanly under
  PowerShell's own parser (`[System.Management.Automation.Language.Parser]
  ::ParseFile`, via PowerShell Core on this dev machine).

**Not verifiable without Windows hardware** (flagged for P3, the
CTO-coordinated real-hardware e2e pass): actual WebView2 window creation and
HWND extraction from pywebview's `edgechromium` backend, the Win32 layer
calls themselves (`SetWindowPos`/`SetParent`/the WorkerW trick) against a
real desktop, the Scheduled Task logon/auto-logon behavior, the WebView2
Evergreen bootstrap installer, and the PyInstaller build producing a working
exe.
