From 4ea8aed04bcf4d61306cdd722f1b5f30a88720d5 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Tue, 2 Jun 2026 12:18:59 -0400 Subject: [PATCH 01/12] Add pure process-path to class normalization for Windows --- internal/winapi/class.go | 21 +++++++++++++++++++++ internal/winapi/class_test.go | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 internal/winapi/class.go create mode 100644 internal/winapi/class_test.go diff --git a/internal/winapi/class.go b/internal/winapi/class.go new file mode 100644 index 0000000..4a26deb --- /dev/null +++ b/internal/winapi/class.go @@ -0,0 +1,21 @@ +// 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) +} diff --git a/internal/winapi/class_test.go b/internal/winapi/class_test.go new file mode 100644 index 0000000..a1d9a4b --- /dev/null +++ b/internal/winapi/class_test.go @@ -0,0 +1,21 @@ +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) + } + } +} From f79c149039270aca8f0371dec71244db89e27423 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Tue, 2 Jun 2026 12:21:12 -0400 Subject: [PATCH 02/12] Clarify .exe suffix handling in class normalization --- internal/winapi/class.go | 5 +++-- internal/winapi/class_test.go | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/winapi/class.go b/internal/winapi/class.go index 4a26deb..ef82147 100644 --- a/internal/winapi/class.go +++ b/internal/winapi/class.go @@ -14,8 +14,9 @@ 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] + 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) } diff --git a/internal/winapi/class_test.go b/internal/winapi/class_test.go index a1d9a4b..d28f5ef 100644 --- a/internal/winapi/class_test.go +++ b/internal/winapi/class_test.go @@ -12,6 +12,7 @@ func TestClassFromImagePath(t *testing.T) { {`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 { From e1194be3c26a188c6e67090a3cd1b9e40271fa5d Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Tue, 2 Jun 2026 12:21:58 -0400 Subject: [PATCH 03/12] Add emit-on-change tracker for the Windows polling sensor --- internal/evidence/foreground_tracker.go | 21 ++++++++++++++ internal/evidence/foreground_tracker_test.go | 29 ++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 internal/evidence/foreground_tracker.go create mode 100644 internal/evidence/foreground_tracker_test.go diff --git a/internal/evidence/foreground_tracker.go b/internal/evidence/foreground_tracker.go new file mode 100644 index 0000000..7afcbdb --- /dev/null +++ b/internal/evidence/foreground_tracker.go @@ -0,0 +1,21 @@ +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 +} diff --git a/internal/evidence/foreground_tracker_test.go b/internal/evidence/foreground_tracker_test.go new file mode 100644 index 0000000..b664238 --- /dev/null +++ b/internal/evidence/foreground_tracker_test.go @@ -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") + } +} From 7d62af0750c107c7ab33d746e0da0600d7784007 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Tue, 2 Jun 2026 12:23:54 -0400 Subject: [PATCH 04/12] Document foregroundTracker unavailable-state contract Co-Authored-By: Claude Sonnet 4.6 --- internal/evidence/foreground_tracker.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/evidence/foreground_tracker.go b/internal/evidence/foreground_tracker.go index 7afcbdb..44a6f0b 100644 --- a/internal/evidence/foreground_tracker.go +++ b/internal/evidence/foreground_tracker.go @@ -12,6 +12,8 @@ type foregroundTracker struct { // 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 From 0ac1511aacf2660671bffcd1eca4f0c8e9047d09 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Tue, 2 Jun 2026 12:24:55 -0400 Subject: [PATCH 05/12] Add Win32 binding for foreground window and minimize --- go.mod | 2 +- internal/winapi/winapi.go | 70 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 internal/winapi/winapi.go diff --git a/go.mod b/go.mod index 171f9d1..57984ef 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/internal/winapi/winapi.go b/internal/winapi/winapi.go new file mode 100644 index 0000000..ffea79c --- /dev/null +++ b/internal/winapi/winapi.go @@ -0,0 +1,70 @@ +//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 +} From 0f47d2dbf07347546ce7044267143e3c5408561a Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Tue, 2 Jun 2026 12:29:07 -0400 Subject: [PATCH 06/12] Document title/path truncation behavior in Win32 binding --- internal/winapi/winapi.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/winapi/winapi.go b/internal/winapi/winapi.go index ffea79c..b0c5513 100644 --- a/internal/winapi/winapi.go +++ b/internal/winapi/winapi.go @@ -27,6 +27,7 @@ func ForegroundWindow() (hwnd uintptr, title, class string, ok bool) { } 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( @@ -34,6 +35,7 @@ func windowTitle(h windows.HWND) string { 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]) } @@ -50,6 +52,8 @@ func windowClass(h windows.HWND) string { 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 "" } From 0daf8c2849844ac66bcea8349b9c733f98feff1e Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Tue, 2 Jun 2026 12:30:16 -0400 Subject: [PATCH 07/12] Add Windows polling active-window sensor --- internal/evidence/source_other.go | 2 +- internal/evidence/windows.go | 51 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 internal/evidence/windows.go diff --git a/internal/evidence/source_other.go b/internal/evidence/source_other.go index d9edd74..5cec967 100644 --- a/internal/evidence/source_other.go +++ b/internal/evidence/source_other.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build !linux && !windows package evidence diff --git a/internal/evidence/windows.go b/internal/evidence/windows.go new file mode 100644 index 0000000..79aae4f --- /dev/null +++ b/internal/evidence/windows.go @@ -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(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() + } + } +} From d1497369463b43f95c9a7fd5622f1e7597ae6e68 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Tue, 2 Jun 2026 12:33:23 -0400 Subject: [PATCH 08/12] Share unavailable() so the Windows sensor logs unavailable transitions --- internal/evidence/unavailable.go | 10 ++++++++++ internal/evidence/windows.go | 2 +- internal/evidence/x11.go | 5 ----- 3 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 internal/evidence/unavailable.go diff --git a/internal/evidence/unavailable.go b/internal/evidence/unavailable.go new file mode 100644 index 0000000..927804e --- /dev/null +++ b/internal/evidence/unavailable.go @@ -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}} +} diff --git a/internal/evidence/windows.go b/internal/evidence/windows.go index 79aae4f..8f597b1 100644 --- a/internal/evidence/windows.go +++ b/internal/evidence/windows.go @@ -31,7 +31,7 @@ func (windowsSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) { return } if !ok { - onChange(WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: "no foreground window"}}) + onChange(unavailable("no foreground window")) return } onChange(WindowSnapshot{Title: title, Class: class, Health: EvidenceHealth{Available: true}}) diff --git a/internal/evidence/x11.go b/internal/evidence/x11.go index 7c972e0..cbf1ace 100644 --- a/internal/evidence/x11.go +++ b/internal/evidence/x11.go @@ -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}} -} From f37e295166f88ac6dbf1305363363db4b2d152c5 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Tue, 2 Jun 2026 12:34:14 -0400 Subject: [PATCH 09/12] Add Windows window-minimize guard --- internal/enforce/guard_other.go | 2 +- internal/enforce/windows.go | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 internal/enforce/windows.go diff --git a/internal/enforce/guard_other.go b/internal/enforce/guard_other.go index d009114..7405110 100644 --- a/internal/enforce/guard_other.go +++ b/internal/enforce/guard_other.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build !linux && !windows package enforce diff --git a/internal/enforce/windows.go b/internal/enforce/windows.go new file mode 100644 index 0000000..5dc4444 --- /dev/null +++ b/internal/enforce/windows.go @@ -0,0 +1,21 @@ +//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() +} From b6c7933f16603b8a8071b38cefe7e6f717a3f38f Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Tue, 2 Jun 2026 12:36:12 -0400 Subject: [PATCH 10/12] Align Windows guard ctx param and doc with X11 guard --- internal/enforce/windows.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/enforce/windows.go b/internal/enforce/windows.go index 5dc4444..36ac3e5 100644 --- a/internal/enforce/windows.go +++ b/internal/enforce/windows.go @@ -15,7 +15,8 @@ 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 { +// 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() } From 9546999acb0789a7997fcb067d77a59b00f43b6f Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Tue, 2 Jun 2026 12:39:32 -0400 Subject: [PATCH 12/12] Clarify null-HWND doc note in ForegroundWindow --- internal/winapi/winapi.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/winapi/winapi.go b/internal/winapi/winapi.go index b0c5513..6282306 100644 --- a/internal/winapi/winapi.go +++ b/internal/winapi/winapi.go @@ -17,7 +17,8 @@ var ( // 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). +// 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 {