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:
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user