// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package wine import ( "context" "errors" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/sessionbus" "github.com/godbus/dbus/v5" "log/slog" "sort" "strings" "sync" ) type reviewProvider struct { path dbus.ObjectPath notice ReviewFocusNotice ctx context.Context cancel context.CancelFunc trees map[string]*atspi.AccessibleTree } type ReviewRegistry struct { logger *slog.Logger mutex sync.Mutex ctx context.Context cancel context.CancelFunc monitor, service *dbus.Conn signals chan *dbus.Signal done chan struct{} changes chan struct{} providers map[string]*reviewProvider captureGate chan struct{} captureCall func(context.Context, string, dbus.ObjectPath, ReviewFocusNotice) (map[string]dbus.Variant, error) } func NewReviewRegistry(logger *slog.Logger) *ReviewRegistry { return &ReviewRegistry{logger: logger, changes: make(chan struct{}, 1), providers: make(map[string]*reviewProvider), captureGate: make(chan struct{}, 1)} } func (r *ReviewRegistry) notify() { select { case r.changes <- struct{}{}: default: } } func (r *ReviewRegistry) Changes() <-chan struct{} { return r.changes } func (r *ReviewRegistry) Start(ctx context.Context) error { if ctx == nil { return errors.New("Wine review requires a context") } conn, err := sessionbus.Connect(r.logger, "wine-review") if err != nil { return err } options := []dbus.MatchOption{dbus.WithMatchSender("org.freedesktop.DBus"), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged")} signals := make(chan *dbus.Signal, 64) conn.Signal(signals) if err = conn.AddMatchSignal(options...); err != nil { conn.RemoveSignal(signals) conn.Close() return err } r.mutex.Lock() if r.ctx != nil { r.mutex.Unlock() conn.RemoveSignal(signals) conn.Close() return errors.New("Wine review already started") } r.ctx, r.cancel = context.WithCancel(ctx) r.monitor = conn r.signals = signals r.done = make(chan struct{}) lifetime, done := r.ctx, r.done r.mutex.Unlock() go func() { defer close(done) for { select { case <-lifetime.Done(): return case <-conn.Context().Done(): r.retireAll() return case signal := <-signals: if signal != nil && len(signal.Body) == 3 { name, ok := signal.Body[0].(string) old, _ := signal.Body[1].(string) next, _ := signal.Body[2].(string) if ok && name == old && next == "" { r.disappeared(name) } } } } }() return nil } // The service connection is borrowed because native capture accepts only the // current owner of Cthulhu's well-known name. Registry never closes it. func (r *ReviewRegistry) BindWineReviewConnection(conn *dbus.Conn) { r.mutex.Lock() r.service = conn if conn == nil { for key, p := range r.providers { p.cancel() delete(r.providers, key) } } r.notify() r.mutex.Unlock() } func (r *ReviewRegistry) Stop(ctx context.Context) error { r.mutex.Lock() if r.cancel != nil { r.cancel() } conn, signals, done := r.monitor, r.signals, r.done for key, p := range r.providers { p.cancel() delete(r.providers, key) } r.ctx = nil r.cancel = nil r.monitor = nil r.service = nil r.signals = nil r.done = nil r.notify() r.mutex.Unlock() if conn != nil { conn.RemoveSignal(signals) conn.RemoveMatchSignal(dbus.WithMatchSender("org.freedesktop.DBus"), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged")) conn.Close() } if done != nil { select { case <-done: case <-ctx.Done(): return ctx.Err() } } return nil } func (r *ReviewRegistry) RegisterProvider(sender string, path dbus.ObjectPath, version uint32) bool { if !strings.HasPrefix(sender, ":") || !dbus.ObjectPath("/"+strings.ReplaceAll(sender[1:], ".", "/")).IsValid() || path != ReviewObjectPath || version != ReviewProtocolVersion { return false } r.mutex.Lock() defer r.mutex.Unlock() if r.ctx == nil || r.ctx.Err() != nil { return false } if old := r.providers[sender]; old != nil { old.cancel() } ctx, cancel := context.WithCancel(r.ctx) r.providers[sender] = &reviewProvider{path: path, ctx: ctx, cancel: cancel, trees: make(map[string]*atspi.AccessibleTree)} r.notify() return true } func (r *ReviewRegistry) PresentFocus(sender string, notice ReviewFocusNotice) bool { if !reviewString(notice.Instance, 256, false) || !reviewString(notice.Window, 256, false) || !reviewString(notice.Focus, 256, false) || notice.Revision == 0 || !reviewString(notice.Title, 4096, true) || !reviewString(notice.Role, 4096, false) || !notice.Bounds.Valid() { return false } r.mutex.Lock() defer r.mutex.Unlock() p := r.providers[sender] if p == nil || p.ctx.Err() != nil { return false } notice.Sender = sender notice.Path = p.path if p.notice == notice { return true } if p.notice.Instance == notice.Instance && p.notice.Revision > notice.Revision { return false } if p.notice.Instance != notice.Instance || p.notice.Window != notice.Window { // Only the active native window can be captured. Retiring older // windows bounds retained text and preserves the current tree's identity. p.trees = make(map[string]*atspi.AccessibleTree) } p.cancel() p.ctx, p.cancel = context.WithCancel(r.ctx) p.notice = notice r.notify() return true } func (r *ReviewRegistry) Focuses() []ReviewFocusNotice { r.mutex.Lock() defer r.mutex.Unlock() var notices []ReviewFocusNotice for _, p := range r.providers { if p.notice.Focus != "" && p.ctx.Err() == nil { notices = append(notices, p.notice) } } sort.Slice(notices, func(i, j int) bool { return notices[i].Sender < notices[j].Sender }) return notices } func (r *ReviewRegistry) Current(id atspi.ObjectID) bool { r.mutex.Lock() defer r.mutex.Unlock() p := r.providers[id.BusName] return IsReviewID(id) && p != nil && p.ctx.Err() == nil && p.notice.ID() == id } func (r *ReviewRegistry) disappeared(sender string) { r.mutex.Lock() defer r.mutex.Unlock() if p := r.providers[sender]; p != nil { p.cancel() delete(r.providers, sender) r.notify() } } func (r *ReviewRegistry) retireAll() { r.mutex.Lock() defer r.mutex.Unlock() for sender, p := range r.providers { p.cancel() delete(r.providers, sender) } r.notify() }