Files
antidrift/docs/superpowers/specs/2026-06-02-windows-support-design.md
T
felixm e0ea1eca83 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) <noreply@anthropic.com>
2026-06-02 11:35:39 -04:00

10 KiB

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:

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:

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.execode. 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.execode, 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.