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>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user