Files
cthulhu/internal/wine/review_registry.go

229 lines
6.3 KiB
Go

// 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()
}