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>
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/antidriftdcompiles cleanly (no cgo).- The daemon would launch and open a browser (
cmd/antidriftd/main.goalready handles thewindowscase viarundll32). 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 whoseMinimizeActivedoes nothing (internal/enforce/guard_other.go).
This design fills both ports on Windows so AntiDrift functions end-to-end.
Goals
- A real
evidence.Sourceon Windows: emitWindowSnapshot{Title, Class, Health}on foreground-window and title changes. - A real
enforce.Guardon Windows: minimize the foreground window on demand. - Pure Go, no cgo. Cross-compilation from Linux stays clean.
- No changes to
session,web,domain, orcmd— the work lives entirely behind the two existing ports.
Non-goals (YAGNI)
- Event-driven sensing via
SetWinEventHook(see Approach B, rejected below). TheSourceinterface 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 pollingSource.internal/enforce/windows.go(//go:build windows) — theShowWindowGuard.
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 && !windowsinternal/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() HWNDGetWindowThreadProcessId(hwnd HWND, *uint32) (tid uint32, err error)→ owning PIDOpenProcess,QueryFullProcessImageName,CloseHandle→ process image path- the
windows.HWNDtype and thewindows.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→ titleShowWindow(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-baseclassin one call.okis 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):
- Emit one snapshot immediately (honors the "once immediately" contract).
time.NewTicker(750 * time.Millisecond).- On each tick, call
winapi.ForegroundWindow()and track the last(hwnd, title). InvokeonChangeonly whenhwndortitlechanged — 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_WINDOWplus name property notifies. - Map a successful read to
WindowSnapshot{Title: title, Class: class, Health: {Available: true}}. - Map
ok == falsetoWindowSnapshot{Health: {Available: false, Reason: "no foreground window"}}. Recovers automatically on the next tick. - 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):
- Read the foreground window. If null, return nil (nothing focused — same as
the X11
active == 0case). - 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: falsesnapshot 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. ShowWindowfails → 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 aWindowSnapshotand a possibleMinimizeActiveerror, 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.
- Cross-compilation gate (primary automated guarantee).
GOOS=windows GOARCH=amd64 go build ./...must pass. Also buildGOOS=darwinto prove the!linux && !windowstag edit still selects the no-op fallback (i.e. the everything-else world is intact). - 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.
- exe-path → class normalization:
- 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. x11_integration_test.gofiles 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.