fix(evidence): X11 sensor polls so tab/title changes are tracked

The X11 source only re-read the active window on _NET_ACTIVE_WINDOW
changes, so switching a browser tab — which changes the window title
but not the active window — was invisible. All the time on the new tab
was credited to the stale title (e.g. reading a Consume article showed
up as time on the Keel tab).

Mirror the Windows sensor: poll the active window every 750ms and emit
only when the window or its title changes. A shared, display-free
pollLoop carries the change-dedup and is unit-tested for the exact
regression (a title-only change must emit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 20:32:24 -04:00
parent 6d296bf743
commit 682eb603fa
4 changed files with 157 additions and 39 deletions
+3 -2
View File
@@ -24,8 +24,9 @@ type WindowSnapshot struct {
} }
// Source is the activity port. Watch runs until ctx is cancelled, invoking // Source is the activity port. Watch runs until ctx is cancelled, invoking
// onChange on every active-window change, and once immediately with the // onChange once immediately with the current window and then on every change of
// current window. // the active window or its title (a tab switch changes the title but not the
// active window).
type Source interface { type Source interface {
Watch(ctx context.Context, onChange func(WindowSnapshot)) Watch(ctx context.Context, onChange func(WindowSnapshot))
} }
+44
View File
@@ -0,0 +1,44 @@
package evidence
import (
"context"
"time"
)
// pollLoop samples the active window via read and forwards snapshots through
// onChange, but only when the observation changes — a different window OR a
// different title. The title case is the one that matters: switching a browser
// tab changes the window title without changing the active window, so a sensor
// that re-reads only on active-window events never sees it and credits the stale
// title. Polling re-reads on a fixed cadence, so a tab switch is caught within
// one interval.
//
// It emits once immediately, then samples every interval, until ctx is
// cancelled. WindowSnapshot is comparable, so the change check is a plain
// equality. Factoring the loop here keeps the change-dedup identical across
// sensors and lets it be tested without a display. (The Windows sensor predates
// this and keeps its own equivalent loop.)
func pollLoop(ctx context.Context, interval time.Duration, read func() WindowSnapshot, onChange func(WindowSnapshot)) {
var last WindowSnapshot
var haveLast bool
emit := func() {
s := read()
if haveLast && s == last {
return
}
last, haveLast = s, true
onChange(s)
}
emit() // immediate current window
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
emit()
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package evidence
import (
"context"
"sync"
"testing"
"time"
)
// TestPollLoopEmitsOnTitleChangeWithinSameWindow is the regression for the bug
// where the X11 sensor only re-read the window on _NET_ACTIVE_WINDOW changes and
// so missed a browser tab switch — same window class, new title — crediting all
// the time to the stale title. The poll loop must emit when only the title
// changes.
func TestPollLoopEmitsOnTitleChangeWithinSameWindow(t *testing.T) {
// Same window (class) throughout; the title changes mid-stream, like a tab
// switch. The repeated identical reads must NOT re-emit; the title change must.
titles := []string{"Keel - Brave", "Keel - Brave", "Consume - Brave", "Consume - Brave"}
var i int
read := func() WindowSnapshot {
idx := i
if idx >= len(titles) {
idx = len(titles) - 1
}
i++
return WindowSnapshot{Class: "Brave-browser", Title: titles[idx], Health: EvidenceHealth{Available: true}}
}
var mu sync.Mutex
var got []WindowSnapshot
onChange := func(s WindowSnapshot) {
mu.Lock()
got = append(got, s)
mu.Unlock()
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
deadline := time.Now().Add(2 * time.Second)
for {
mu.Lock()
n := len(got)
mu.Unlock()
if n >= 2 {
break
}
if time.Now().After(deadline) {
cancel()
<-done
t.Fatalf("expected >=2 emits (one per distinct title), got %d", n)
}
time.Sleep(time.Millisecond)
}
cancel()
<-done
mu.Lock()
defer mu.Unlock()
if got[0].Title != "Keel - Brave" {
t.Fatalf("first emit title = %q, want %q", got[0].Title, "Keel - Brave")
}
if got[1].Title != "Consume - Brave" {
t.Fatalf("second emit title = %q, want %q (a title-only change must emit)", got[1].Title, "Consume - Brave")
}
}
// TestPollLoopDedupesIdenticalObservations confirms an unchanged observation is
// not re-emitted, so redundant samples never churn the consumer.
func TestPollLoopDedupesIdenticalObservations(t *testing.T) {
read := func() WindowSnapshot {
return WindowSnapshot{Class: "code", Title: "main.go", Health: EvidenceHealth{Available: true}}
}
var mu sync.Mutex
var n int
onChange := func(WindowSnapshot) {
mu.Lock()
n++
mu.Unlock()
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
time.Sleep(50 * time.Millisecond)
cancel()
<-done
mu.Lock()
defer mu.Unlock()
if n != 1 {
t.Fatalf("identical observations must emit once, got %d emits", n)
}
}
+15 -37
View File
@@ -4,24 +4,29 @@ package evidence
import ( import (
"context" "context"
"time"
"github.com/jezek/xgb/xproto"
"github.com/jezek/xgbutil" "github.com/jezek/xgbutil"
"github.com/jezek/xgbutil/ewmh" "github.com/jezek/xgbutil/ewmh"
"github.com/jezek/xgbutil/icccm" "github.com/jezek/xgbutil/icccm"
"github.com/jezek/xgbutil/xevent"
"github.com/jezek/xgbutil/xprop"
"github.com/jezek/xgbutil/xwindow"
) )
// pollInterval is how often the X11 sensor samples the active window. ~1s
// latency on a switch is immaterial for a focus tracker, and polling is what
// catches a title change inside the same window — e.g. switching a browser tab,
// which changes the title but not the active window, so an _NET_ACTIVE_WINDOW
// event subscription would miss it and credit the stale title. Mirrors the
// Windows sensor's polling cadence.
const pollInterval = 750 * time.Millisecond
// NewSource returns the real X11 active-window sensor. // NewSource returns the real X11 active-window sensor.
func NewSource() Source { return &x11Source{} } func NewSource() Source { return &x11Source{} }
type x11Source struct{} type x11Source struct{}
// Watch opens one long-lived X connection, subscribes to _NET_ACTIVE_WINDOW // Watch opens one long-lived X connection and samples the active window every
// changes on the root window, and emits a snapshot immediately plus on every // pollInterval, emitting a snapshot whenever the active window OR its title
// change. Any failure degrades to an Unavailable snapshot; it never panics the // changes. Any failure degrades to an Unavailable snapshot; it never panics the
// daemon. // daemon.
func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) { func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
X, err := xgbutil.NewConn() X, err := xgbutil.NewConn()
@@ -32,38 +37,11 @@ func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
} }
defer X.Conn().Close() defer X.Conn().Close()
root := X.RootWin() pollLoop(ctx, pollInterval, func() WindowSnapshot { return snapshot(X) }, onChange)
activeAtom, err := xprop.Atm(X, "_NET_ACTIVE_WINDOW")
if err != nil {
onChange(unavailable("no _NET_ACTIVE_WINDOW atom: " + err.Error()))
<-ctx.Done()
return
}
// Listen for property changes on the root window.
if err := xwindow.New(X, root).Listen(xproto.EventMaskPropertyChange); err != nil {
onChange(unavailable("cannot listen on root window: " + err.Error()))
<-ctx.Done()
return
}
emit := func() { onChange(snapshot(X)) }
emit() // immediate current window
xevent.PropertyNotifyFun(func(_ *xgbutil.XUtil, ev xevent.PropertyNotifyEvent) {
if ev.Atom == activeAtom {
emit()
}
}).Connect(X, root)
// Run the event loop until ctx is cancelled.
go func() {
<-ctx.Done()
xevent.Quit(X)
}()
xevent.Main(X)
} }
// snapshot reads the current active window's title and class. A missing active
// window (or read error) yields an Unavailable snapshot.
func snapshot(X *xgbutil.XUtil) WindowSnapshot { func snapshot(X *xgbutil.XUtil) WindowSnapshot {
active, err := ewmh.ActiveWindowGet(X) active, err := ewmh.ActiveWindowGet(X)
if err != nil || active == 0 { if err != nil || active == 0 {