Add Windows 11 support behind evidence.Source and enforce.Guard ports
Polling active-window sensor and window-minimize guard as //go:build windows adapters over a shared internal/winapi Win32 binding. Pure logic (class normalization, emit-on-change tracking) is TDD-tested on Linux; syscall code is cross-compile verified. No consumer changes.
This commit is contained in:
@@ -7,6 +7,7 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jezek/xgb v1.3.1
|
||||
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a
|
||||
golang.org/x/sys v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -36,7 +37,6 @@ require (
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.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
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !linux
|
||||
//go:build !linux && !windows
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/jezek/xgb/xproto"
|
||||
"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