1package atspi
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "time"
9
10 "github.com/bnema/zerobus"
11
12 "github.com/bnema/neferwl/internal/adapters/logging"
13 "github.com/bnema/neferwl/internal/ports"
14)
15
16const (
17 // callTimeout is how long a batch waits for the next reply.
18 callTimeout = 100 * time.Millisecond
19 // budget is the whole time Targets may take.
20 budget = 300 * time.Millisecond
21)
22
23// connectBudget bounds the whole connection setup (session bus,
24// GetAddress, IsEnabled, dial of the accessibility bus) of one attempt.
25const connectBudget = 300 * time.Millisecond
26
27// retryEvery is the minimum time between two connection attempts.
28const retryEvery = 5 * time.Second
29
30// connector opens a transport on the accessibility bus.
31type connector interface {
32 Connect(ctx context.Context) (transport, error)
33}
34
35// stallWarnAfter is how long an attempt may be in flight before it is
36// reported as a stalled accessibility bus.
37const stallWarnAfter = 5 * time.Second
38
39// dialResult is the outcome of a connection attempt.
40type dialResult struct {
41 tr transport
42 err error
43 at time.Time // when the attempt finished
44}
45
46// Source reads hint targets from the accessibility tree. It owns one
47// connection to the accessibility bus. It is NOT safe for concurrent use:
48// it is owned by the hints worker goroutine, which calls Targets and,
49// when it returns, Close. The connection is made on the first Targets call
50// that needs it, never at startup, and made again after a failure, at most
51// once every retryEvery; meanwhile Targets finds nothing and OCR takes over.
52//
53// At most one connection attempt (one goroutine and one socket) exists at
54// any time: an attempt that outlives the wait of ensure stays in flight, is
55// polled by the later Targets calls and is never doubled.
56type Source struct {
57 tr transport
58 dial connector
59 // attempt delivers the outcome of the attempt in flight (buffered, so
60 // the attempt goroutine never blocks); nil when none runs.
61 attempt chan dialResult
62 attemptStarted time.Time
63 warnedStall bool
64 retryAt time.Time
65 closed bool
66 // wait is how long ensure waits for a new attempt; connectBudget if 0.
67 wait time.Duration
68}
69
70// New returns a Source. It does not touch the bus: the connection is made by
71// the first Targets call.
72func New() *Source { return &Source{dial: busConnector{}} }
73
74// busConnector connects to the session bus, enables accessibility and dials
75// the accessibility bus.
76type busConnector struct{}
77
78func (busConnector) Connect(ctx context.Context) (transport, error) {
79 log := logging.For(ctx, "atspi")
80 // zerobus has no deadline or context API: SessionBus and Dial run the
81 // socket dial, SASL auth and Hello without a bound, and the watchdog
82 // below cannot exist before the connection does. A stall there is
83 // bounded by the single-in-flight rule of Source.ensure, not by a
84 // deadline. Follow-up: a deadline-capable zerobus API.
85 sess, err := zerobus.SessionBus()
86 if err != nil {
87 return nil, fmt.Errorf("atspi: session bus: %w", err)
88 }
89 defer func() { _ = sess.Close() }()
90 // Call has no deadline: closing the connection ends the wait for a
91 // silent service.
92 stop := time.AfterFunc(connectBudget, func() { _ = sess.Close() })
93 defer stop.Stop()
94
95 sess.NewCall(a11yBusName, a11yBusPath, a11yBusName, "GetAddress", "")
96 m, err := sess.Call()
97 if err != nil {
98 return nil, fmt.Errorf("atspi: org.a11y.Bus GetAddress: %w", err)
99 }
100 r := m.Body()
101 addr := r.Str()
102 if r.Err() != nil || addr == "" {
103 return nil, errors.New("atspi: no accessibility bus address")
104 }
105 addr = strings.Clone(addr)
106
107 e := sess.NewCall(a11yBusName, a11yBusPath, ifaceProps, "Set", "ssv")
108 e.Str(a11yStatus)
109 e.Str("IsEnabled")
110 e.Variant("b")
111 e.Bool(true)
112 if _, err := sess.Call(); err != nil {
113 // Toolkits that already see the bus keep working.
114 log.Warn().Err(err).Msg("could not enable accessibility")
115 } else {
116 log.Info().Msg("accessibility enabled")
117 }
118
119 zc, err := zerobus.Dial(addr)
120 if err != nil {
121 return nil, fmt.Errorf("atspi: accessibility bus: %w", err)
122 }
123 tr, err := newConn(zc)
124 if err != nil {
125 return nil, err
126 }
127 log.Info().Msg("accessibility bus connected")
128 return tr, nil
129}
130
131// ensure returns the connection, making one if none exists and the last
132// attempt is old enough. It waits at most the budget for a new attempt; an
133// attempt that does not answer in time stays in flight (abandoned, never
134// doubled) and is polled without blocking by the next calls.
135func (s *Source) ensure(ctx context.Context) transport {
136 if s.closed {
137 return nil
138 }
139 if s.tr != nil {
140 return s.tr
141 }
142 if s.dial == nil {
143 return nil
144 }
145 now := time.Now()
146 if s.attempt != nil {
147 select {
148 case r := <-s.attempt:
149 s.finish(ctx, r)
150 default:
151 if !s.warnedStall && now.Sub(s.attemptStarted) > stallWarnAfter {
152 s.warnedStall = true
153 log := logging.For(ctx, "atspi")
154 log.Warn().Dur("for", now.Sub(s.attemptStarted)).Msg("accessibility bus not answering")
155 }
156 }
157 return s.tr
158 }
159 if now.Before(s.retryAt) {
160 return nil
161 }
162 ch := make(chan dialResult, 1)
163 s.attempt = ch
164 s.attemptStarted = now
165 s.warnedStall = false
166 dial := s.dial
167 go func() {
168 tr, err := dial.Connect(ctx)
169 ch <- dialResult{tr: tr, err: err, at: time.Now()}
170 }()
171 wait := s.wait
172 if wait <= 0 {
173 wait = connectBudget
174 }
175 timer := time.NewTimer(wait)
176 defer timer.Stop()
177 select {
178 case r := <-ch:
179 s.finish(ctx, r)
180 case <-timer.C:
181 log := logging.For(ctx, "atspi")
182 log.Debug().Dur("after", wait).Msg("accessibility connect attempt abandoned in flight")
183 case <-ctx.Done():
184 }
185 return s.tr
186}
187
188// finish takes the outcome of the attempt in flight: a transport is kept,
189// an error starts the backoff from the time the attempt finished.
190func (s *Source) finish(ctx context.Context, r dialResult) {
191 s.attempt = nil
192 if r.err != nil || r.tr == nil {
193 s.retryAt = r.at.Add(retryEvery)
194 err := r.err
195 if err == nil {
196 err = errors.New("atspi: no transport")
197 }
198 log := logging.For(ctx, "atspi")
199 log.Warn().Err(err).Msg("accessibility hint targets unavailable, using OCR only")
200 return
201 }
202 s.tr = r.tr
203}
204
205// drop closes a broken connection; the next attempt reconnects.
206func (s *Source) drop() {
207 s.retryAt = time.Now().Add(retryEvery)
208 if s.tr != nil {
209 _ = s.tr.Close()
210 s.tr = nil
211 }
212}
213
214// Close closes the accessibility bus connection, and the one an attempt
215// still in flight may deliver late. After Close, Targets finds nothing and
216// never connects. Like Targets it is called by the owner goroutine only.
217func (s *Source) Close() error {
218 if s.closed {
219 return nil
220 }
221 s.closed = true
222 var err error
223 if s.tr != nil {
224 err = s.tr.Close()
225 s.tr = nil
226 }
227 if ch := s.attempt; ch != nil {
228 s.attempt = nil
229 // One reaper at most: Close runs once, and one attempt exists.
230 go func() {
231 if r := <-ch; r.tr != nil {
232 _ = r.tr.Close()
233 }
234 }()
235 }
236 return err
237}
238
239// appInfo is an application of the registry whose process shows a window.
240type appInfo struct {
241 root ref
242 pid int
243}
244
245// winMatch is a window with the frame it was paired with.
246type winMatch struct {
247 win ports.HintWindow
248 frame ref
249 fx int32
250 fy int32
251}
252
253// Targets returns the clickable targets of the accessibility tree for
254// windows (logical output-local, clipped to each window) and the set of
255// windows those targets cover. A window is covered when its tree answered
256// with a link target or web document; uncovered windows also get OCR. It
257// returns what is ready within the budget.
258func (s *Source) Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) {
259 covered := map[ports.WindowID]bool{}
260 deadline := time.Now().Add(budget)
261 if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
262 deadline = d
263 }
264 wanted := map[int]bool{}
265 for _, w := range windows {
266 if w.PID > 0 {
267 wanted[w.PID] = true
268 }
269 }
270 if len(wanted) == 0 {
271 return nil, covered, nil
272 }
273 tr := s.ensure(ctx)
274 if tr == nil {
275 return nil, covered, nil
276 }
277 broken := false
278 do := func(calls []call) []reply {
279 if len(calls) == 0 || ctx.Err() != nil {
280 return make([]reply, len(calls))
281 }
282 if broken {
283 out := make([]reply, len(calls))
284 for i := range out {
285 out[i].err = ErrBroken
286 }
287 return out
288 }
289 out := tr.Do(deadline, callTimeout, calls)
290 for _, r := range out {
291 if errors.Is(r.err, ErrBroken) {
292 broken = true
293 s.drop()
294 break
295 }
296 }
297 return out
298 }
299
300 // Applications of the registry, kept when their process shows a window.
301 rep := do([]call{{op: opChildren, name: registryName, path: registryRoot}})
302 if len(rep) == 0 || rep[0].err != nil {
303 if len(rep) > 0 {
304 return nil, covered, fmt.Errorf("atspi: registry: %w", rep[0].err)
305 }
306 return nil, covered, nil
307 }
308 apps := rep[0].refs
309 calls := make([]call, len(apps))
310 for i, a := range apps {
311 calls[i] = call{op: opPID, name: a.name}
312 }
313 var kept []appInfo
314 for i, r := range do(calls) {
315 if r.err == nil && wanted[int(r.u)] {
316 kept = append(kept, appInfo{root: apps[i], pid: int(r.u)})
317 }
318 }
319
320 // The showing windows of each kept app.
321 calls = calls[:0]
322 for _, a := range kept {
323 calls = append(calls, call{op: opChildren, name: a.root.name, path: a.root.path})
324 }
325 var cands []ref
326 var candApp []int
327 for i, r := range do(calls) {
328 if r.err != nil {
329 continue
330 }
331 for _, c := range r.refs[:min(len(r.refs), maxFrames)] {
332 cands = append(cands, c)
333 candApp = append(candApp, i)
334 }
335 }
336 calls = calls[:0]
337 for _, c := range cands {
338 calls = append(calls,
339 call{op: opRole, name: c.name, path: c.path},
340 call{op: opState, name: c.name, path: c.path},
341 call{op: opExtents, name: c.name, path: c.path, arg: coordWindow})
342 }
343 info := do(calls)
344 type frame struct {
345 frameInfo
346 pid int
347 fx, fy int32
348 }
349 var frames []frame
350 for i, c := range cands {
351 role, state, ext := info[3*i], info[3*i+1], info[3*i+2]
352 if role.err != nil || state.err != nil || ext.err != nil || !isWindowRole(role.u) || !hasState(state.state, stateShowing) {
353 continue
354 }
355 frames = append(frames, frame{
356 frameInfo: frameInfo{ref: c, active: hasState(state.state, stateActive), w: int(ext.rect[2]), h: int(ext.rect[3])},
357 pid: kept[candApp[i]].pid, fx: ext.rect[0], fy: ext.rect[1],
358 })
359 }
360
361 // Pair the windows with the frames, process by process.
362 var matched []winMatch
363 for pid := range wanted {
364 var wins []ports.HintWindow
365 for _, w := range windows {
366 if w.PID == pid {
367 wins = append(wins, w)
368 }
369 }
370 var fs []frameInfo
371 var origin []frame
372 for _, f := range frames {
373 if f.pid == pid {
374 fs = append(fs, f.frameInfo)
375 origin = append(origin, f)
376 }
377 }
378 for wi, fi := range matchFrames(wins, fs) {
379 matched = append(matched, winMatch{win: wins[wi], frame: origin[fi].ref, fx: origin[fi].fx, fy: origin[fi].fy})
380 }
381 }
382
383 targets := s.elements(do, matched, linksOnly, covered)
384 log := logging.For(ctx, "atspi")
385 log.Debug().Int("apps", len(kept)).Int("frames", len(frames)).Int("windows", len(matched)).Int("targets", len(targets)).Msg("atspi targets")
386 return targets, covered, nil
387}
388
389// elements reads the clickable elements of the matched frames.
390func (s *Source) elements(do func([]call) []reply, matched []winMatch, linksOnly bool, covered map[ports.WindowID]bool) []ports.HintTarget {
391 calls := make([]call, len(matched))
392 for i, m := range matched {
393 calls[i] = call{op: opMatches, name: m.frame.name, path: m.frame.path, rule: matchRule{linksOnly: linksOnly}}
394 }
395 found := make([][]ref, len(matched))
396 var walk []int // frames without Collection
397 for i, r := range do(calls) {
398 if r.err != nil {
399 var be *busError
400 if errors.As(r.err, &be) {
401 walk = append(walk, i)
402 }
403 continue
404 }
405 found[i] = r.refs
406 }
407 for _, i := range walk {
408 found[i] = s.walk(do, matched[i].frame, linksOnly)
409 }
410
411 calls = calls[:0]
412 var owner []int
413 for i, refs := range found {
414 for _, r := range refs {
415 calls = append(calls,
416 call{op: opRole, name: r.name, path: r.path},
417 call{op: opExtents, name: r.name, path: r.path, arg: coordWindow})
418 owner = append(owner, i)
419 }
420 }
421 info := do(calls)
422 var out []ports.HintTarget
423 for k, i := range owner {
424 role, ext := info[2*k], info[2*k+1]
425 if role.err == nil && isDocument(role.u) {
426 covered[matched[i].win.ID] = true
427 continue
428 }
429 if role.err != nil || ext.err != nil || !isClickable(role.u, linksOnly) {
430 continue
431 }
432 m := matched[i]
433 t, ok := toTarget(m.win.Rect, m.fx, m.fy, element{role: role.u, x: ext.rect[0], y: ext.rect[1], w: ext.rect[2], h: ext.rect[3]})
434 if !ok {
435 continue
436 }
437 t.Window = m.win.ID
438 out = append(out, t)
439 if t.Kind == ports.HintLink {
440 covered[m.win.ID] = true
441 }
442 }
443 return out
444}
445
446// walk is the fallback for toolkits without Collection: a breadth-first
447// walk of at most maxDepth levels and maxWalkNodes nodes, returning the
448// clickable nodes.
449func (s *Source) walk(do func([]call) []reply, root ref, linksOnly bool) []ref {
450 var out []ref
451 level := []ref{root}
452 seen := 0
453 for depth := 0; depth < maxDepth && len(level) > 0 && seen < maxWalkNodes; depth++ {
454 calls := make([]call, 0, 3*len(level))
455 for _, n := range level {
456 calls = append(calls,
457 call{op: opChildren, name: n.name, path: n.path},
458 call{op: opRole, name: n.name, path: n.path},
459 call{op: opState, name: n.name, path: n.path})
460 }
461 rep := do(calls)
462 room := max(0, maxWalkNodes-seen-len(level))
463 next := make([]ref, 0, room)
464 for i, n := range level {
465 ch, role, state := rep[3*i], rep[3*i+1], rep[3*i+2]
466 if state.err == nil && !hasState(state.state, stateShowing) {
467 continue
468 }
469 if role.err == nil && (isClickable(role.u, linksOnly) || isDocument(role.u)) && state.err == nil && hasState(state.state, stateVisible) {
470 out = append(out, n)
471 }
472 if ch.err == nil {
473 take := min(len(ch.refs), room-len(next))
474 next = append(next, ch.refs[:take]...)
475 }
476 }
477 seen += len(level)
478 level = next
479 }
480 return out
481}