main f2d166b

source.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close