diff --git a/go.mod b/go.mod index 4ff2e52..171f9d1 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.26.3 require ( github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 + github.com/jezek/xgb v1.3.1 + github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a ) require ( @@ -19,8 +21,6 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/jezek/xgb v1.3.1 // indirect - github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/internal/evidence/source_other.go b/internal/evidence/source_other.go new file mode 100644 index 0000000..d9edd74 --- /dev/null +++ b/internal/evidence/source_other.go @@ -0,0 +1,16 @@ +//go:build !linux + +package evidence + +import "context" + +// NewSource returns a sensor that reports evidence permanently unavailable on +// platforms without the X11 adapter. +func NewSource() Source { return noopSource{} } + +type noopSource struct{} + +func (noopSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) { + onChange(WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: "no active-window sensor on this platform"}}) + <-ctx.Done() +} diff --git a/internal/evidence/x11.go b/internal/evidence/x11.go new file mode 100644 index 0000000..7c972e0 --- /dev/null +++ b/internal/evidence/x11.go @@ -0,0 +1,94 @@ +//go:build linux + +package evidence + +import ( + "context" + "log" + + "github.com/jezek/xgb/xproto" + "github.com/jezek/xgbutil" + "github.com/jezek/xgbutil/ewmh" + "github.com/jezek/xgbutil/icccm" + "github.com/jezek/xgbutil/xevent" + "github.com/jezek/xgbutil/xprop" + "github.com/jezek/xgbutil/xwindow" +) + +// NewSource returns the real X11 active-window sensor. +func NewSource() Source { return &x11Source{} } + +type x11Source struct{} + +// Watch opens one long-lived X connection, subscribes to _NET_ACTIVE_WINDOW +// changes on the root window, and emits a snapshot immediately plus on every +// change. 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() + + root := X.RootWin() + 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) +} + +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}, + } +} + +func unavailable(reason string) WindowSnapshot { + log.Printf("evidence: %s", reason) + return WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: reason}} +} diff --git a/internal/evidence/x11_integration_test.go b/internal/evidence/x11_integration_test.go new file mode 100644 index 0000000..f52949a --- /dev/null +++ b/internal/evidence/x11_integration_test.go @@ -0,0 +1,35 @@ +//go:build linux + +package evidence + +import ( + "context" + "os" + "testing" + "time" +) + +func TestX11SourceEmitsWhenDisplaySet(t *testing.T) { + if os.Getenv("DISPLAY") == "" { + t.Skip("no DISPLAY; skipping live X11 smoke test") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + got := make(chan WindowSnapshot, 1) + go NewSource().Watch(ctx, func(s WindowSnapshot) { + select { + case got <- s: + default: + } + }) + + select { + case snap := <-got: + // Either a real window or a clean Unavailable — both are valid; we only + // assert the sensor produced an observation without panicking. + t.Logf("first snapshot: %+v", snap) + case <-ctx.Done(): + t.Fatal("x11 source emitted no snapshot within timeout") + } +}