//go:build linux package evidence import ( "context" "time" "github.com/jezek/xgbutil" "github.com/jezek/xgbutil/ewmh" "github.com/jezek/xgbutil/icccm" ) // 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. func NewSource() Source { return &x11Source{} } type x11Source struct{} // Watch opens one long-lived X connection and samples the active window every // pollInterval, emitting a snapshot whenever the active window OR its title // changes. Any failure degrades to an Unavailable snapshot; it never panics the // daemon. func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) { X, err := xgbutil.NewConn() if err != nil { onChange(unavailable("cannot connect to X server: " + err.Error())) <-ctx.Done() return } defer X.Conn().Close() pollLoop(ctx, pollInterval, func() WindowSnapshot { return snapshot(X) }, onChange) } // 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 { active, err := ewmh.ActiveWindowGet(X) if err != nil || active == 0 { return unavailable("no active window") } title, err := ewmh.WmNameGet(X, active) if err != nil || title == "" { // Fall back to ICCCM WM_NAME. if t, e := icccm.WmNameGet(X, active); e == nil { title = t } } var class string if wmClass, err := icccm.WmClassGet(X, active); err == nil && wmClass != nil { class = wmClass.Class } return WindowSnapshot{ Title: title, Class: class, Health: EvidenceHealth{Available: true}, } }