From e0ea1eca8333407ee28a4c35683213927e4d1818 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Tue, 2 Jun 2026 11:35:39 -0400 Subject: [PATCH] Spec Windows 11 support via windows-tagged port adapters Full-parity design: evidence.Source (polling) and enforce.Guard (ShowWindow) behind the existing X11/no-op port boundaries, pure Go, verified compile-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-02-windows-support-design.md | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-windows-support-design.md diff --git a/docs/superpowers/specs/2026-06-02-windows-support-design.md b/docs/superpowers/specs/2026-06-02-windows-support-design.md new file mode 100644 index 0000000..26e3ae7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-windows-support-design.md @@ -0,0 +1,246 @@ +# 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.