# Windows 11 Support — Design **Date:** 2026-06-02 **Status:** Approved, ready for implementation planning **Scope:** Full feature parity on Windows 11 — both OS ports (active-window sensing and window-minimize enforcement) — implemented as `//go:build windows` adapters behind the existing interfaces. No consumer code changes. ## Problem AntiDrift's value is making drift visible by watching the active window and, on confirmed off-task drift, minimizing it. Both capabilities are X11-only, gated behind `//go:build linux`. On any non-Linux target the build links the `//go:build !linux` no-op fallbacks, so the daemon starts and serves its UI but the entire focus loop is dead: no evidence, no drift detection, no nudges, no enforcement. Today's Windows behavior, verified: - `GOOS=windows GOARCH=amd64 go build ./cmd/antidriftd` compiles cleanly (no cgo). - The daemon would launch and open a browser (`cmd/antidriftd/main.go` already handles the `windows` case via `rundll32`). - `evidence.NewSource()` returns a no-op reporting "no active-window sensor on this platform" (`internal/evidence/source_other.go`). - `enforce.NewGuard()` returns a no-op whose `MinimizeActive` does nothing (`internal/enforce/guard_other.go`). This design fills both ports on Windows so AntiDrift functions end-to-end. ## Goals - A real `evidence.Source` on Windows: emit `WindowSnapshot{Title, Class, Health}` on foreground-window and title changes. - A real `enforce.Guard` on Windows: minimize the foreground window on demand. - Pure Go, no cgo. Cross-compilation from Linux stays clean. - No changes to `session`, `web`, `domain`, or `cmd` — the work lives entirely behind the two existing ports. ## Non-goals (YAGNI) - Event-driven sensing via `SetWinEventHook` (see Approach B, rejected below). The `Source` interface hides the polling-vs-event choice; B can replace A later with zero consumer impact if latency ever matters. - Windows ARM64. - Installer, system tray, packaging, autostart. - Any session-policy or web/UI changes. ## Constraints that shaped this design - **Verification is compile-only for now.** Development is on Manjaro Linux with no Windows 11 machine available. The design therefore minimizes untested syscall surface: no callbacks, no Win32 message loop, no OS-thread affinity. Live runtime verification is deferred (see Testing). ## Port contracts being satisfied From `internal/evidence/evidence.go`: ```go type WindowSnapshot struct { Title string // full window title Class string // app identity, matched case-folded against allowed classes Health EvidenceHealth } type Source interface { // Watch runs until ctx is cancelled, invoking onChange on every // active-window change, and once immediately with the current window. Watch(ctx context.Context, onChange func(WindowSnapshot)) } ``` From `internal/enforce/enforce.go`: ```go type Guard interface { // MinimizeActive minimizes the currently-focused window. Idempotent and // best-effort: returns an error for diagnostics; callers never block on it. MinimizeActive(ctx context.Context) error } ``` The `Class` field is the authoritative on-task signal. `MatchesAllowed` (`internal/evidence/context.go`) compares it **case-folded, exact match** against the session's allowed window classes (or matches a title substring). ## Architecture All new code is behind `//go:build windows`. No consumer changes — `session`, `web`, and `cmd/antidriftd` already call `evidence.NewSource()` / `enforce.NewGuard()` and degrade on the health flags. ### New files - `internal/winapi/winapi.go` (`//go:build windows`) — a small shared binding layer over the Win32 calls, so the two adapters don't each redeclare the same procs. - `internal/evidence/windows.go` (`//go:build windows`) — the polling `Source`. - `internal/enforce/windows.go` (`//go:build windows`) — the `ShowWindow` `Guard`. ### Build-tag edit (correctness-critical) The fallbacks are currently tagged `//go:build !linux`, which is what compiles on Windows today. Once `windows.go` files exist in those packages, the fallbacks must exclude Windows too, or the build gets duplicate `NewSource`/`NewGuard` symbols: - `internal/evidence/source_other.go`: `//go:build !linux` → `//go:build !linux && !windows` - `internal/enforce/guard_other.go`: `//go:build !linux` → `//go:build !linux && !windows` This yields three mutually exclusive worlds per port: `linux` (X11), `windows` (new), everything-else (no-op). macOS and any other GOOS remain no-ops, exactly as today. ### Dependencies `golang.org/x/sys` is already in `go.mod` (indirect, v0.41.0). `go mod tidy` promotes it to a direct require. No new module, no cgo. ## Component design ### `internal/winapi` — shared Win32 binding Most of the needed calls are already typed wrappers in `x/sys/windows` (verified under `GOOS=windows go doc`), so the hand-written binding is deliberately tiny. Provided by `golang.org/x/sys/windows`, used directly — no LazyDLL needed: - `GetForegroundWindow() HWND` - `GetWindowThreadProcessId(hwnd HWND, *uint32) (tid uint32, err error)` → owning PID - `OpenProcess`, `QueryFullProcessImageName`, `CloseHandle` → process image path - the `windows.HWND` type and the `windows.SW_MINIMIZE` (= 6) constant Not wrapped by `x/sys/windows`; loaded once via `windows.NewLazySystemDLL("user32.dll")` and called through `proc.Call`: - `GetWindowTextW(hwnd, *uint16, max int32) int32` → title - `ShowWindow(hwnd, SW_MINIMIZE) bool` Public surface (two helpers the adapters consume): - `ForegroundWindow() (hwnd uintptr, title, class string, ok bool)` — resolves the foreground window's title and process-exe-base `class` in one call. `ok` is false when there is no foreground window (null hwnd) — e.g. secure desktop, UAC prompt, lock screen. - `MinimizeForeground() error` — minimizes the current foreground window; returns nil when nothing is focused. `class` is derived from the owning process image path (`QueryFullProcessImageName`) as the **base name minus the `.exe` extension, lowercased**: `C:\Program Files\Microsoft VS Code\Code.exe` → `code`. This is the closest analog to X11 `WM_CLASS` conventions and keeps allowed-class lists readable. Path parsing is a pure function (see Testing). ### `evidence.Source` (Windows) — polling `Watch(ctx, onChange)`: 1. Emit one snapshot immediately (honors the "once immediately" contract). 2. `time.NewTicker(750 * time.Millisecond)`. 3. On each tick, call `winapi.ForegroundWindow()` and track the last `(hwnd, title)`. Invoke `onChange` **only when `hwnd` or `title` changed** — a steady window stays silent, and a same-window title change (e.g. a browser tab switch) still fires. This reproduces the fidelity the X11 source gets from `_NET_ACTIVE_WINDOW` plus name property notifies. 4. Map a successful read to `WindowSnapshot{Title: title, Class: class, Health: {Available: true}}`. 5. Map `ok == false` to `WindowSnapshot{Health: {Available: false, Reason: "no foreground window"}}`. Recovers automatically on the next tick. 6. On `ctx.Done()`, stop the ticker and return. Nothing else to tear down. Rationale for polling over `SetWinEventHook`: under compile-only verification, the responsible choice is the design with no callback trampoline (`syscall.NewCallback`), no `GetMessage`/`DispatchMessage` loop, and no `runtime.LockOSThread` — three failure modes that are hard to validate without a Windows machine. ~1s switch latency is irrelevant for a focus tracker, and polling also catches in-window title changes for free. ### `enforce.Guard` (Windows) `MinimizeActive(ctx)`: 1. Read the foreground window. If null, return nil (nothing focused — same as the X11 `active == 0` case). 2. Otherwise `ShowWindow(hwnd, SW_MINIMIZE)`; on failure return a wrapped error. Per-call and stateless, mirroring the short-lived X11 connection model. Errors are for the caller to log; the caller never blocks on enforcement. ## Error handling and degradation The existing contracts are unchanged; this design simply honors them on Windows. - Sensor can't read a foreground window → `Available: false` snapshot with a reason. The daemon already treats this as "no evidence this tick" and surfaces the reason in the live view. Auto-recovers on the next tick. - `ShowWindow` fails → error returned and logged upstream; enforcement is best-effort, treated as "did nothing this time." No panic, no block. - No new failure types reach `session`/`web`: they still see only a `WindowSnapshot` and a possible `MinimizeActive` error, exactly as with X11. ## Known cross-platform nuance (not a bug) The same application can produce a different `Class` on Linux vs Windows (X11 `WM_CLASS` vs Windows exe base name; e.g. Chrome may be `google-chrome` on Linux and `chrome` on Windows). Allowed-class lists are per-session and case-folded, so this only affects portability of class names *across machines*, never matching within a single session. Documented, not engineered around. ## Testing and verification Bounded honestly by the compile-only constraint. 1. **Cross-compilation gate (primary automated guarantee).** `GOOS=windows GOARCH=amd64 go build ./...` must pass. Also build `GOOS=darwin` to prove the `!linux && !windows` tag edit still selects the no-op fallback (i.e. the everything-else world is intact). 2. **Pure-logic unit tests, no build tag (run on Linux).** The syscall-free logic is extracted into pure functions and tested directly: - exe-path → class normalization: `Code.exe` → `code`, strip `.exe`, lowercase, handle no-extension names and UNC/odd separators. - change-detection predicate: emit on `(hwnd, title)` change, stay silent otherwise. 3. **No fabricated syscall mocks.** We will not write a fake that pretends to exercise `user32` — that produces false confidence. Live verification is deferred until a Windows 11 machine is available. 4. **`x11_integration_test.go` files stay `//go:build linux`**, untouched. ### Deferred manual verification (when a Windows 11 machine is available) - Run `antidriftd`; confirm the browser opens and the live view shows the current window title/class. - Switch windows and change a browser tab; confirm snapshots update and the health reads available. - Start a commitment with "Enforce focus" armed; focus an off-task window; confirm the drift judge fires and the window minimizes. ## Out of scope (restated) `SetWinEventHook`, Windows ARM64, tray/installer/packaging/autostart, and any session/web/domain changes. This is pure adapter work behind two existing ports.