Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91a3fb29c2 | |||
| 9546999acb | |||
| d45a01eca5 | |||
| b6c7933f16 | |||
| f37e295166 | |||
| d149736946 | |||
| 0daf8c2849 | |||
| 0f47d2dbf0 | |||
| 0ac1511aac | |||
| 7d62af0750 | |||
| e1194be3c2 | |||
| f79c149039 | |||
| 4ea8aed04b | |||
| 885b0f9224 | |||
| e0ea1eca83 |
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jezek/xgb v1.3.1
|
github.com/jezek/xgb v1.3.1
|
||||||
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a
|
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a
|
||||||
|
golang.org/x/sys v0.41.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -36,7 +37,6 @@ require (
|
|||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !linux
|
//go:build !linux && !windows
|
||||||
|
|
||||||
package enforce
|
package enforce
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//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,
|
||||||
|
// with no connection or shared state to manage. ShowWindow does not report a
|
||||||
|
// minimize failure, so this always returns nil today.
|
||||||
|
func (windowsGuard) MinimizeActive(_ context.Context) error {
|
||||||
|
return winapi.MinimizeForeground()
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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.
|
||||||
|
// Callers pass the zero values (hwnd 0, title "") when available is false, so a
|
||||||
|
// steady "no foreground window" run does not re-emit.
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !linux
|
//go:build !linux && !windows
|
||||||
|
|
||||||
package evidence
|
package evidence
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
|
// unavailable builds an Unavailable snapshot and logs the reason. It is shared
|
||||||
|
// by every platform sensor so an unobservable window is recorded consistently.
|
||||||
|
func unavailable(reason string) WindowSnapshot {
|
||||||
|
log.Printf("evidence: %s", reason)
|
||||||
|
return WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: reason}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//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(unavailable("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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ package evidence
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/jezek/xgb/xproto"
|
"github.com/jezek/xgb/xproto"
|
||||||
"github.com/jezek/xgbutil"
|
"github.com/jezek/xgbutil"
|
||||||
@@ -88,7 +87,3 @@ func snapshot(X *xgbutil.XUtil) WindowSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func unavailable(reason string) WindowSnapshot {
|
|
||||||
log.Printf("evidence: %s", reason)
|
|
||||||
return WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: reason}}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// 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:]
|
||||||
|
}
|
||||||
|
const exeSuffix = ".exe"
|
||||||
|
if len(p) >= len(exeSuffix) && strings.EqualFold(p[len(p)-len(exeSuffix):], exeSuffix) {
|
||||||
|
p = p[:len(p)-len(exeSuffix)]
|
||||||
|
}
|
||||||
|
return strings.ToLower(p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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"},
|
||||||
|
{``, ""},
|
||||||
|
{`.exe`, ""}, // degenerate: only the extension
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := ClassFromImagePath(c.in); got != c.want {
|
||||||
|
t.Errorf("ClassFromImagePath(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
//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 GetForegroundWindow returns null, which happens transiently during focus
|
||||||
|
// changes and some secure-desktop transitions (UAC / 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 {
|
||||||
|
// 512 uint16s; titles over 511 chars are truncated — acceptable for a sensor tag.
|
||||||
|
const max = 512
|
||||||
|
buf := make([]uint16, max)
|
||||||
|
n, _, _ := procGetWindowTextW.Call(
|
||||||
|
uintptr(h),
|
||||||
|
uintptr(unsafe.Pointer(&buf[0])),
|
||||||
|
uintptr(max),
|
||||||
|
)
|
||||||
|
// n==0 covers both call failure and a genuinely empty title; both are fine here.
|
||||||
|
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))
|
||||||
|
// MAX_PATH suffices for almost all paths; an over-long path fails here and
|
||||||
|
// degrades windowClass to "" (window left unclassified), never a crash.
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user