From 885b0f92240fe68b0f78cf2aa5a6cbfcbde299d8 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Tue, 2 Jun 2026 12:08:01 -0400 Subject: [PATCH] Plan Windows 11 support implementation Six TDD/cross-compile tasks: pure class normalization and emit-on-change tracker (unit-tested on Linux), Win32 binding, the two windows-tagged port adapters, build-tag narrowing, and a whole-module cross-compile gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-02-windows-support.md | 548 ++++++++++++++++++ 1 file changed, 548 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-windows-support.md diff --git a/docs/superpowers/plans/2026-06-02-windows-support.md b/docs/superpowers/plans/2026-06-02-windows-support.md new file mode 100644 index 0000000..cc8d755 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-windows-support.md @@ -0,0 +1,548 @@ +# Windows 11 Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make AntiDrift function end-to-end on Windows 11 by adding `//go:build windows` adapters behind the existing `evidence.Source` and `enforce.Guard` ports — active-window sensing (polling) and window-minimize enforcement — with no consumer code changes. + +**Architecture:** Two new windows-tagged adapters call a small shared Win32 binding (`internal/winapi`). The syscall-free logic (process-path → class normalization, emit-on-change tracking) is extracted into untagged files so it is unit-tested on Linux via TDD. The syscall-bound code is verified by cross-compilation only (no Windows machine is available). The current `//go:build !linux` no-op fallbacks are narrowed to `//go:build !linux && !windows` so Windows links the real adapters while macOS/other stay no-ops. + +**Tech Stack:** Go 1.26, `golang.org/x/sys/windows` (already an indirect dep; promoted to direct), pure Go (no cgo). Win32 calls: `GetForegroundWindow`, `GetWindowThreadProcessId`, `OpenProcess`/`QueryFullProcessImageName`/`CloseHandle` (all typed in `x/sys/windows`), plus `GetWindowTextW` and `ShowWindow` via `windows.NewLazySystemDLL("user32.dll")`. + +**Reference spec:** `docs/superpowers/specs/2026-06-02-windows-support-design.md` + +--- + +## File structure + +- Create `internal/winapi/class.go` — untagged — `ClassFromImagePath` (pure: image path → class string). Builds on all platforms; this is the only file that makes package `winapi` compile on Linux. +- Create `internal/winapi/class_test.go` — untagged — table test for `ClassFromImagePath` (runs on Linux). +- Create `internal/winapi/winapi.go` — `//go:build windows` — `ForegroundWindow()` and `MinimizeForeground()` over the Win32 calls. +- Create `internal/evidence/foreground_tracker.go` — untagged — `foregroundTracker.changed` (pure emit-on-change predicate). Runs on Linux. +- Create `internal/evidence/foreground_tracker_test.go` — untagged — tests for the tracker. +- Create `internal/evidence/windows.go` — `//go:build windows` — polling `Source` (`NewSource`, `windowsSource.Watch`). +- Create `internal/enforce/windows.go` — `//go:build windows` — `Guard` (`NewGuard`, `windowsGuard.MinimizeActive`). +- Modify `internal/evidence/source_other.go:1` — build tag `//go:build !linux` → `//go:build !linux && !windows`. +- Modify `internal/enforce/guard_other.go:1` — build tag `//go:build !linux` → `//go:build !linux && !windows`. +- Modify `go.mod` — `go mod tidy` promotes `golang.org/x/sys` from indirect to direct. + +No changes to `session`, `web`, `domain`, or `cmd` — they already consume the ports and degrade on `Health`. + +--- + +## Task 1: Pure class-name normalization (TDD on Linux) + +`ClassFromImagePath` turns a Windows process image path into the on-task class +identity: base file name, trailing `.exe` removed (case-insensitive), +lowercased. It must be correct when the test runs on Linux, so it parses +separators itself rather than using OS-dependent `path/filepath`. + +**Files:** +- Create: `internal/winapi/class.go` +- Test: `internal/winapi/class_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/winapi/class_test.go`: + +```go +package winapi + +import "testing" + +func TestClassFromImagePath(t *testing.T) { + cases := []struct{ in, want string }{ + {`C:\Program Files\Microsoft VS Code\Code.exe`, "code"}, + {`C:\Windows\explorer.exe`, "explorer"}, + {`chrome.exe`, "chrome"}, + {`C:\x\FOO.EXE`, "foo"}, + {`C:\x\My.App.exe`, "my.app"}, + {`C:/forward/slash/Code.exe`, "code"}, + {`firefox`, "firefox"}, + {``, ""}, + } + for _, c := range cases { + if got := ClassFromImagePath(c.in); got != c.want { + t.Errorf("ClassFromImagePath(%q) = %q, want %q", c.in, got, c.want) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/winapi/` +Expected: FAIL — build error, `undefined: ClassFromImagePath`. + +- [ ] **Step 3: Write minimal implementation** + +Create `internal/winapi/class.go`: + +```go +// Package winapi is the Windows Win32 binding layer for AntiDrift's OS ports. +// The syscall-bound code lives in windows-tagged files; this untagged file +// holds the pure path logic so it builds and is tested on every platform. +package winapi + +import "strings" + +// ClassFromImagePath derives the on-task class identity from a process image +// path: the base file name with any trailing ".exe" removed (case-insensitive), +// lowercased. It is the Windows analog of an X11 WM_CLASS. It parses both `\` +// and `/` separators so it is correct regardless of the host OS running the +// test. Returns "" for an empty path. +func ClassFromImagePath(p string) string { + if i := strings.LastIndexAny(p, `\/`); i >= 0 { + p = p[i+1:] + } + if len(p) >= 4 && strings.EqualFold(p[len(p)-4:], ".exe") { + p = p[:len(p)-4] + } + return strings.ToLower(p) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/winapi/` +Expected: PASS (`ok antidrift/internal/winapi`). + +- [ ] **Step 5: Commit** + +```bash +git add internal/winapi/class.go internal/winapi/class_test.go +git commit -m "Add pure process-path to class normalization for Windows" +``` + +--- + +## Task 2: Emit-on-change tracker (TDD on Linux) + +The X11 source is event-driven; the Windows source polls. `foregroundTracker` +reproduces "emit only on change" for the poll loop: it remembers the last +observation and reports whether the new one differs. It tracks availability too, +so a steady "no foreground window" run does not re-emit every tick. + +**Files:** +- Create: `internal/evidence/foreground_tracker.go` +- Test: `internal/evidence/foreground_tracker_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/evidence/foreground_tracker_test.go`: + +```go +package evidence + +import "testing" + +func TestForegroundTrackerChanged(t *testing.T) { + var tr foregroundTracker + + if !tr.changed(true, 100, "A") { + t.Fatal("first observation should always report changed") + } + if tr.changed(true, 100, "A") { + t.Error("identical observation should not report changed") + } + if !tr.changed(true, 100, "B") { + t.Error("title change on same hwnd should report changed") + } + if !tr.changed(true, 200, "B") { + t.Error("hwnd change should report changed") + } + if !tr.changed(false, 0, "") { + t.Error("transition to unavailable should report changed") + } + if tr.changed(false, 0, "") { + t.Error("repeated unavailable should not report changed") + } + if !tr.changed(true, 200, "B") { + t.Error("transition back to available should report changed") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/evidence/ -run TestForegroundTracker` +Expected: FAIL — build error, `undefined: foregroundTracker`. + +- [ ] **Step 3: Write minimal implementation** + +Create `internal/evidence/foreground_tracker.go`: + +```go +package evidence + +// foregroundTracker remembers the last observed foreground window so the +// Windows polling Source emits only on change. hwnd is held as uintptr so this +// file stays platform-neutral (it must build and test on Linux). +type foregroundTracker struct { + primed bool + available bool + hwnd uintptr + title string +} + +// changed reports whether (available, hwnd, title) differs from the last +// observation and records the new values. The first call always returns true. +func (t *foregroundTracker) changed(available bool, hwnd uintptr, title string) bool { + if t.primed && available == t.available && hwnd == t.hwnd && title == t.title { + return false + } + t.primed, t.available, t.hwnd, t.title = true, available, hwnd, title + return true +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/evidence/ -run TestForegroundTracker` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/evidence/foreground_tracker.go internal/evidence/foreground_tracker_test.go +git commit -m "Add emit-on-change tracker for the Windows polling sensor" +``` + +--- + +## Task 3: Win32 binding layer (cross-compile verified) + +The syscall surface. Cannot be unit-tested without Windows; verified by +cross-compilation. All referenced `x/sys/windows` symbols were confirmed present +via `GOOS=windows go doc` while writing the spec. + +**Files:** +- Create: `internal/winapi/winapi.go` +- Modify: `go.mod` (via `go mod tidy`) + +- [ ] **Step 1: Write the implementation** + +Create `internal/winapi/winapi.go`: + +```go +//go:build windows + +package winapi + +import ( + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + user32 = windows.NewLazySystemDLL("user32.dll") + procGetWindowTextW = user32.NewProc("GetWindowTextW") + procShowWindow = user32.NewProc("ShowWindow") +) + +// ForegroundWindow returns the current foreground window's handle (as a uintptr +// so platform-neutral callers need not import windows), its title, and its +// on-task class (process exe base name; see ClassFromImagePath). ok is false +// when there is no foreground window (e.g. secure desktop / lock screen). +func ForegroundWindow() (hwnd uintptr, title, class string, ok bool) { + h := windows.GetForegroundWindow() + if h == 0 { + return 0, "", "", false + } + return uintptr(h), windowTitle(h), windowClass(h), true +} + +func windowTitle(h windows.HWND) string { + const max = 512 + buf := make([]uint16, max) + n, _, _ := procGetWindowTextW.Call( + uintptr(h), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(max), + ) + return windows.UTF16ToString(buf[:n]) +} + +func windowClass(h windows.HWND) string { + var pid uint32 + if _, err := windows.GetWindowThreadProcessId(h, &pid); err != nil || pid == 0 { + return "" + } + proc, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + return "" + } + defer windows.CloseHandle(proc) + + buf := make([]uint16, windows.MAX_PATH) + size := uint32(len(buf)) + if err := windows.QueryFullProcessImageName(proc, 0, &buf[0], &size); err != nil { + return "" + } + return ClassFromImagePath(windows.UTF16ToString(buf[:size])) +} + +// MinimizeForeground minimizes the current foreground window. It returns nil +// when nothing is focused. ShowWindow's BOOL return reports prior visibility, +// not success, so there is nothing to error-check; minimize is best-effort by +// the Guard contract. +func MinimizeForeground() error { + h := windows.GetForegroundWindow() + if h == 0 { + return nil + } + procShowWindow.Call(uintptr(h), uintptr(windows.SW_MINIMIZE)) + return nil +} +``` + +- [ ] **Step 2: Promote the dependency and verify the package cross-compiles** + +Run: +```bash +go mod tidy +GOOS=windows GOARCH=amd64 go build ./internal/winapi/ +``` +Expected: both succeed with no output. `go.mod` now lists `golang.org/x/sys` in the direct (non-`// indirect`) require block. + +- [ ] **Step 3: Verify the package still builds on Linux (pure file only)** + +Run: `go test ./internal/winapi/` +Expected: PASS — on Linux the package is just `class.go` + its test; `winapi.go` is excluded by its build tag. + +- [ ] **Step 4: Commit** + +```bash +git add internal/winapi/winapi.go go.mod go.sum +git commit -m "Add Win32 binding for foreground window and minimize" +``` + +--- + +## Task 4: Windows evidence.Source adapter (cross-compile verified) + +The polling sensor. Emits one snapshot immediately, then on every 750ms tick +emits only when the foreground window or title changed (via `foregroundTracker` +from Task 2 and `winapi.ForegroundWindow` from Task 3). Also narrows the +non-Linux fallback so it no longer claims Windows. + +**Files:** +- Create: `internal/evidence/windows.go` +- Modify: `internal/evidence/source_other.go:1` + +- [ ] **Step 1: Narrow the fallback build tag** + +In `internal/evidence/source_other.go`, change the first line: + +```go +//go:build !linux && !windows +``` + +(from `//go:build !linux`). Leave the rest of the file unchanged. + +- [ ] **Step 2: Write the Windows source** + +Create `internal/evidence/windows.go`: + +```go +//go:build windows + +package evidence + +import ( + "context" + "time" + + "antidrift/internal/winapi" +) + +// pollInterval is how often the Windows sensor samples the foreground window. +// ~1s latency on a window switch is immaterial for a focus tracker, and polling +// avoids the message-loop/callback machinery a SetWinEventHook source needs. +const pollInterval = 750 * time.Millisecond + +// NewSource returns the Windows active-window sensor (polling). +func NewSource() Source { return windowsSource{} } + +type windowsSource struct{} + +// Watch emits the current window immediately, then samples every pollInterval, +// emitting only when the foreground window or its title changes. A read with no +// foreground window yields an Unavailable snapshot (once, until it recovers). It +// runs until ctx is cancelled. It never panics the daemon. +func (windowsSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) { + var tr foregroundTracker + poll := func() { + hwnd, title, class, ok := winapi.ForegroundWindow() + if !tr.changed(ok, hwnd, title) { + return + } + if !ok { + onChange(WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: "no foreground window"}}) + return + } + onChange(WindowSnapshot{Title: title, Class: class, Health: EvidenceHealth{Available: true}}) + } + + poll() // immediate current window + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + poll() + } + } +} +``` + +- [ ] **Step 3: Verify Windows build and Linux tests** + +Run: +```bash +GOOS=windows GOARCH=amd64 go build ./internal/evidence/ +go test ./internal/evidence/ +``` +Expected: the Windows build succeeds with no output; the Linux test run passes (tracker test included, X11/other files unaffected). + +- [ ] **Step 4: Verify the fallback world is intact (macOS still no-op)** + +Run: `GOOS=darwin GOARCH=amd64 go build ./internal/evidence/` +Expected: succeeds — `source_other.go` (`!linux && !windows`) still provides `NewSource` on darwin. + +- [ ] **Step 5: Commit** + +```bash +git add internal/evidence/windows.go internal/evidence/source_other.go +git commit -m "Add Windows polling active-window sensor" +``` + +--- + +## Task 5: Windows enforce.Guard adapter (cross-compile verified) + +The minimize guard. Thin wrapper over `winapi.MinimizeForeground`. Narrows the +non-Linux fallback the same way as Task 4. + +**Files:** +- Create: `internal/enforce/windows.go` +- Modify: `internal/enforce/guard_other.go:1` + +- [ ] **Step 1: Narrow the fallback build tag** + +In `internal/enforce/guard_other.go`, change the first line: + +```go +//go:build !linux && !windows +``` + +(from `//go:build !linux`). Leave the rest of the file unchanged. + +- [ ] **Step 2: Write the Windows guard** + +Create `internal/enforce/windows.go`: + +```go +//go:build windows + +package enforce + +import ( + "context" + + "antidrift/internal/winapi" +) + +// NewGuard returns the Windows window-minimize guard. +func NewGuard() Guard { return windowsGuard{} } + +type windowsGuard struct{} + +// MinimizeActive minimizes the current foreground window. It is best-effort: +// with nothing focused it does nothing and returns nil. Stateless and per-call, +// mirroring the short-lived X11 connection model. +func (windowsGuard) MinimizeActive(context.Context) error { + return winapi.MinimizeForeground() +} +``` + +- [ ] **Step 3: Verify Windows build and the fallback world** + +Run: +```bash +GOOS=windows GOARCH=amd64 go build ./internal/enforce/ +GOOS=darwin GOARCH=amd64 go build ./internal/enforce/ +go build ./internal/enforce/ +``` +Expected: all three succeed with no output (Windows uses `windows.go`; darwin and Linux-host build paths still resolve `NewGuard`). + +- [ ] **Step 4: Commit** + +```bash +git add internal/enforce/windows.go internal/enforce/guard_other.go +git commit -m "Add Windows window-minimize guard" +``` + +--- + +## Task 6: Full cross-compilation gate and regression check + +The primary automated guarantee for the syscall-bound code: the whole module +cross-compiles for Windows, the fallback world is intact for macOS, and the +Linux host build and full test suite are still green. + +**Files:** none (verification only). + +- [ ] **Step 1: Whole-module Windows cross-compile** + +Run: `GOOS=windows GOARCH=amd64 go build ./...` +Expected: succeeds with no output. (This is the headline check: `cmd/antidriftd`, `evidence`, `enforce`, `winapi`, and every consumer link for Windows.) + +- [ ] **Step 2: Whole-module macOS cross-compile (fallback intact)** + +Run: `GOOS=darwin GOARCH=amd64 go build ./...` +Expected: succeeds with no output — proves the `!linux && !windows` tag edits did not orphan `NewSource`/`NewGuard` on other platforms. + +- [ ] **Step 3: Linux host build and full test suite** + +Run: +```bash +go build ./... +go test ./... +``` +Expected: build succeeds; all tests pass, including the new `winapi` and `evidence` pure-logic tests, with the existing Linux X11 integration tests unaffected. + +- [ ] **Step 4: Confirm the Windows binary actually links (optional sanity)** + +Run: `GOOS=windows GOARCH=amd64 go build -o /tmp/antidriftd.exe ./cmd/antidriftd && ls -l /tmp/antidriftd.exe` +Expected: a `.exe` is produced. + +- [ ] **Step 5: Commit (if `go mod tidy` left any tidy changes)** + +```bash +git add -A +git commit -m "Verify Windows cross-compile and Linux regression for Windows support" --allow-empty +``` + +--- + +## Deferred manual verification (when a Windows 11 machine is available) + +Not part of this plan's automated gates — recorded for whoever runs it on real +hardware later (from the spec): + +- Run `antidriftd`; confirm the browser opens and the live view shows the + current window title and class. +- Switch windows and change a browser tab; confirm snapshots update and health + reads available. +- Start a commitment with "Enforce focus" armed; focus an off-task window; + confirm the drift judge fires and the window minimizes. + +--- + +## Self-review notes + +- **Spec coverage:** both ports (Task 4 sensor, Task 5 guard); `winapi` binding (Task 3); build-tag narrowing (Tasks 4–5); `go mod tidy` promotion (Task 3); pure-logic TDD for class + change-detection (Tasks 1–2); cross-compile + darwin-fallback + Linux-regression gates (Task 6); deferred manual steps recorded. No fabricated syscall mocks. The `Class` = exe-base-name decision is implemented in Task 1. +- **Type consistency:** `ClassFromImagePath` (Tasks 1, 3); `foregroundTracker.changed(available, hwnd, title)` with `hwnd uintptr` (Tasks 2, 4); `winapi.ForegroundWindow() (uintptr, string, string, bool)` and `winapi.MinimizeForeground() error` (Tasks 3, 4, 5). Module path `antidrift` confirmed against `go.mod`.