Files
antidrift/docs/superpowers/plans/2026-06-02-windows-support.md
felixm 885b0f9224 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) <noreply@anthropic.com>
2026-06-02 12:08:01 -04:00

549 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 45); `go mod tidy` promotion (Task 3); pure-logic TDD for class + change-detection (Tasks 12); 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`.