detached 54bfe5b

feat(atspi): find hint targets in the accessibility tree

commit
54bfe5b06bdef446a26966450d2058ab5ce8d177
author
brice · · 2026-10-19 12:49 UTC
parent
74fc2f3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +1361 -0
M .mockery.yml +7 -0
@@ -111,6 +111,13 @@ packages:
111111 dir: internal/adapters/statefile
112112 pkgname: statefile
113113 filename: proc_tree_mock_test.go
114+ github.com/bnema/neferwl/internal/adapters/atspi:
115+ interfaces:
116+ transport:
117+ config:
118+ dir: internal/adapters/atspi
119+ pkgname: atspi
120+ filename: transport_mock_test.go
114121 github.com/bnema/neferwl/internal/adapters/screencast:
115122 interfaces:
116123 bus:
@@ -111,6 +111,13 @@ packages:
111 dir: internal/adapters/statefile111 dir: internal/adapters/statefile
112 pkgname: statefile112 pkgname: statefile
113 filename: proc_tree_mock_test.go113 filename: proc_tree_mock_test.go
114+ github.com/bnema/neferwl/internal/adapters/atspi:
115+ interfaces:
116+ transport:
117+ config:
118+ dir: internal/adapters/atspi
119+ pkgname: atspi
120+ filename: transport_mock_test.go
114 github.com/bnema/neferwl/internal/adapters/screencast:121 github.com/bnema/neferwl/internal/adapters/screencast:
115 interfaces:122 interfaces:
116 bus:123 bus:
A internal/adapters/atspi/bus_test.go +166 -0
@@ -0,0 +1,166 @@
1+package atspi
2+
3+import (
4+ "bufio"
5+ "os"
6+ "os/exec"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/zerobus"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// startBus starts a private dbus-daemon, so the test never touches the
16+// desktop's buses. It skips when the daemon is not installed.
17+func startBus(t *testing.T) string {
18+ t.Helper()
19+ bin, err := exec.LookPath("dbus-daemon")
20+ if err != nil {
21+ if os.Getenv("CI") != "" {
22+ t.Fatal("dbus-daemon not installed")
23+ }
24+ t.Skip("dbus-daemon not installed")
25+ }
26+ cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1")
27+ out, err := cmd.StdoutPipe()
28+ require.NoError(t, err)
29+ require.NoError(t, cmd.Start())
30+ t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
31+ line, err := bufio.NewReader(out).ReadString('\n')
32+ require.NoError(t, err)
33+ return strings.TrimSpace(line)
34+}
35+
36+// TestDoDecodesMatchesPipelined runs Do against an app on a real bus: the
37+// GetMatches reply is decoded, and replies to several calls sent before any
38+// is read are matched by serial.
39+func TestDoDecodesMatchesPipelined(t *testing.T) {
40+ addr := startBus(t)
41+ app, err := zerobus.Dial(addr)
42+ require.NoError(t, err)
43+ t.Cleanup(func() { _ = app.Close() })
44+ _, err = app.RequestName("org.test.App", 0)
45+ require.NoError(t, err)
46+
47+ done := make(chan struct{})
48+ go func() {
49+ defer close(done)
50+ for {
51+ m, err := app.ReadMessage()
52+ if err != nil {
53+ return
54+ }
55+ if m.Type != zerobus.TypeMethodCall {
56+ continue
57+ }
58+ req := &zerobus.Message{Sender: strings.Clone(m.Sender), Serial: m.Serial}
59+ switch m.Member {
60+ case "GetMatches":
61+ // Check the rule: struct, then sort, count, traverse.
62+ r := m.Body()
63+ r.Struct()
64+ end := r.Array('i')
65+ var states []int32
66+ for r.More(end) {
67+ states = append(states, r.Int32())
68+ }
69+ _ = r.Int32()
70+ ad := r.Array('{')
71+ _ = ad
72+ _ = r.Int32()
73+ end = r.Array('i')
74+ var roles []int32
75+ for r.More(end) {
76+ roles = append(roles, r.Int32())
77+ }
78+ ok := r.Err() == nil && len(states) == 2 && states[0] == 1<<25|1<<30 && len(roles) == 5 && roles[2] == 1<<24
79+ if !ok {
80+ app.NewError(req, "org.test.Error.BadRule", "s").Str("bad rule")
81+ _, _ = app.Send()
82+ continue
83+ }
84+ e := app.NewReply(req, "a(so)")
85+ a := e.BeginArray('(')
86+ for _, p := range []string{"/one", "/two"} {
87+ e.Struct()
88+ e.Str(":1.99")
89+ e.ObjectPath(p)
90+ }
91+ e.EndArray(a)
92+ case "GetExtents":
93+ e := app.NewReply(req, "(iiii)")
94+ e.Struct()
95+ e.Int32(1)
96+ e.Int32(2)
97+ e.Int32(30)
98+ e.Int32(40)
99+ case "GetRole":
100+ app.NewReply(req, "u").Uint32(roleLink)
101+ case "GetState":
102+ e := app.NewReply(req, "au")
103+ a := e.BeginArray('u')
104+ e.Uint32(stShowing)
105+ e.Uint32(0)
106+ e.EndArray(a)
107+ default:
108+ app.NewError(req, "org.freedesktop.DBus.Error.UnknownMethod", "s").Str("no")
109+ }
110+ _, _ = app.Send()
111+ }
112+ }()
113+
114+ cc, err := zerobus.Dial(addr)
115+ require.NoError(t, err)
116+ b, err := newConn(cc)
117+ require.NoError(t, err)
118+ t.Cleanup(func() { _ = b.Close() })
119+
120+ calls := []call{
121+ {op: opMatches, name: "org.test.App", path: "/frame", rule: matchRule{linksOnly: true}},
122+ {op: opExtents, name: "org.test.App", path: "/one", arg: coordWindow},
123+ {op: opRole, name: "org.test.App", path: "/one"},
124+ {op: opState, name: "org.test.App", path: "/one"},
125+ {op: opChildren, name: "org.test.App", path: "/one"},
126+ }
127+ rep := b.Do(time.Now().Add(2*time.Second), time.Second, calls)
128+ require.Len(t, rep, 5)
129+ require.NoError(t, rep[0].err)
130+ require.Equal(t, []ref{{":1.99", "/one"}, {":1.99", "/two"}}, rep[0].refs)
131+ require.NoError(t, rep[1].err)
132+ require.Equal(t, [4]int32{1, 2, 30, 40}, rep[1].rect)
133+ require.Equal(t, uint32(roleLink), rep[2].u)
134+ require.Equal(t, uint64(stShowing), rep[3].state)
135+ var be *busError
136+ require.ErrorAs(t, rep[4].err, &be)
137+ require.Equal(t, "org.freedesktop.DBus.Error.UnknownMethod", be.name)
138+
139+ // A call to a name nobody owns fails fast, others still succeed.
140+ rep = b.Do(time.Now().Add(2*time.Second), time.Second, []call{
141+ {op: opRole, name: "org.test.Nobody", path: "/x"},
142+ {op: opRole, name: "org.test.App", path: "/x"},
143+ })
144+ require.Error(t, rep[0].err)
145+ require.NoError(t, rep[1].err)
146+}
147+
148+func TestDoTimesOutOnSilentApp(t *testing.T) {
149+ addr := startBus(t)
150+ app, err := zerobus.Dial(addr)
151+ require.NoError(t, err)
152+ t.Cleanup(func() { _ = app.Close() })
153+ _, err = app.RequestName("org.test.Mute", 0)
154+ require.NoError(t, err)
155+
156+ cc, err := zerobus.Dial(addr)
157+ require.NoError(t, err)
158+ b, err := newConn(cc)
159+ require.NoError(t, err)
160+ t.Cleanup(func() { _ = b.Close() })
161+
162+ t0 := time.Now()
163+ rep := b.Do(time.Now().Add(time.Second), 50*time.Millisecond, []call{{op: opRole, name: "org.test.Mute", path: "/x"}})
164+ require.ErrorIs(t, rep[0].err, ErrTimeout)
165+ require.Less(t, time.Since(t0), 500*time.Millisecond)
166+}
@@ -0,0 +1,166 @@
1+package atspi
2+
3+import (
4+ "bufio"
5+ "os"
6+ "os/exec"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/zerobus"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// startBus starts a private dbus-daemon, so the test never touches the
16+// desktop's buses. It skips when the daemon is not installed.
17+func startBus(t *testing.T) string {
18+ t.Helper()
19+ bin, err := exec.LookPath("dbus-daemon")
20+ if err != nil {
21+ if os.Getenv("CI") != "" {
22+ t.Fatal("dbus-daemon not installed")
23+ }
24+ t.Skip("dbus-daemon not installed")
25+ }
26+ cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1")
27+ out, err := cmd.StdoutPipe()
28+ require.NoError(t, err)
29+ require.NoError(t, cmd.Start())
30+ t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
31+ line, err := bufio.NewReader(out).ReadString('\n')
32+ require.NoError(t, err)
33+ return strings.TrimSpace(line)
34+}
35+
36+// TestDoDecodesMatchesPipelined runs Do against an app on a real bus: the
37+// GetMatches reply is decoded, and replies to several calls sent before any
38+// is read are matched by serial.
39+func TestDoDecodesMatchesPipelined(t *testing.T) {
40+ addr := startBus(t)
41+ app, err := zerobus.Dial(addr)
42+ require.NoError(t, err)
43+ t.Cleanup(func() { _ = app.Close() })
44+ _, err = app.RequestName("org.test.App", 0)
45+ require.NoError(t, err)
46+
47+ done := make(chan struct{})
48+ go func() {
49+ defer close(done)
50+ for {
51+ m, err := app.ReadMessage()
52+ if err != nil {
53+ return
54+ }
55+ if m.Type != zerobus.TypeMethodCall {
56+ continue
57+ }
58+ req := &zerobus.Message{Sender: strings.Clone(m.Sender), Serial: m.Serial}
59+ switch m.Member {
60+ case "GetMatches":
61+ // Check the rule: struct, then sort, count, traverse.
62+ r := m.Body()
63+ r.Struct()
64+ end := r.Array('i')
65+ var states []int32
66+ for r.More(end) {
67+ states = append(states, r.Int32())
68+ }
69+ _ = r.Int32()
70+ ad := r.Array('{')
71+ _ = ad
72+ _ = r.Int32()
73+ end = r.Array('i')
74+ var roles []int32
75+ for r.More(end) {
76+ roles = append(roles, r.Int32())
77+ }
78+ ok := r.Err() == nil && len(states) == 2 && states[0] == 1<<25|1<<30 && len(roles) == 5 && roles[2] == 1<<24
79+ if !ok {
80+ app.NewError(req, "org.test.Error.BadRule", "s").Str("bad rule")
81+ _, _ = app.Send()
82+ continue
83+ }
84+ e := app.NewReply(req, "a(so)")
85+ a := e.BeginArray('(')
86+ for _, p := range []string{"/one", "/two"} {
87+ e.Struct()
88+ e.Str(":1.99")
89+ e.ObjectPath(p)
90+ }
91+ e.EndArray(a)
92+ case "GetExtents":
93+ e := app.NewReply(req, "(iiii)")
94+ e.Struct()
95+ e.Int32(1)
96+ e.Int32(2)
97+ e.Int32(30)
98+ e.Int32(40)
99+ case "GetRole":
100+ app.NewReply(req, "u").Uint32(roleLink)
101+ case "GetState":
102+ e := app.NewReply(req, "au")
103+ a := e.BeginArray('u')
104+ e.Uint32(stShowing)
105+ e.Uint32(0)
106+ e.EndArray(a)
107+ default:
108+ app.NewError(req, "org.freedesktop.DBus.Error.UnknownMethod", "s").Str("no")
109+ }
110+ _, _ = app.Send()
111+ }
112+ }()
113+
114+ cc, err := zerobus.Dial(addr)
115+ require.NoError(t, err)
116+ b, err := newConn(cc)
117+ require.NoError(t, err)
118+ t.Cleanup(func() { _ = b.Close() })
119+
120+ calls := []call{
121+ {op: opMatches, name: "org.test.App", path: "/frame", rule: matchRule{linksOnly: true}},
122+ {op: opExtents, name: "org.test.App", path: "/one", arg: coordWindow},
123+ {op: opRole, name: "org.test.App", path: "/one"},
124+ {op: opState, name: "org.test.App", path: "/one"},
125+ {op: opChildren, name: "org.test.App", path: "/one"},
126+ }
127+ rep := b.Do(time.Now().Add(2*time.Second), time.Second, calls)
128+ require.Len(t, rep, 5)
129+ require.NoError(t, rep[0].err)
130+ require.Equal(t, []ref{{":1.99", "/one"}, {":1.99", "/two"}}, rep[0].refs)
131+ require.NoError(t, rep[1].err)
132+ require.Equal(t, [4]int32{1, 2, 30, 40}, rep[1].rect)
133+ require.Equal(t, uint32(roleLink), rep[2].u)
134+ require.Equal(t, uint64(stShowing), rep[3].state)
135+ var be *busError
136+ require.ErrorAs(t, rep[4].err, &be)
137+ require.Equal(t, "org.freedesktop.DBus.Error.UnknownMethod", be.name)
138+
139+ // A call to a name nobody owns fails fast, others still succeed.
140+ rep = b.Do(time.Now().Add(2*time.Second), time.Second, []call{
141+ {op: opRole, name: "org.test.Nobody", path: "/x"},
142+ {op: opRole, name: "org.test.App", path: "/x"},
143+ })
144+ require.Error(t, rep[0].err)
145+ require.NoError(t, rep[1].err)
146+}
147+
148+func TestDoTimesOutOnSilentApp(t *testing.T) {
149+ addr := startBus(t)
150+ app, err := zerobus.Dial(addr)
151+ require.NoError(t, err)
152+ t.Cleanup(func() { _ = app.Close() })
153+ _, err = app.RequestName("org.test.Mute", 0)
154+ require.NoError(t, err)
155+
156+ cc, err := zerobus.Dial(addr)
157+ require.NoError(t, err)
158+ b, err := newConn(cc)
159+ require.NoError(t, err)
160+ t.Cleanup(func() { _ = b.Close() })
161+
162+ t0 := time.Now()
163+ rep := b.Do(time.Now().Add(time.Second), 50*time.Millisecond, []call{{op: opRole, name: "org.test.Mute", path: "/x"}})
164+ require.ErrorIs(t, rep[0].err, ErrTimeout)
165+ require.Less(t, time.Since(t0), 500*time.Millisecond)
166+}
A internal/adapters/atspi/doc.go +10 -0
@@ -0,0 +1,10 @@
1+// Package atspi finds hint targets in the accessibility tree of the apps on
2+// screen. It talks to the AT-SPI registry over the accessibility bus with
3+// zerobus, from one connection used by one goroutine at a time, and pipelines
4+// the calls it needs: several are sent before their replies are read, and the
5+// replies are matched by serial.
6+//
7+// The approach (Collection.GetMatches on the window frame, bounded walk when
8+// the toolkit has no Collection, window-relative extents) is adapted from
9+// neru's AT-SPI client, https://github.com/y3owk1n/neru (MIT licence).
10+package atspi
@@ -0,0 +1,10 @@
1+// Package atspi finds hint targets in the accessibility tree of the apps on
2+// screen. It talks to the AT-SPI registry over the accessibility bus with
3+// zerobus, from one connection used by one goroutine at a time, and pipelines
4+// the calls it needs: several are sent before their replies are read, and the
5+// replies are matched by serial.
6+//
7+// The approach (Collection.GetMatches on the window frame, bounded walk when
8+// the toolkit has no Collection, window-relative extents) is adapted from
9+// neru's AT-SPI client, https://github.com/y3owk1n/neru (MIT licence).
10+package atspi
A internal/adapters/atspi/model.go +188 -0
@@ -0,0 +1,188 @@
1+package atspi
2+
3+import "github.com/bnema/neferwl/internal/ports"
4+
5+// AT-SPI constants (atspi-constants.h). The role ids are the AtspiRole enum
6+// values; the state ids are bit indices of the state set.
7+const (
8+ roleCheckBox = 7
9+ roleComboBox = 11
10+ roleDialog = 16
11+ roleFrame = 23
12+ roleMenuItem = 35
13+ rolePageTab = 37
14+ rolePushButton = 43
15+ roleRadioButton = 44
16+ roleToggleButton = 62
17+ roleWindow = 69
18+ roleLink = 88
19+
20+ stateActive = 1
21+ stateShowing = 25
22+ stateVisible = 30
23+
24+ roleWords = 5 // int32 words of a role set in a MatchRule
25+ stateWords = 2 // int32 words of a state set in a MatchRule
26+
27+ // AtspiCollectionMatchType and AtspiCollectionSortOrder values.
28+ matchAll = 1
29+ matchAny = 2
30+ sortCanonical = 1
31+ matchCount = 500
32+ maxDepth = 30
33+ maxWalkNodes = 3000
34+ maxFrames = 32
35+ sizeTolerance = 40
36+ maxMatchesRead = matchCount
37+)
38+
39+// clickableRoles are the roles that become hint targets.
40+var clickableRoles = []uint32{
41+ roleLink, rolePushButton, roleToggleButton, roleCheckBox,
42+ roleRadioButton, roleMenuItem, rolePageTab, roleComboBox,
43+}
44+
45+// matchRule is the Collection query: showing and visible objects of the
46+// clickable roles (only links when linksOnly).
47+type matchRule struct{ linksOnly bool }
48+
49+func (r matchRule) roles() []uint32 {
50+ if r.linksOnly {
51+ return []uint32{roleLink}
52+ }
53+ return clickableRoles
54+}
55+
56+// roleBits packs role ids into the bit set of a MatchRule: bit id%32 of word
57+// id/32.
58+func roleBits(ids []uint32) []int32 {
59+ words := make([]int32, roleWords)
60+ for _, id := range ids {
61+ if w := int(id / 32); w < len(words) {
62+ words[w] |= int32(uint32(1) << (id % 32))
63+ }
64+ }
65+ return words
66+}
67+
68+// stateBits packs state ids into the bit set of a MatchRule.
69+func stateBits(ids ...uint32) []int32 {
70+ words := make([]int32, stateWords)
71+ for _, id := range ids {
72+ if w := int(id / 32); w < len(words) {
73+ words[w] |= int32(uint32(1) << (id % 32))
74+ }
75+ }
76+ return words
77+}
78+
79+func hasState(set uint64, state uint) bool { return set&(1<<state) != 0 }
80+
81+// isClickable reports whether role makes a target.
82+func isClickable(role uint32, linksOnly bool) bool {
83+ if linksOnly {
84+ return role == roleLink
85+ }
86+ for _, r := range clickableRoles {
87+ if r == role {
88+ return true
89+ }
90+ }
91+ return false
92+}
93+
94+// isWindowRole reports whether role is a top-level window role.
95+func isWindowRole(role uint32) bool {
96+ return role == roleFrame || role == roleWindow || role == roleDialog
97+}
98+
99+// kindOf maps a role to the hint kind: links are links, the rest buttons.
100+func kindOf(role uint32) ports.HintKind {
101+ if role == roleLink {
102+ return ports.HintLink
103+ }
104+ return ports.HintButton
105+}
106+
107+// frameInfo is a showing top-level window of an app.
108+type frameInfo struct {
109+ ref ref
110+ active bool
111+ w, h int // size of the frame, from its window extents
112+}
113+
114+// matchFrames pairs the windows of one app with its frames. A window is
115+// paired with a frame when the pairing is certain:
116+// - one window and one frame;
117+// - the focused window with the one ACTIVE frame;
118+// - otherwise the one frame whose size is within sizeTolerance of the
119+// window, provided no other window claims it as well.
120+//
121+// Ambiguous windows are left out. The result maps the index of a window in
122+// wins to the index of its frame in frames.
123+func matchFrames(wins []ports.HintWindow, frames []frameInfo) map[int]int {
124+ out := map[int]int{}
125+ if len(wins) == 1 && len(frames) == 1 {
126+ out[0] = 0
127+ return out
128+ }
129+ claims := make([][]int, len(wins)) // size candidates per window
130+ owners := make([]int, len(frames)) // windows that size-match a frame
131+ for i, w := range wins {
132+ for j, f := range frames {
133+ if absInt(f.w-w.Rect.W) <= sizeTolerance && absInt(f.h-w.Rect.H) <= sizeTolerance {
134+ claims[i] = append(claims[i], j)
135+ owners[j]++
136+ }
137+ }
138+ }
139+ for i, w := range wins {
140+ if w.Focused {
141+ active := -1
142+ n := 0
143+ for j, f := range frames {
144+ if f.active {
145+ active, n = j, n+1
146+ }
147+ }
148+ if n == 1 {
149+ out[i] = active
150+ continue
151+ }
152+ }
153+ if len(claims[i]) == 1 && owners[claims[i][0]] == 1 {
154+ out[i] = claims[i][0]
155+ }
156+ }
157+ return out
158+}
159+
160+func absInt(v int) int {
161+ if v < 0 {
162+ return -v
163+ }
164+ return v
165+}
166+
167+// element is a match with its extents.
168+type element struct {
169+ role uint32
170+ x, y, w, h int32 // window-relative
171+}
172+
173+// toTarget converts an element of a window into a logical output-local
174+// target: the element extents are relative to the top-level frame, whose own
175+// window-relative origin is (fx, fy); the result is clipped to the window.
176+// It reports false for an empty result.
177+func toTarget(win ports.Rect, fx, fy int32, e element) (ports.HintTarget, bool) {
178+ if e.w <= 0 || e.h <= 0 {
179+ return ports.HintTarget{}, false
180+ }
181+ r := ports.Rect{X: win.X + int(e.x-fx), Y: win.Y + int(e.y-fy), W: int(e.w), H: int(e.h)}
182+ x0, y0 := max(r.X, win.X), max(r.Y, win.Y)
183+ x1, y1 := min(r.X+r.W, win.X+win.W), min(r.Y+r.H, win.Y+win.H)
184+ if x1 <= x0 || y1 <= y0 {
185+ return ports.HintTarget{}, false
186+ }
187+ return ports.HintTarget{Rect: ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}, Kind: kindOf(e.role)}, true
188+}
@@ -0,0 +1,188 @@
1+package atspi
2+
3+import "github.com/bnema/neferwl/internal/ports"
4+
5+// AT-SPI constants (atspi-constants.h). The role ids are the AtspiRole enum
6+// values; the state ids are bit indices of the state set.
7+const (
8+ roleCheckBox = 7
9+ roleComboBox = 11
10+ roleDialog = 16
11+ roleFrame = 23
12+ roleMenuItem = 35
13+ rolePageTab = 37
14+ rolePushButton = 43
15+ roleRadioButton = 44
16+ roleToggleButton = 62
17+ roleWindow = 69
18+ roleLink = 88
19+
20+ stateActive = 1
21+ stateShowing = 25
22+ stateVisible = 30
23+
24+ roleWords = 5 // int32 words of a role set in a MatchRule
25+ stateWords = 2 // int32 words of a state set in a MatchRule
26+
27+ // AtspiCollectionMatchType and AtspiCollectionSortOrder values.
28+ matchAll = 1
29+ matchAny = 2
30+ sortCanonical = 1
31+ matchCount = 500
32+ maxDepth = 30
33+ maxWalkNodes = 3000
34+ maxFrames = 32
35+ sizeTolerance = 40
36+ maxMatchesRead = matchCount
37+)
38+
39+// clickableRoles are the roles that become hint targets.
40+var clickableRoles = []uint32{
41+ roleLink, rolePushButton, roleToggleButton, roleCheckBox,
42+ roleRadioButton, roleMenuItem, rolePageTab, roleComboBox,
43+}
44+
45+// matchRule is the Collection query: showing and visible objects of the
46+// clickable roles (only links when linksOnly).
47+type matchRule struct{ linksOnly bool }
48+
49+func (r matchRule) roles() []uint32 {
50+ if r.linksOnly {
51+ return []uint32{roleLink}
52+ }
53+ return clickableRoles
54+}
55+
56+// roleBits packs role ids into the bit set of a MatchRule: bit id%32 of word
57+// id/32.
58+func roleBits(ids []uint32) []int32 {
59+ words := make([]int32, roleWords)
60+ for _, id := range ids {
61+ if w := int(id / 32); w < len(words) {
62+ words[w] |= int32(uint32(1) << (id % 32))
63+ }
64+ }
65+ return words
66+}
67+
68+// stateBits packs state ids into the bit set of a MatchRule.
69+func stateBits(ids ...uint32) []int32 {
70+ words := make([]int32, stateWords)
71+ for _, id := range ids {
72+ if w := int(id / 32); w < len(words) {
73+ words[w] |= int32(uint32(1) << (id % 32))
74+ }
75+ }
76+ return words
77+}
78+
79+func hasState(set uint64, state uint) bool { return set&(1<<state) != 0 }
80+
81+// isClickable reports whether role makes a target.
82+func isClickable(role uint32, linksOnly bool) bool {
83+ if linksOnly {
84+ return role == roleLink
85+ }
86+ for _, r := range clickableRoles {
87+ if r == role {
88+ return true
89+ }
90+ }
91+ return false
92+}
93+
94+// isWindowRole reports whether role is a top-level window role.
95+func isWindowRole(role uint32) bool {
96+ return role == roleFrame || role == roleWindow || role == roleDialog
97+}
98+
99+// kindOf maps a role to the hint kind: links are links, the rest buttons.
100+func kindOf(role uint32) ports.HintKind {
101+ if role == roleLink {
102+ return ports.HintLink
103+ }
104+ return ports.HintButton
105+}
106+
107+// frameInfo is a showing top-level window of an app.
108+type frameInfo struct {
109+ ref ref
110+ active bool
111+ w, h int // size of the frame, from its window extents
112+}
113+
114+// matchFrames pairs the windows of one app with its frames. A window is
115+// paired with a frame when the pairing is certain:
116+// - one window and one frame;
117+// - the focused window with the one ACTIVE frame;
118+// - otherwise the one frame whose size is within sizeTolerance of the
119+// window, provided no other window claims it as well.
120+//
121+// Ambiguous windows are left out. The result maps the index of a window in
122+// wins to the index of its frame in frames.
123+func matchFrames(wins []ports.HintWindow, frames []frameInfo) map[int]int {
124+ out := map[int]int{}
125+ if len(wins) == 1 && len(frames) == 1 {
126+ out[0] = 0
127+ return out
128+ }
129+ claims := make([][]int, len(wins)) // size candidates per window
130+ owners := make([]int, len(frames)) // windows that size-match a frame
131+ for i, w := range wins {
132+ for j, f := range frames {
133+ if absInt(f.w-w.Rect.W) <= sizeTolerance && absInt(f.h-w.Rect.H) <= sizeTolerance {
134+ claims[i] = append(claims[i], j)
135+ owners[j]++
136+ }
137+ }
138+ }
139+ for i, w := range wins {
140+ if w.Focused {
141+ active := -1
142+ n := 0
143+ for j, f := range frames {
144+ if f.active {
145+ active, n = j, n+1
146+ }
147+ }
148+ if n == 1 {
149+ out[i] = active
150+ continue
151+ }
152+ }
153+ if len(claims[i]) == 1 && owners[claims[i][0]] == 1 {
154+ out[i] = claims[i][0]
155+ }
156+ }
157+ return out
158+}
159+
160+func absInt(v int) int {
161+ if v < 0 {
162+ return -v
163+ }
164+ return v
165+}
166+
167+// element is a match with its extents.
168+type element struct {
169+ role uint32
170+ x, y, w, h int32 // window-relative
171+}
172+
173+// toTarget converts an element of a window into a logical output-local
174+// target: the element extents are relative to the top-level frame, whose own
175+// window-relative origin is (fx, fy); the result is clipped to the window.
176+// It reports false for an empty result.
177+func toTarget(win ports.Rect, fx, fy int32, e element) (ports.HintTarget, bool) {
178+ if e.w <= 0 || e.h <= 0 {
179+ return ports.HintTarget{}, false
180+ }
181+ r := ports.Rect{X: win.X + int(e.x-fx), Y: win.Y + int(e.y-fy), W: int(e.w), H: int(e.h)}
182+ x0, y0 := max(r.X, win.X), max(r.Y, win.Y)
183+ x1, y1 := min(r.X+r.W, win.X+win.W), min(r.Y+r.H, win.Y+win.H)
184+ if x1 <= x0 || y1 <= y0 {
185+ return ports.HintTarget{}, false
186+ }
187+ return ports.HintTarget{Rect: ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}, Kind: kindOf(e.role)}, true
188+}
A internal/adapters/atspi/model_test.go +104 -0
@@ -0,0 +1,104 @@
1+package atspi
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestToTargetConvertsWindowCoordinates(t *testing.T) {
12+ win := ports.Rect{X: 100, Y: 50, W: 800, H: 600}
13+ // The frame origin (fx, fy) is subtracted: toolkit offsets cancel out.
14+ got, ok := toTarget(win, 10, 20, element{role: roleLink, x: 110, y: 120, w: 40, h: 16})
15+ require.True(t, ok)
16+ require.Equal(t, ports.HintTarget{Rect: ports.Rect{X: 200, Y: 150, W: 40, H: 16}, Kind: ports.HintLink}, got)
17+}
18+
19+func TestToTargetClipsToWindow(t *testing.T) {
20+ win := ports.Rect{X: 100, Y: 50, W: 200, H: 100}
21+ got, ok := toTarget(win, 0, 0, element{role: rolePushButton, x: 180, y: 90, w: 60, h: 30})
22+ require.True(t, ok)
23+ require.Equal(t, ports.Rect{X: 280, Y: 140, W: 20, H: 10}, got.Rect)
24+ require.Equal(t, ports.HintButton, got.Kind)
25+}
26+
27+func TestToTargetDropsEmptyAndOutside(t *testing.T) {
28+ win := ports.Rect{X: 0, Y: 0, W: 100, H: 100}
29+ for name, e := range map[string]element{
30+ "zero width": {role: roleLink, x: 1, y: 1, w: 0, h: 10},
31+ "neg height": {role: roleLink, x: 1, y: 1, w: 10, h: -1},
32+ "outside": {role: roleLink, x: 500, y: 1, w: 10, h: 10},
33+ "above": {role: roleLink, x: 1, y: -50, w: 10, h: 10},
34+ "left of win": {role: roleLink, x: -30, y: 1, w: 10, h: 10},
35+ } {
36+ _, ok := toTarget(win, 0, 0, e)
37+ require.False(t, ok, name)
38+ }
39+}
40+
41+func TestRoleKindMapping(t *testing.T) {
42+ require.Equal(t, ports.HintLink, kindOf(roleLink))
43+ for _, r := range []uint32{rolePushButton, roleToggleButton, roleCheckBox, roleRadioButton, roleMenuItem, rolePageTab, roleComboBox} {
44+ require.Equal(t, ports.HintButton, kindOf(r), r)
45+ require.True(t, isClickable(r, false), r)
46+ require.False(t, isClickable(r, true), r)
47+ }
48+ require.True(t, isClickable(roleLink, true))
49+ require.False(t, isClickable(roleFrame, false))
50+ require.True(t, isWindowRole(roleFrame) && isWindowRole(roleWindow) && isWindowRole(roleDialog))
51+ require.False(t, isWindowRole(roleLink))
52+}
53+
54+func TestRoleAndStateBits(t *testing.T) {
55+ require.Equal(t, []int32{0, 0, 0, 0, 0}, roleBits(nil))
56+ // link is role 88: word 2, bit 24; push button is 43: word 1, bit 11.
57+ require.Equal(t, []int32{0, 1 << 11, 1 << 24, 0, 0}, roleBits([]uint32{roleLink, rolePushButton}))
58+ // showing is state 25, visible 30: both in word 0.
59+ require.Equal(t, []int32{1<<25 | 1<<30, 0}, stateBits(stateShowing, stateVisible))
60+}
61+
62+func wins(ws ...ports.HintWindow) []ports.HintWindow { return ws }
63+
64+func win(id ports.WindowID, w, h int, focused bool) ports.HintWindow {
65+ return ports.HintWindow{ID: id, PID: 1, Rect: ports.Rect{W: w, H: h}, Focused: focused}
66+}
67+
68+func TestMatchFramesSingle(t *testing.T) {
69+ got := matchFrames(wins(win(1, 800, 600, false)), []frameInfo{{w: 100, h: 100}})
70+ require.Equal(t, map[int]int{0: 0}, got)
71+}
72+
73+func TestMatchFramesActive(t *testing.T) {
74+ frames := []frameInfo{{w: 800, h: 600}, {w: 800, h: 600, active: true}}
75+ got := matchFrames(wins(win(1, 800, 600, true), win(2, 800, 600, false)), frames)
76+ // The focused window takes the ACTIVE frame; the other cannot be told
77+ // apart by size (both frames match it) and is left out.
78+ require.Equal(t, map[int]int{0: 1}, got)
79+}
80+
81+func TestMatchFramesSize(t *testing.T) {
82+ frames := []frameInfo{{w: 1000, h: 700}, {w: 400, h: 300}}
83+ got := matchFrames(wins(win(1, 1000, 700, false), win(2, 420, 290, false)), frames)
84+ require.Equal(t, map[int]int{0: 0, 1: 1}, got)
85+}
86+
87+func TestMatchFramesSizeTolerance(t *testing.T) {
88+ frames := []frameInfo{{w: 840, h: 640}, {w: 100, h: 100}}
89+ other := win(2, 90, 90, false)
90+ // 40 px off still matches; 41 px does not.
91+ require.Equal(t, map[int]int{0: 0, 1: 1}, matchFrames(wins(win(1, 800, 600, false), other), frames))
92+ require.Equal(t, map[int]int{1: 1}, matchFrames(wins(win(1, 799, 600, false), other), frames))
93+}
94+
95+func TestMatchFramesAmbiguous(t *testing.T) {
96+ // Two windows and two frames of the same size, none active: skipped.
97+ frames := []frameInfo{{w: 800, h: 600}, {w: 810, h: 600}}
98+ got := matchFrames(wins(win(1, 800, 600, false), win(2, 800, 600, false)), frames)
99+ require.Empty(t, got)
100+ // One window, two same-size frames, not focused: skipped.
101+ require.Empty(t, matchFrames(wins(win(1, 800, 600, false)), frames))
102+ // No frame close enough.
103+ require.Empty(t, matchFrames(wins(win(1, 200, 200, false), win(2, 300, 300, false)), frames))
104+}
@@ -0,0 +1,104 @@
1+package atspi
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestToTargetConvertsWindowCoordinates(t *testing.T) {
12+ win := ports.Rect{X: 100, Y: 50, W: 800, H: 600}
13+ // The frame origin (fx, fy) is subtracted: toolkit offsets cancel out.
14+ got, ok := toTarget(win, 10, 20, element{role: roleLink, x: 110, y: 120, w: 40, h: 16})
15+ require.True(t, ok)
16+ require.Equal(t, ports.HintTarget{Rect: ports.Rect{X: 200, Y: 150, W: 40, H: 16}, Kind: ports.HintLink}, got)
17+}
18+
19+func TestToTargetClipsToWindow(t *testing.T) {
20+ win := ports.Rect{X: 100, Y: 50, W: 200, H: 100}
21+ got, ok := toTarget(win, 0, 0, element{role: rolePushButton, x: 180, y: 90, w: 60, h: 30})
22+ require.True(t, ok)
23+ require.Equal(t, ports.Rect{X: 280, Y: 140, W: 20, H: 10}, got.Rect)
24+ require.Equal(t, ports.HintButton, got.Kind)
25+}
26+
27+func TestToTargetDropsEmptyAndOutside(t *testing.T) {
28+ win := ports.Rect{X: 0, Y: 0, W: 100, H: 100}
29+ for name, e := range map[string]element{
30+ "zero width": {role: roleLink, x: 1, y: 1, w: 0, h: 10},
31+ "neg height": {role: roleLink, x: 1, y: 1, w: 10, h: -1},
32+ "outside": {role: roleLink, x: 500, y: 1, w: 10, h: 10},
33+ "above": {role: roleLink, x: 1, y: -50, w: 10, h: 10},
34+ "left of win": {role: roleLink, x: -30, y: 1, w: 10, h: 10},
35+ } {
36+ _, ok := toTarget(win, 0, 0, e)
37+ require.False(t, ok, name)
38+ }
39+}
40+
41+func TestRoleKindMapping(t *testing.T) {
42+ require.Equal(t, ports.HintLink, kindOf(roleLink))
43+ for _, r := range []uint32{rolePushButton, roleToggleButton, roleCheckBox, roleRadioButton, roleMenuItem, rolePageTab, roleComboBox} {
44+ require.Equal(t, ports.HintButton, kindOf(r), r)
45+ require.True(t, isClickable(r, false), r)
46+ require.False(t, isClickable(r, true), r)
47+ }
48+ require.True(t, isClickable(roleLink, true))
49+ require.False(t, isClickable(roleFrame, false))
50+ require.True(t, isWindowRole(roleFrame) && isWindowRole(roleWindow) && isWindowRole(roleDialog))
51+ require.False(t, isWindowRole(roleLink))
52+}
53+
54+func TestRoleAndStateBits(t *testing.T) {
55+ require.Equal(t, []int32{0, 0, 0, 0, 0}, roleBits(nil))
56+ // link is role 88: word 2, bit 24; push button is 43: word 1, bit 11.
57+ require.Equal(t, []int32{0, 1 << 11, 1 << 24, 0, 0}, roleBits([]uint32{roleLink, rolePushButton}))
58+ // showing is state 25, visible 30: both in word 0.
59+ require.Equal(t, []int32{1<<25 | 1<<30, 0}, stateBits(stateShowing, stateVisible))
60+}
61+
62+func wins(ws ...ports.HintWindow) []ports.HintWindow { return ws }
63+
64+func win(id ports.WindowID, w, h int, focused bool) ports.HintWindow {
65+ return ports.HintWindow{ID: id, PID: 1, Rect: ports.Rect{W: w, H: h}, Focused: focused}
66+}
67+
68+func TestMatchFramesSingle(t *testing.T) {
69+ got := matchFrames(wins(win(1, 800, 600, false)), []frameInfo{{w: 100, h: 100}})
70+ require.Equal(t, map[int]int{0: 0}, got)
71+}
72+
73+func TestMatchFramesActive(t *testing.T) {
74+ frames := []frameInfo{{w: 800, h: 600}, {w: 800, h: 600, active: true}}
75+ got := matchFrames(wins(win(1, 800, 600, true), win(2, 800, 600, false)), frames)
76+ // The focused window takes the ACTIVE frame; the other cannot be told
77+ // apart by size (both frames match it) and is left out.
78+ require.Equal(t, map[int]int{0: 1}, got)
79+}
80+
81+func TestMatchFramesSize(t *testing.T) {
82+ frames := []frameInfo{{w: 1000, h: 700}, {w: 400, h: 300}}
83+ got := matchFrames(wins(win(1, 1000, 700, false), win(2, 420, 290, false)), frames)
84+ require.Equal(t, map[int]int{0: 0, 1: 1}, got)
85+}
86+
87+func TestMatchFramesSizeTolerance(t *testing.T) {
88+ frames := []frameInfo{{w: 840, h: 640}, {w: 100, h: 100}}
89+ other := win(2, 90, 90, false)
90+ // 40 px off still matches; 41 px does not.
91+ require.Equal(t, map[int]int{0: 0, 1: 1}, matchFrames(wins(win(1, 800, 600, false), other), frames))
92+ require.Equal(t, map[int]int{1: 1}, matchFrames(wins(win(1, 799, 600, false), other), frames))
93+}
94+
95+func TestMatchFramesAmbiguous(t *testing.T) {
96+ // Two windows and two frames of the same size, none active: skipped.
97+ frames := []frameInfo{{w: 800, h: 600}, {w: 810, h: 600}}
98+ got := matchFrames(wins(win(1, 800, 600, false), win(2, 800, 600, false)), frames)
99+ require.Empty(t, got)
100+ // One window, two same-size frames, not focused: skipped.
101+ require.Empty(t, matchFrames(wins(win(1, 800, 600, false)), frames))
102+ // No frame close enough.
103+ require.Empty(t, matchFrames(wins(win(1, 200, 200, false), win(2, 300, 300, false)), frames))
104+}
A internal/adapters/atspi/source.go +296 -0
@@ -0,0 +1,296 @@
1+package atspi
2+
3+import (
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+
16+const (
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+// Source reads hint targets from the accessibility tree. It owns one
24+// connection to the accessibility bus and is used by one goroutine at a
25+// time (the hints worker).
26+type Source struct {
27+ tr transport
28+}
29+
30+// New enables accessibility on the session and connects to the
31+// accessibility bus.
32+func New(ctx context.Context) (*Source, error) {
33+ log := logging.For(ctx, "atspi")
34+ sess, err := zerobus.SessionBus()
35+ if err != nil {
36+ return nil, fmt.Errorf("atspi: session bus: %w", err)
37+ }
38+ defer func() { _ = sess.Close() }()
39+
40+ sess.NewCall(a11yBusName, a11yBusPath, a11yBusName, "GetAddress", "")
41+ m, err := sess.Call()
42+ if err != nil {
43+ return nil, fmt.Errorf("atspi: org.a11y.Bus GetAddress: %w", err)
44+ }
45+ r := m.Body()
46+ addr := r.Str()
47+ if r.Err() != nil || addr == "" {
48+ return nil, errors.New("atspi: no accessibility bus address")
49+ }
50+ addr = strings.Clone(addr)
51+
52+ e := sess.NewCall(a11yBusName, a11yBusPath, ifaceProps, "Set", "ssv")
53+ e.Str(a11yStatus)
54+ e.Str("IsEnabled")
55+ e.Variant("b")
56+ e.Bool(true)
57+ if _, err := sess.Call(); err != nil {
58+ // Toolkits that already see the bus keep working.
59+ log.Warn().Err(err).Msg("could not enable accessibility")
60+ } else {
61+ log.Info().Msg("accessibility enabled")
62+ }
63+
64+ zc, err := zerobus.Dial(addr)
65+ if err != nil {
66+ return nil, fmt.Errorf("atspi: accessibility bus: %w", err)
67+ }
68+ tr, err := newConn(zc)
69+ if err != nil {
70+ return nil, err
71+ }
72+ log.Info().Msg("accessibility bus connected")
73+ return &Source{tr: tr}, nil
74+}
75+
76+// Close closes the accessibility bus connection.
77+func (s *Source) Close() error { return s.tr.Close() }
78+
79+// appInfo is an application of the registry whose process shows a window.
80+type appInfo struct {
81+ root ref
82+ pid int
83+}
84+
85+// winMatch is a window with the frame it was paired with.
86+type winMatch struct {
87+ win ports.HintWindow
88+ frame ref
89+ fx int32
90+ fy int32
91+}
92+
93+// Targets returns the clickable targets of the accessibility tree for
94+// windows (logical output-local, clipped to each window) and the set of
95+// windows those targets cover. A window is covered when its tree answered
96+// with at least one target; the pixels of an uncovered window are kept. It
97+// returns what is ready within the budget.
98+func (s *Source) Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) {
99+ covered := map[ports.WindowID]bool{}
100+ deadline := time.Now().Add(budget)
101+ if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
102+ deadline = d
103+ }
104+ do := func(calls []call) []reply {
105+ if len(calls) == 0 || ctx.Err() != nil {
106+ return make([]reply, len(calls))
107+ }
108+ return s.tr.Do(deadline, callTimeout, calls)
109+ }
110+
111+ wanted := map[int]bool{}
112+ for _, w := range windows {
113+ if w.PID > 0 {
114+ wanted[w.PID] = true
115+ }
116+ }
117+ if len(wanted) == 0 {
118+ return nil, covered, nil
119+ }
120+
121+ // Applications of the registry, kept when their process shows a window.
122+ rep := do([]call{{op: opChildren, name: registryName, path: registryRoot}})
123+ if len(rep) == 0 || rep[0].err != nil {
124+ if len(rep) > 0 {
125+ return nil, covered, fmt.Errorf("atspi: registry: %w", rep[0].err)
126+ }
127+ return nil, covered, nil
128+ }
129+ apps := rep[0].refs
130+ calls := make([]call, len(apps))
131+ for i, a := range apps {
132+ calls[i] = call{op: opPID, name: a.name}
133+ }
134+ var kept []appInfo
135+ for i, r := range do(calls) {
136+ if r.err == nil && wanted[int(r.u)] {
137+ kept = append(kept, appInfo{root: apps[i], pid: int(r.u)})
138+ }
139+ }
140+
141+ // The showing windows of each kept app.
142+ calls = calls[:0]
143+ for _, a := range kept {
144+ calls = append(calls, call{op: opChildren, name: a.root.name, path: a.root.path})
145+ }
146+ var cands []ref
147+ var candApp []int
148+ for i, r := range do(calls) {
149+ if r.err != nil {
150+ continue
151+ }
152+ for _, c := range r.refs[:min(len(r.refs), maxFrames)] {
153+ cands = append(cands, c)
154+ candApp = append(candApp, i)
155+ }
156+ }
157+ calls = calls[:0]
158+ for _, c := range cands {
159+ calls = append(calls,
160+ call{op: opRole, name: c.name, path: c.path},
161+ call{op: opState, name: c.name, path: c.path},
162+ call{op: opExtents, name: c.name, path: c.path, arg: coordWindow})
163+ }
164+ info := do(calls)
165+ type frame struct {
166+ frameInfo
167+ pid int
168+ fx, fy int32
169+ }
170+ var frames []frame
171+ for i, c := range cands {
172+ role, state, ext := info[3*i], info[3*i+1], info[3*i+2]
173+ if role.err != nil || state.err != nil || ext.err != nil || !isWindowRole(role.u) || !hasState(state.state, stateShowing) {
174+ continue
175+ }
176+ frames = append(frames, frame{
177+ frameInfo: frameInfo{ref: c, active: hasState(state.state, stateActive), w: int(ext.rect[2]), h: int(ext.rect[3])},
178+ pid: kept[candApp[i]].pid, fx: ext.rect[0], fy: ext.rect[1],
179+ })
180+ }
181+
182+ // Pair the windows with the frames, process by process.
183+ var matched []winMatch
184+ for pid := range wanted {
185+ var wins []ports.HintWindow
186+ for _, w := range windows {
187+ if w.PID == pid {
188+ wins = append(wins, w)
189+ }
190+ }
191+ var fs []frameInfo
192+ var origin []frame
193+ for _, f := range frames {
194+ if f.pid == pid {
195+ fs = append(fs, f.frameInfo)
196+ origin = append(origin, f)
197+ }
198+ }
199+ for wi, fi := range matchFrames(wins, fs) {
200+ matched = append(matched, winMatch{win: wins[wi], frame: origin[fi].ref, fx: origin[fi].fx, fy: origin[fi].fy})
201+ }
202+ }
203+
204+ targets := s.elements(do, matched, linksOnly, covered)
205+ log := logging.For(ctx, "atspi")
206+ log.Debug().Int("apps", len(kept)).Int("frames", len(frames)).Int("windows", len(matched)).Int("targets", len(targets)).Msg("atspi targets")
207+ return targets, covered, nil
208+}
209+
210+// elements reads the clickable elements of the matched frames.
211+func (s *Source) elements(do func([]call) []reply, matched []winMatch, linksOnly bool, covered map[ports.WindowID]bool) []ports.HintTarget {
212+ calls := make([]call, len(matched))
213+ for i, m := range matched {
214+ calls[i] = call{op: opMatches, name: m.frame.name, path: m.frame.path, rule: matchRule{linksOnly: linksOnly}}
215+ }
216+ found := make([][]ref, len(matched))
217+ var walk []int // frames without Collection
218+ for i, r := range do(calls) {
219+ if r.err != nil {
220+ var be *busError
221+ if errors.As(r.err, &be) {
222+ walk = append(walk, i)
223+ }
224+ continue
225+ }
226+ found[i] = r.refs
227+ }
228+ for _, i := range walk {
229+ found[i] = s.walk(do, matched[i].frame, linksOnly)
230+ }
231+
232+ calls = calls[:0]
233+ var owner []int
234+ for i, refs := range found {
235+ for _, r := range refs {
236+ calls = append(calls,
237+ call{op: opRole, name: r.name, path: r.path},
238+ call{op: opExtents, name: r.name, path: r.path, arg: coordWindow})
239+ owner = append(owner, i)
240+ }
241+ }
242+ info := do(calls)
243+ var out []ports.HintTarget
244+ for k, i := range owner {
245+ role, ext := info[2*k], info[2*k+1]
246+ if role.err != nil || ext.err != nil || !isClickable(role.u, linksOnly) {
247+ continue
248+ }
249+ m := matched[i]
250+ 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]})
251+ if !ok {
252+ continue
253+ }
254+ out = append(out, t)
255+ covered[m.win.ID] = true
256+ }
257+ return out
258+}
259+
260+// walk is the fallback for toolkits without Collection: a breadth-first
261+// walk of at most maxDepth levels and maxWalkNodes nodes, returning the
262+// clickable nodes.
263+func (s *Source) walk(do func([]call) []reply, root ref, linksOnly bool) []ref {
264+ var out []ref
265+ level := []ref{root}
266+ seen := 0
267+ for depth := 0; depth < maxDepth && len(level) > 0 && seen < maxWalkNodes; depth++ {
268+ calls := make([]call, 0, 3*len(level))
269+ for _, n := range level {
270+ calls = append(calls,
271+ call{op: opChildren, name: n.name, path: n.path},
272+ call{op: opRole, name: n.name, path: n.path},
273+ call{op: opState, name: n.name, path: n.path})
274+ }
275+ rep := do(calls)
276+ var next []ref
277+ for i, n := range level {
278+ ch, role, state := rep[3*i], rep[3*i+1], rep[3*i+2]
279+ if state.err == nil && !hasState(state.state, stateShowing) {
280+ continue
281+ }
282+ if role.err == nil && isClickable(role.u, linksOnly) && state.err == nil && hasState(state.state, stateVisible) {
283+ out = append(out, n)
284+ }
285+ if ch.err == nil {
286+ next = append(next, ch.refs...)
287+ }
288+ }
289+ seen += len(level)
290+ if room := maxWalkNodes - seen; len(next) > room {
291+ next = next[:max(room, 0)]
292+ }
293+ level = next
294+ }
295+ return out
296+}
@@ -0,0 +1,296 @@
1+package atspi
2+
3+import (
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+
16+const (
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+// Source reads hint targets from the accessibility tree. It owns one
24+// connection to the accessibility bus and is used by one goroutine at a
25+// time (the hints worker).
26+type Source struct {
27+ tr transport
28+}
29+
30+// New enables accessibility on the session and connects to the
31+// accessibility bus.
32+func New(ctx context.Context) (*Source, error) {
33+ log := logging.For(ctx, "atspi")
34+ sess, err := zerobus.SessionBus()
35+ if err != nil {
36+ return nil, fmt.Errorf("atspi: session bus: %w", err)
37+ }
38+ defer func() { _ = sess.Close() }()
39+
40+ sess.NewCall(a11yBusName, a11yBusPath, a11yBusName, "GetAddress", "")
41+ m, err := sess.Call()
42+ if err != nil {
43+ return nil, fmt.Errorf("atspi: org.a11y.Bus GetAddress: %w", err)
44+ }
45+ r := m.Body()
46+ addr := r.Str()
47+ if r.Err() != nil || addr == "" {
48+ return nil, errors.New("atspi: no accessibility bus address")
49+ }
50+ addr = strings.Clone(addr)
51+
52+ e := sess.NewCall(a11yBusName, a11yBusPath, ifaceProps, "Set", "ssv")
53+ e.Str(a11yStatus)
54+ e.Str("IsEnabled")
55+ e.Variant("b")
56+ e.Bool(true)
57+ if _, err := sess.Call(); err != nil {
58+ // Toolkits that already see the bus keep working.
59+ log.Warn().Err(err).Msg("could not enable accessibility")
60+ } else {
61+ log.Info().Msg("accessibility enabled")
62+ }
63+
64+ zc, err := zerobus.Dial(addr)
65+ if err != nil {
66+ return nil, fmt.Errorf("atspi: accessibility bus: %w", err)
67+ }
68+ tr, err := newConn(zc)
69+ if err != nil {
70+ return nil, err
71+ }
72+ log.Info().Msg("accessibility bus connected")
73+ return &Source{tr: tr}, nil
74+}
75+
76+// Close closes the accessibility bus connection.
77+func (s *Source) Close() error { return s.tr.Close() }
78+
79+// appInfo is an application of the registry whose process shows a window.
80+type appInfo struct {
81+ root ref
82+ pid int
83+}
84+
85+// winMatch is a window with the frame it was paired with.
86+type winMatch struct {
87+ win ports.HintWindow
88+ frame ref
89+ fx int32
90+ fy int32
91+}
92+
93+// Targets returns the clickable targets of the accessibility tree for
94+// windows (logical output-local, clipped to each window) and the set of
95+// windows those targets cover. A window is covered when its tree answered
96+// with at least one target; the pixels of an uncovered window are kept. It
97+// returns what is ready within the budget.
98+func (s *Source) Targets(ctx context.Context, windows []ports.HintWindow, linksOnly bool) ([]ports.HintTarget, map[ports.WindowID]bool, error) {
99+ covered := map[ports.WindowID]bool{}
100+ deadline := time.Now().Add(budget)
101+ if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
102+ deadline = d
103+ }
104+ do := func(calls []call) []reply {
105+ if len(calls) == 0 || ctx.Err() != nil {
106+ return make([]reply, len(calls))
107+ }
108+ return s.tr.Do(deadline, callTimeout, calls)
109+ }
110+
111+ wanted := map[int]bool{}
112+ for _, w := range windows {
113+ if w.PID > 0 {
114+ wanted[w.PID] = true
115+ }
116+ }
117+ if len(wanted) == 0 {
118+ return nil, covered, nil
119+ }
120+
121+ // Applications of the registry, kept when their process shows a window.
122+ rep := do([]call{{op: opChildren, name: registryName, path: registryRoot}})
123+ if len(rep) == 0 || rep[0].err != nil {
124+ if len(rep) > 0 {
125+ return nil, covered, fmt.Errorf("atspi: registry: %w", rep[0].err)
126+ }
127+ return nil, covered, nil
128+ }
129+ apps := rep[0].refs
130+ calls := make([]call, len(apps))
131+ for i, a := range apps {
132+ calls[i] = call{op: opPID, name: a.name}
133+ }
134+ var kept []appInfo
135+ for i, r := range do(calls) {
136+ if r.err == nil && wanted[int(r.u)] {
137+ kept = append(kept, appInfo{root: apps[i], pid: int(r.u)})
138+ }
139+ }
140+
141+ // The showing windows of each kept app.
142+ calls = calls[:0]
143+ for _, a := range kept {
144+ calls = append(calls, call{op: opChildren, name: a.root.name, path: a.root.path})
145+ }
146+ var cands []ref
147+ var candApp []int
148+ for i, r := range do(calls) {
149+ if r.err != nil {
150+ continue
151+ }
152+ for _, c := range r.refs[:min(len(r.refs), maxFrames)] {
153+ cands = append(cands, c)
154+ candApp = append(candApp, i)
155+ }
156+ }
157+ calls = calls[:0]
158+ for _, c := range cands {
159+ calls = append(calls,
160+ call{op: opRole, name: c.name, path: c.path},
161+ call{op: opState, name: c.name, path: c.path},
162+ call{op: opExtents, name: c.name, path: c.path, arg: coordWindow})
163+ }
164+ info := do(calls)
165+ type frame struct {
166+ frameInfo
167+ pid int
168+ fx, fy int32
169+ }
170+ var frames []frame
171+ for i, c := range cands {
172+ role, state, ext := info[3*i], info[3*i+1], info[3*i+2]
173+ if role.err != nil || state.err != nil || ext.err != nil || !isWindowRole(role.u) || !hasState(state.state, stateShowing) {
174+ continue
175+ }
176+ frames = append(frames, frame{
177+ frameInfo: frameInfo{ref: c, active: hasState(state.state, stateActive), w: int(ext.rect[2]), h: int(ext.rect[3])},
178+ pid: kept[candApp[i]].pid, fx: ext.rect[0], fy: ext.rect[1],
179+ })
180+ }
181+
182+ // Pair the windows with the frames, process by process.
183+ var matched []winMatch
184+ for pid := range wanted {
185+ var wins []ports.HintWindow
186+ for _, w := range windows {
187+ if w.PID == pid {
188+ wins = append(wins, w)
189+ }
190+ }
191+ var fs []frameInfo
192+ var origin []frame
193+ for _, f := range frames {
194+ if f.pid == pid {
195+ fs = append(fs, f.frameInfo)
196+ origin = append(origin, f)
197+ }
198+ }
199+ for wi, fi := range matchFrames(wins, fs) {
200+ matched = append(matched, winMatch{win: wins[wi], frame: origin[fi].ref, fx: origin[fi].fx, fy: origin[fi].fy})
201+ }
202+ }
203+
204+ targets := s.elements(do, matched, linksOnly, covered)
205+ log := logging.For(ctx, "atspi")
206+ log.Debug().Int("apps", len(kept)).Int("frames", len(frames)).Int("windows", len(matched)).Int("targets", len(targets)).Msg("atspi targets")
207+ return targets, covered, nil
208+}
209+
210+// elements reads the clickable elements of the matched frames.
211+func (s *Source) elements(do func([]call) []reply, matched []winMatch, linksOnly bool, covered map[ports.WindowID]bool) []ports.HintTarget {
212+ calls := make([]call, len(matched))
213+ for i, m := range matched {
214+ calls[i] = call{op: opMatches, name: m.frame.name, path: m.frame.path, rule: matchRule{linksOnly: linksOnly}}
215+ }
216+ found := make([][]ref, len(matched))
217+ var walk []int // frames without Collection
218+ for i, r := range do(calls) {
219+ if r.err != nil {
220+ var be *busError
221+ if errors.As(r.err, &be) {
222+ walk = append(walk, i)
223+ }
224+ continue
225+ }
226+ found[i] = r.refs
227+ }
228+ for _, i := range walk {
229+ found[i] = s.walk(do, matched[i].frame, linksOnly)
230+ }
231+
232+ calls = calls[:0]
233+ var owner []int
234+ for i, refs := range found {
235+ for _, r := range refs {
236+ calls = append(calls,
237+ call{op: opRole, name: r.name, path: r.path},
238+ call{op: opExtents, name: r.name, path: r.path, arg: coordWindow})
239+ owner = append(owner, i)
240+ }
241+ }
242+ info := do(calls)
243+ var out []ports.HintTarget
244+ for k, i := range owner {
245+ role, ext := info[2*k], info[2*k+1]
246+ if role.err != nil || ext.err != nil || !isClickable(role.u, linksOnly) {
247+ continue
248+ }
249+ m := matched[i]
250+ 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]})
251+ if !ok {
252+ continue
253+ }
254+ out = append(out, t)
255+ covered[m.win.ID] = true
256+ }
257+ return out
258+}
259+
260+// walk is the fallback for toolkits without Collection: a breadth-first
261+// walk of at most maxDepth levels and maxWalkNodes nodes, returning the
262+// clickable nodes.
263+func (s *Source) walk(do func([]call) []reply, root ref, linksOnly bool) []ref {
264+ var out []ref
265+ level := []ref{root}
266+ seen := 0
267+ for depth := 0; depth < maxDepth && len(level) > 0 && seen < maxWalkNodes; depth++ {
268+ calls := make([]call, 0, 3*len(level))
269+ for _, n := range level {
270+ calls = append(calls,
271+ call{op: opChildren, name: n.name, path: n.path},
272+ call{op: opRole, name: n.name, path: n.path},
273+ call{op: opState, name: n.name, path: n.path})
274+ }
275+ rep := do(calls)
276+ var next []ref
277+ for i, n := range level {
278+ ch, role, state := rep[3*i], rep[3*i+1], rep[3*i+2]
279+ if state.err == nil && !hasState(state.state, stateShowing) {
280+ continue
281+ }
282+ if role.err == nil && isClickable(role.u, linksOnly) && state.err == nil && hasState(state.state, stateVisible) {
283+ out = append(out, n)
284+ }
285+ if ch.err == nil {
286+ next = append(next, ch.refs...)
287+ }
288+ }
289+ seen += len(level)
290+ if room := maxWalkNodes - seen; len(next) > room {
291+ next = next[:max(room, 0)]
292+ }
293+ level = next
294+ }
295+ return out
296+}
A internal/adapters/atspi/source_test.go +124 -0
@@ -0,0 +1,124 @@
1+package atspi
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+const (
15+ stActive = 1 << stateActive
16+ stShowing = 1 << stateShowing
17+)
18+
19+// tree answers calls like an app: registry with one app (:1.5, pid 77) with
20+// one showing frame whose links sit at window-relative extents.
21+func answerTree(frameExt [4]int32, matches []ref, ext map[string][4]int32, roles map[string]uint32, noCollection bool) func(time.Time, time.Duration, []call) []reply {
22+ return func(_ time.Time, _ time.Duration, calls []call) []reply {
23+ out := make([]reply, len(calls))
24+ for i, c := range calls {
25+ switch {
26+ case c.op == opChildren && c.name == registryName:
27+ out[i].refs = []ref{{":1.5", "/root"}, {":1.9", "/root"}}
28+ case c.op == opPID:
29+ out[i].u = map[string]uint32{":1.5": 77, ":1.9": 99}[c.name]
30+ case c.op == opChildren && c.path == "/root":
31+ out[i].refs = []ref{{":1.5", "/frame"}}
32+ case c.op == opRole && c.path == "/frame":
33+ out[i].u = roleFrame
34+ case c.op == opState && c.path == "/frame":
35+ out[i].state = stShowing | stActive
36+ case c.op == opExtents && c.path == "/frame":
37+ out[i].rect = frameExt
38+ case c.op == opMatches:
39+ if noCollection {
40+ out[i].err = &busError{name: "org.freedesktop.DBus.Error.UnknownMethod"}
41+ } else {
42+ out[i].refs = matches
43+ }
44+ case c.op == opChildren && c.path == "/frame":
45+ out[i].refs = matches
46+ case c.op == opChildren:
47+ case c.op == opRole:
48+ out[i].u = roles[c.path]
49+ case c.op == opState:
50+ out[i].state = stShowing | 1<<stateVisible
51+ case c.op == opExtents:
52+ out[i].rect = ext[c.path]
53+ }
54+ }
55+ return out
56+ }
57+}
58+
59+func TestTargetsCoversWindowAndConverts(t *testing.T) {
60+ tr := newMocktransport(t)
61+ matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}, {":1.5", "/zero"}}
62+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
63+ [4]int32{4, 30, 800, 600}, matches,
64+ map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}, "/zero": {5, 5, 0, 0}},
65+ map[string]uint32{"/a": roleLink, "/b": rolePushButton, "/zero": roleLink}, false))
66+ s := &Source{tr: tr}
67+
68+ windows := []ports.HintWindow{
69+ {ID: 1, PID: 77, Rect: ports.Rect{X: 100, Y: 200, W: 800, H: 600}, Focused: true},
70+ {ID: 2, PID: 5, Rect: ports.Rect{X: 0, Y: 0, W: 50, H: 50}},
71+ }
72+ targets, covered, err := s.Targets(context.Background(), windows, false)
73+ require.NoError(t, err)
74+ require.Equal(t, map[ports.WindowID]bool{1: true}, covered)
75+ require.Equal(t, []ports.HintTarget{
76+ {Rect: ports.Rect{X: 110, Y: 300, W: 50, H: 16}, Kind: ports.HintLink},
77+ {Rect: ports.Rect{X: 200, Y: 400, W: 20, H: 20}, Kind: ports.HintButton},
78+ }, targets)
79+}
80+
81+func TestTargetsLinksOnlyDropsButtons(t *testing.T) {
82+ tr := newMocktransport(t)
83+ matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}}
84+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
85+ [4]int32{0, 0, 800, 600}, matches,
86+ map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}},
87+ map[string]uint32{"/a": roleLink, "/b": rolePushButton}, false))
88+ s := &Source{tr: tr}
89+ targets, _, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, true)
90+ require.NoError(t, err)
91+ require.Len(t, targets, 1)
92+ require.Equal(t, ports.HintLink, targets[0].Kind)
93+}
94+
95+func TestTargetsWalkWithoutCollection(t *testing.T) {
96+ tr := newMocktransport(t)
97+ matches := []ref{{":1.5", "/a"}}
98+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
99+ [4]int32{0, 0, 800, 600}, matches,
100+ map[string][4]int32{"/a": {14, 130, 50, 16}},
101+ map[string]uint32{"/a": roleLink}, true))
102+ s := &Source{tr: tr}
103+ targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false)
104+ require.NoError(t, err)
105+ require.Len(t, targets, 1)
106+ require.True(t, covered[1])
107+}
108+
109+func TestTargetsNoPIDMatchCoversNothing(t *testing.T) {
110+ tr := newMocktransport(t)
111+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree([4]int32{}, nil, nil, nil, false))
112+ s := &Source{tr: tr}
113+ targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 1234, Rect: ports.Rect{W: 8, H: 6}}}, false)
114+ require.NoError(t, err)
115+ require.Empty(t, targets)
116+ require.Empty(t, covered)
117+}
118+
119+func TestTargetsWithoutPIDsAsksNothing(t *testing.T) {
120+ s := &Source{tr: newMocktransport(t)}
121+ _, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 8, H: 6}}}, false)
122+ require.NoError(t, err)
123+ require.Empty(t, covered)
124+}
@@ -0,0 +1,124 @@
1+package atspi
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+const (
15+ stActive = 1 << stateActive
16+ stShowing = 1 << stateShowing
17+)
18+
19+// tree answers calls like an app: registry with one app (:1.5, pid 77) with
20+// one showing frame whose links sit at window-relative extents.
21+func answerTree(frameExt [4]int32, matches []ref, ext map[string][4]int32, roles map[string]uint32, noCollection bool) func(time.Time, time.Duration, []call) []reply {
22+ return func(_ time.Time, _ time.Duration, calls []call) []reply {
23+ out := make([]reply, len(calls))
24+ for i, c := range calls {
25+ switch {
26+ case c.op == opChildren && c.name == registryName:
27+ out[i].refs = []ref{{":1.5", "/root"}, {":1.9", "/root"}}
28+ case c.op == opPID:
29+ out[i].u = map[string]uint32{":1.5": 77, ":1.9": 99}[c.name]
30+ case c.op == opChildren && c.path == "/root":
31+ out[i].refs = []ref{{":1.5", "/frame"}}
32+ case c.op == opRole && c.path == "/frame":
33+ out[i].u = roleFrame
34+ case c.op == opState && c.path == "/frame":
35+ out[i].state = stShowing | stActive
36+ case c.op == opExtents && c.path == "/frame":
37+ out[i].rect = frameExt
38+ case c.op == opMatches:
39+ if noCollection {
40+ out[i].err = &busError{name: "org.freedesktop.DBus.Error.UnknownMethod"}
41+ } else {
42+ out[i].refs = matches
43+ }
44+ case c.op == opChildren && c.path == "/frame":
45+ out[i].refs = matches
46+ case c.op == opChildren:
47+ case c.op == opRole:
48+ out[i].u = roles[c.path]
49+ case c.op == opState:
50+ out[i].state = stShowing | 1<<stateVisible
51+ case c.op == opExtents:
52+ out[i].rect = ext[c.path]
53+ }
54+ }
55+ return out
56+ }
57+}
58+
59+func TestTargetsCoversWindowAndConverts(t *testing.T) {
60+ tr := newMocktransport(t)
61+ matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}, {":1.5", "/zero"}}
62+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
63+ [4]int32{4, 30, 800, 600}, matches,
64+ map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}, "/zero": {5, 5, 0, 0}},
65+ map[string]uint32{"/a": roleLink, "/b": rolePushButton, "/zero": roleLink}, false))
66+ s := &Source{tr: tr}
67+
68+ windows := []ports.HintWindow{
69+ {ID: 1, PID: 77, Rect: ports.Rect{X: 100, Y: 200, W: 800, H: 600}, Focused: true},
70+ {ID: 2, PID: 5, Rect: ports.Rect{X: 0, Y: 0, W: 50, H: 50}},
71+ }
72+ targets, covered, err := s.Targets(context.Background(), windows, false)
73+ require.NoError(t, err)
74+ require.Equal(t, map[ports.WindowID]bool{1: true}, covered)
75+ require.Equal(t, []ports.HintTarget{
76+ {Rect: ports.Rect{X: 110, Y: 300, W: 50, H: 16}, Kind: ports.HintLink},
77+ {Rect: ports.Rect{X: 200, Y: 400, W: 20, H: 20}, Kind: ports.HintButton},
78+ }, targets)
79+}
80+
81+func TestTargetsLinksOnlyDropsButtons(t *testing.T) {
82+ tr := newMocktransport(t)
83+ matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}}
84+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
85+ [4]int32{0, 0, 800, 600}, matches,
86+ map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}},
87+ map[string]uint32{"/a": roleLink, "/b": rolePushButton}, false))
88+ s := &Source{tr: tr}
89+ targets, _, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, true)
90+ require.NoError(t, err)
91+ require.Len(t, targets, 1)
92+ require.Equal(t, ports.HintLink, targets[0].Kind)
93+}
94+
95+func TestTargetsWalkWithoutCollection(t *testing.T) {
96+ tr := newMocktransport(t)
97+ matches := []ref{{":1.5", "/a"}}
98+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
99+ [4]int32{0, 0, 800, 600}, matches,
100+ map[string][4]int32{"/a": {14, 130, 50, 16}},
101+ map[string]uint32{"/a": roleLink}, true))
102+ s := &Source{tr: tr}
103+ targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false)
104+ require.NoError(t, err)
105+ require.Len(t, targets, 1)
106+ require.True(t, covered[1])
107+}
108+
109+func TestTargetsNoPIDMatchCoversNothing(t *testing.T) {
110+ tr := newMocktransport(t)
111+ tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree([4]int32{}, nil, nil, nil, false))
112+ s := &Source{tr: tr}
113+ targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 1234, Rect: ports.Rect{W: 8, H: 6}}}, false)
114+ require.NoError(t, err)
115+ require.Empty(t, targets)
116+ require.Empty(t, covered)
117+}
118+
119+func TestTargetsWithoutPIDsAsksNothing(t *testing.T) {
120+ s := &Source{tr: newMocktransport(t)}
121+ _, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 8, H: 6}}}, false)
122+ require.NoError(t, err)
123+ require.Empty(t, covered)
124+}
A internal/adapters/atspi/transport.go +310 -0
@@ -0,0 +1,310 @@
1+package atspi
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strings"
7+ "syscall"
8+ "time"
9+
10+ "github.com/bnema/zerobus"
11+ "golang.org/x/sys/unix"
12+)
13+
14+// D-Bus names, paths and interfaces.
15+const (
16+ busDaemon = "org.freedesktop.DBus"
17+ busDaemonPath = "/org/freedesktop/DBus"
18+ ifaceProps = "org.freedesktop.DBus.Properties"
19+
20+ a11yBusName = "org.a11y.Bus"
21+ a11yBusPath = "/org/a11y/bus"
22+ a11yStatus = "org.a11y.Status"
23+ registryName = "org.a11y.atspi.Registry"
24+ registryRoot = "/org/a11y/atspi/accessible/root"
25+
26+ ifaceAccessible = "org.a11y.atspi.Accessible"
27+ ifaceComponent = "org.a11y.atspi.Component"
28+ ifaceCollection = "org.a11y.atspi.Collection"
29+
30+ // coordWindow is ATSPI_COORD_TYPE_WINDOW: extents relative to the
31+ // top-level window.
32+ coordWindow = 1
33+
34+ // maxInflight bounds the calls sent before a reply is read, so the bus
35+ // never waits on a full socket while this side waits on it.
36+ maxInflight = 64
37+ // maxRefs bounds the objects read from one reply.
38+ maxRefs = 4096
39+)
40+
41+// op is a call the source makes.
42+type op uint8
43+
44+const (
45+ opChildren op = iota // Accessible.GetChildren -> refs
46+ opPID // GetConnectionUnixProcessID(name) -> u
47+ opRole // Accessible.GetRole -> u
48+ opState // Accessible.GetState -> state
49+ opExtents // Component.GetExtents(arg) -> rect
50+ opMatches // Collection.GetMatches(rule) -> refs
51+)
52+
53+// ref is an accessible object: a bus name and a path.
54+type ref struct{ name, path string }
55+
56+// call is one request of a batch. name and path address the object; for
57+// opPID, name is the bus name asked about.
58+type call struct {
59+ op op
60+ name, path string
61+ arg uint32
62+ rule matchRule
63+}
64+
65+// reply answers the call at the same index of the batch.
66+type reply struct {
67+ err error
68+ refs []ref
69+ u uint32
70+ state uint64
71+ rect [4]int32 // x, y, w, h
72+}
73+
74+// transport sends batches of calls on one bus connection. It is used by one
75+// goroutine at a time.
76+type transport interface {
77+ // Do sends calls pipelined and returns one reply per call, in order.
78+ // The batch ends at deadline; it also ends when no reply arrives for
79+ // idle. Calls still unanswered then get ErrTimeout.
80+ Do(deadline time.Time, idle time.Duration, calls []call) []reply
81+ Close() error
82+}
83+
84+// ErrTimeout is the error of a call not answered in time.
85+var ErrTimeout = errors.New("atspi: call timed out")
86+
87+// busError is a D-Bus error reply.
88+type busError struct{ name, msg string }
89+
90+func (e *busError) Error() string {
91+ if e.msg == "" {
92+ return e.name
93+ }
94+ return e.name + ": " + e.msg
95+}
96+
97+// conn is a transport on a zerobus connection.
98+type conn struct {
99+ c *zerobus.Conn
100+ raw syscall.RawConn
101+}
102+
103+func newConn(c *zerobus.Conn) (*conn, error) {
104+ raw, err := c.SyscallConn()
105+ if err != nil {
106+ _ = c.Close()
107+ return nil, err
108+ }
109+ return &conn{c: c, raw: raw}, nil
110+}
111+
112+func (b *conn) Close() error { return b.c.Close() }
113+
114+// readable waits until a message can be read or d passes.
115+func (b *conn) readable(d time.Duration) (bool, error) {
116+ if b.c.Buffered() {
117+ return true, nil
118+ }
119+ ms := int((d + time.Millisecond - 1) / time.Millisecond)
120+ if ms < 1 {
121+ ms = 1
122+ }
123+ var n int
124+ var perr error
125+ if err := b.raw.Control(func(fd uintptr) {
126+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
127+ for {
128+ n, perr = unix.Poll(fds, ms)
129+ if perr != unix.EINTR {
130+ return
131+ }
132+ }
133+ }); err != nil {
134+ return false, err
135+ }
136+ return n > 0, perr
137+}
138+
139+func (b *conn) Do(deadline time.Time, idle time.Duration, calls []call) []reply {
140+ out := make([]reply, len(calls))
141+ answered := make([]bool, len(calls))
142+ pending := make(map[uint32]int, min(len(calls), maxInflight))
143+ next, done := 0, 0
144+ // finish gives err to every call without an answer.
145+ finish := func(err error) []reply {
146+ for i := range out {
147+ if !answered[i] {
148+ out[i].err = err
149+ }
150+ }
151+ return out
152+ }
153+ for done < len(calls) {
154+ for next < len(calls) && len(pending) < maxInflight {
155+ serial, err := b.send(calls[next])
156+ if err != nil {
157+ if errors.Is(err, zerobus.ErrClosed) {
158+ return finish(err)
159+ }
160+ out[next].err = err
161+ answered[next] = true
162+ done++
163+ } else {
164+ pending[serial] = next
165+ }
166+ next++
167+ }
168+ if len(pending) == 0 {
169+ continue
170+ }
171+ wait := min(idle, time.Until(deadline))
172+ if wait <= 0 {
173+ break
174+ }
175+ ok, err := b.readable(wait)
176+ if err != nil {
177+ return finish(err)
178+ }
179+ if !ok {
180+ break
181+ }
182+ m, err := b.c.ReadMessage()
183+ if err != nil {
184+ return finish(err)
185+ }
186+ i, found := pending[m.ReplySerial]
187+ if !found || (m.Type != zerobus.TypeMethodReturn && m.Type != zerobus.TypeError) {
188+ // A signal, or the late reply of an earlier batch.
189+ continue
190+ }
191+ delete(pending, m.ReplySerial)
192+ decode(m, calls[i], &out[i])
193+ answered[i] = true
194+ done++
195+ }
196+ return finish(ErrTimeout)
197+}
198+
199+// send builds and sends one call and returns its serial.
200+func (b *conn) send(c call) (uint32, error) {
201+ switch c.op {
202+ case opChildren:
203+ b.c.NewCall(c.name, c.path, ifaceAccessible, "GetChildren", "")
204+ case opPID:
205+ b.c.NewCall(busDaemon, busDaemonPath, busDaemon, "GetConnectionUnixProcessID", "s").Str(c.name)
206+ case opRole:
207+ b.c.NewCall(c.name, c.path, ifaceAccessible, "GetRole", "")
208+ case opState:
209+ b.c.NewCall(c.name, c.path, ifaceAccessible, "GetState", "")
210+ case opExtents:
211+ b.c.NewCall(c.name, c.path, ifaceComponent, "GetExtents", "u").Uint32(c.arg)
212+ case opMatches:
213+ e := b.c.NewCall(c.name, c.path, ifaceCollection, "GetMatches", "(aiia{ss}iaiiasib)uib")
214+ encodeRule(e, c.rule)
215+ e.Uint32(sortCanonical)
216+ e.Int32(matchCount)
217+ e.Bool(true)
218+ }
219+ return b.c.Send()
220+}
221+
222+// encodeRule writes the MatchRule struct (aiia{ss}iaiiasib): states, state
223+// match, attributes, attribute match, roles, role match, interfaces,
224+// interface match, invert.
225+func encodeRule(e *zerobus.Encoder, r matchRule) {
226+ e.Struct()
227+ a := e.BeginArray('i')
228+ for _, w := range stateBits(stateShowing, stateVisible) {
229+ e.Int32(w)
230+ }
231+ e.EndArray(a)
232+ e.Int32(matchAll)
233+ m := e.BeginArray('{')
234+ e.EndArray(m)
235+ e.Int32(matchAll)
236+ a = e.BeginArray('i')
237+ for _, w := range roleBits(r.roles()) {
238+ e.Int32(w)
239+ }
240+ e.EndArray(a)
241+ e.Int32(matchAny)
242+ s := e.BeginArray('s')
243+ e.EndArray(s)
244+ e.Int32(matchAll)
245+ e.Bool(false)
246+}
247+
248+// decode reads the reply m of c into out.
249+func decode(m *zerobus.Message, c call, out *reply) {
250+ if m.Type == zerobus.TypeError {
251+ be := &busError{name: strings.Clone(m.ErrorName)}
252+ if strings.HasPrefix(m.Signature, "s") {
253+ be.msg = strings.Clone(m.Body().Str())
254+ }
255+ out.err = be
256+ return
257+ }
258+ r := m.Body()
259+ want := func(sig string) bool {
260+ if m.Signature != sig {
261+ out.err = fmt.Errorf("atspi: reply signature %q, want %q", m.Signature, sig)
262+ return false
263+ }
264+ return true
265+ }
266+ switch c.op {
267+ case opChildren, opMatches:
268+ if !want("a(so)") {
269+ return
270+ }
271+ end := r.Array('(')
272+ for r.More(end) {
273+ if len(out.refs) >= maxRefs {
274+ break
275+ }
276+ r.Struct()
277+ name := r.Str()
278+ path := r.ObjectPath()
279+ if r.Err() != nil {
280+ break
281+ }
282+ out.refs = append(out.refs, ref{strings.Clone(name), strings.Clone(path)})
283+ }
284+ case opPID, opRole:
285+ if want("u") {
286+ out.u = r.Uint32()
287+ }
288+ case opState:
289+ if !want("au") {
290+ return
291+ }
292+ end := r.Array('u')
293+ var words [2]uint32
294+ for i := 0; r.More(end); i++ {
295+ w := r.Uint32()
296+ if i < len(words) {
297+ words[i] = w
298+ }
299+ }
300+ out.state = uint64(words[0]) | uint64(words[1])<<32
301+ case opExtents:
302+ if want("(iiii)") {
303+ r.Struct()
304+ out.rect = [4]int32{r.Int32(), r.Int32(), r.Int32(), r.Int32()}
305+ }
306+ }
307+ if out.err == nil && r.Err() != nil {
308+ out.err = r.Err()
309+ }
310+}
@@ -0,0 +1,310 @@
1+package atspi
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strings"
7+ "syscall"
8+ "time"
9+
10+ "github.com/bnema/zerobus"
11+ "golang.org/x/sys/unix"
12+)
13+
14+// D-Bus names, paths and interfaces.
15+const (
16+ busDaemon = "org.freedesktop.DBus"
17+ busDaemonPath = "/org/freedesktop/DBus"
18+ ifaceProps = "org.freedesktop.DBus.Properties"
19+
20+ a11yBusName = "org.a11y.Bus"
21+ a11yBusPath = "/org/a11y/bus"
22+ a11yStatus = "org.a11y.Status"
23+ registryName = "org.a11y.atspi.Registry"
24+ registryRoot = "/org/a11y/atspi/accessible/root"
25+
26+ ifaceAccessible = "org.a11y.atspi.Accessible"
27+ ifaceComponent = "org.a11y.atspi.Component"
28+ ifaceCollection = "org.a11y.atspi.Collection"
29+
30+ // coordWindow is ATSPI_COORD_TYPE_WINDOW: extents relative to the
31+ // top-level window.
32+ coordWindow = 1
33+
34+ // maxInflight bounds the calls sent before a reply is read, so the bus
35+ // never waits on a full socket while this side waits on it.
36+ maxInflight = 64
37+ // maxRefs bounds the objects read from one reply.
38+ maxRefs = 4096
39+)
40+
41+// op is a call the source makes.
42+type op uint8
43+
44+const (
45+ opChildren op = iota // Accessible.GetChildren -> refs
46+ opPID // GetConnectionUnixProcessID(name) -> u
47+ opRole // Accessible.GetRole -> u
48+ opState // Accessible.GetState -> state
49+ opExtents // Component.GetExtents(arg) -> rect
50+ opMatches // Collection.GetMatches(rule) -> refs
51+)
52+
53+// ref is an accessible object: a bus name and a path.
54+type ref struct{ name, path string }
55+
56+// call is one request of a batch. name and path address the object; for
57+// opPID, name is the bus name asked about.
58+type call struct {
59+ op op
60+ name, path string
61+ arg uint32
62+ rule matchRule
63+}
64+
65+// reply answers the call at the same index of the batch.
66+type reply struct {
67+ err error
68+ refs []ref
69+ u uint32
70+ state uint64
71+ rect [4]int32 // x, y, w, h
72+}
73+
74+// transport sends batches of calls on one bus connection. It is used by one
75+// goroutine at a time.
76+type transport interface {
77+ // Do sends calls pipelined and returns one reply per call, in order.
78+ // The batch ends at deadline; it also ends when no reply arrives for
79+ // idle. Calls still unanswered then get ErrTimeout.
80+ Do(deadline time.Time, idle time.Duration, calls []call) []reply
81+ Close() error
82+}
83+
84+// ErrTimeout is the error of a call not answered in time.
85+var ErrTimeout = errors.New("atspi: call timed out")
86+
87+// busError is a D-Bus error reply.
88+type busError struct{ name, msg string }
89+
90+func (e *busError) Error() string {
91+ if e.msg == "" {
92+ return e.name
93+ }
94+ return e.name + ": " + e.msg
95+}
96+
97+// conn is a transport on a zerobus connection.
98+type conn struct {
99+ c *zerobus.Conn
100+ raw syscall.RawConn
101+}
102+
103+func newConn(c *zerobus.Conn) (*conn, error) {
104+ raw, err := c.SyscallConn()
105+ if err != nil {
106+ _ = c.Close()
107+ return nil, err
108+ }
109+ return &conn{c: c, raw: raw}, nil
110+}
111+
112+func (b *conn) Close() error { return b.c.Close() }
113+
114+// readable waits until a message can be read or d passes.
115+func (b *conn) readable(d time.Duration) (bool, error) {
116+ if b.c.Buffered() {
117+ return true, nil
118+ }
119+ ms := int((d + time.Millisecond - 1) / time.Millisecond)
120+ if ms < 1 {
121+ ms = 1
122+ }
123+ var n int
124+ var perr error
125+ if err := b.raw.Control(func(fd uintptr) {
126+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
127+ for {
128+ n, perr = unix.Poll(fds, ms)
129+ if perr != unix.EINTR {
130+ return
131+ }
132+ }
133+ }); err != nil {
134+ return false, err
135+ }
136+ return n > 0, perr
137+}
138+
139+func (b *conn) Do(deadline time.Time, idle time.Duration, calls []call) []reply {
140+ out := make([]reply, len(calls))
141+ answered := make([]bool, len(calls))
142+ pending := make(map[uint32]int, min(len(calls), maxInflight))
143+ next, done := 0, 0
144+ // finish gives err to every call without an answer.
145+ finish := func(err error) []reply {
146+ for i := range out {
147+ if !answered[i] {
148+ out[i].err = err
149+ }
150+ }
151+ return out
152+ }
153+ for done < len(calls) {
154+ for next < len(calls) && len(pending) < maxInflight {
155+ serial, err := b.send(calls[next])
156+ if err != nil {
157+ if errors.Is(err, zerobus.ErrClosed) {
158+ return finish(err)
159+ }
160+ out[next].err = err
161+ answered[next] = true
162+ done++
163+ } else {
164+ pending[serial] = next
165+ }
166+ next++
167+ }
168+ if len(pending) == 0 {
169+ continue
170+ }
171+ wait := min(idle, time.Until(deadline))
172+ if wait <= 0 {
173+ break
174+ }
175+ ok, err := b.readable(wait)
176+ if err != nil {
177+ return finish(err)
178+ }
179+ if !ok {
180+ break
181+ }
182+ m, err := b.c.ReadMessage()
183+ if err != nil {
184+ return finish(err)
185+ }
186+ i, found := pending[m.ReplySerial]
187+ if !found || (m.Type != zerobus.TypeMethodReturn && m.Type != zerobus.TypeError) {
188+ // A signal, or the late reply of an earlier batch.
189+ continue
190+ }
191+ delete(pending, m.ReplySerial)
192+ decode(m, calls[i], &out[i])
193+ answered[i] = true
194+ done++
195+ }
196+ return finish(ErrTimeout)
197+}
198+
199+// send builds and sends one call and returns its serial.
200+func (b *conn) send(c call) (uint32, error) {
201+ switch c.op {
202+ case opChildren:
203+ b.c.NewCall(c.name, c.path, ifaceAccessible, "GetChildren", "")
204+ case opPID:
205+ b.c.NewCall(busDaemon, busDaemonPath, busDaemon, "GetConnectionUnixProcessID", "s").Str(c.name)
206+ case opRole:
207+ b.c.NewCall(c.name, c.path, ifaceAccessible, "GetRole", "")
208+ case opState:
209+ b.c.NewCall(c.name, c.path, ifaceAccessible, "GetState", "")
210+ case opExtents:
211+ b.c.NewCall(c.name, c.path, ifaceComponent, "GetExtents", "u").Uint32(c.arg)
212+ case opMatches:
213+ e := b.c.NewCall(c.name, c.path, ifaceCollection, "GetMatches", "(aiia{ss}iaiiasib)uib")
214+ encodeRule(e, c.rule)
215+ e.Uint32(sortCanonical)
216+ e.Int32(matchCount)
217+ e.Bool(true)
218+ }
219+ return b.c.Send()
220+}
221+
222+// encodeRule writes the MatchRule struct (aiia{ss}iaiiasib): states, state
223+// match, attributes, attribute match, roles, role match, interfaces,
224+// interface match, invert.
225+func encodeRule(e *zerobus.Encoder, r matchRule) {
226+ e.Struct()
227+ a := e.BeginArray('i')
228+ for _, w := range stateBits(stateShowing, stateVisible) {
229+ e.Int32(w)
230+ }
231+ e.EndArray(a)
232+ e.Int32(matchAll)
233+ m := e.BeginArray('{')
234+ e.EndArray(m)
235+ e.Int32(matchAll)
236+ a = e.BeginArray('i')
237+ for _, w := range roleBits(r.roles()) {
238+ e.Int32(w)
239+ }
240+ e.EndArray(a)
241+ e.Int32(matchAny)
242+ s := e.BeginArray('s')
243+ e.EndArray(s)
244+ e.Int32(matchAll)
245+ e.Bool(false)
246+}
247+
248+// decode reads the reply m of c into out.
249+func decode(m *zerobus.Message, c call, out *reply) {
250+ if m.Type == zerobus.TypeError {
251+ be := &busError{name: strings.Clone(m.ErrorName)}
252+ if strings.HasPrefix(m.Signature, "s") {
253+ be.msg = strings.Clone(m.Body().Str())
254+ }
255+ out.err = be
256+ return
257+ }
258+ r := m.Body()
259+ want := func(sig string) bool {
260+ if m.Signature != sig {
261+ out.err = fmt.Errorf("atspi: reply signature %q, want %q", m.Signature, sig)
262+ return false
263+ }
264+ return true
265+ }
266+ switch c.op {
267+ case opChildren, opMatches:
268+ if !want("a(so)") {
269+ return
270+ }
271+ end := r.Array('(')
272+ for r.More(end) {
273+ if len(out.refs) >= maxRefs {
274+ break
275+ }
276+ r.Struct()
277+ name := r.Str()
278+ path := r.ObjectPath()
279+ if r.Err() != nil {
280+ break
281+ }
282+ out.refs = append(out.refs, ref{strings.Clone(name), strings.Clone(path)})
283+ }
284+ case opPID, opRole:
285+ if want("u") {
286+ out.u = r.Uint32()
287+ }
288+ case opState:
289+ if !want("au") {
290+ return
291+ }
292+ end := r.Array('u')
293+ var words [2]uint32
294+ for i := 0; r.More(end); i++ {
295+ w := r.Uint32()
296+ if i < len(words) {
297+ words[i] = w
298+ }
299+ }
300+ out.state = uint64(words[0]) | uint64(words[1])<<32
301+ case opExtents:
302+ if want("(iiii)") {
303+ r.Struct()
304+ out.rect = [4]int32{r.Int32(), r.Int32(), r.Int32(), r.Int32()}
305+ }
306+ }
307+ if out.err == nil && r.Err() != nil {
308+ out.err = r.Err()
309+ }
310+}
A internal/adapters/atspi/transport_mock_test.go +156 -0
@@ -0,0 +1,156 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package atspi
6+
7+import (
8+ "time"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMocktransport creates a new instance of mocktransport. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMocktransport(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mocktransport {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mocktransport{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mocktransport is an autogenerated mock type for the transport type
37+type mocktransport struct {
38+ mock.Mock
39+}
40+
41+type mocktransport_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mocktransport) EXPECT() *mocktransport_Expecter {
46+ return &mocktransport_Expecter{mock: &_m.Mock}
47+}
48+
49+// Close provides a mock function for the type mocktransport
50+func (_mock *mocktransport) Close() error {
51+ ret := _mock.Called()
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for Close")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func() error); ok {
59+ r0 = returnFunc()
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// mocktransport_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
67+type mocktransport_Close_Call struct {
68+ *mock.Call
69+}
70+
71+// Close is a helper method to define mock.On call
72+func (_e *mocktransport_Expecter) Close() *mocktransport_Close_Call {
73+ return &mocktransport_Close_Call{Call: _e.mock.On("Close")}
74+}
75+
76+func (_c *mocktransport_Close_Call) Run(run func()) *mocktransport_Close_Call {
77+ _c.Call.Run(func(args mock.Arguments) {
78+ run()
79+ })
80+ return _c
81+}
82+
83+func (_c *mocktransport_Close_Call) Return(err error) *mocktransport_Close_Call {
84+ _c.Call.Return(err)
85+ return _c
86+}
87+
88+func (_c *mocktransport_Close_Call) RunAndReturn(run func() error) *mocktransport_Close_Call {
89+ _c.Call.Return(run)
90+ return _c
91+}
92+
93+// Do provides a mock function for the type mocktransport
94+func (_mock *mocktransport) Do(deadline time.Time, idle time.Duration, calls []call) []reply {
95+ ret := _mock.Called(deadline, idle, calls)
96+
97+ if len(ret) == 0 {
98+ panic("no return value specified for Do")
99+ }
100+
101+ var r0 []reply
102+ if returnFunc, ok := ret.Get(0).(func(time.Time, time.Duration, []call) []reply); ok {
103+ r0 = returnFunc(deadline, idle, calls)
104+ } else {
105+ if ret.Get(0) != nil {
106+ r0 = ret.Get(0).([]reply)
107+ }
108+ }
109+ return r0
110+}
111+
112+// mocktransport_Do_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Do'
113+type mocktransport_Do_Call struct {
114+ *mock.Call
115+}
116+
117+// Do is a helper method to define mock.On call
118+// - deadline time.Time
119+// - idle time.Duration
120+// - calls []call
121+func (_e *mocktransport_Expecter) Do(deadline any, idle any, calls any) *mocktransport_Do_Call {
122+ return &mocktransport_Do_Call{Call: _e.mock.On("Do", deadline, idle, calls)}
123+}
124+
125+func (_c *mocktransport_Do_Call) Run(run func(deadline time.Time, idle time.Duration, calls []call)) *mocktransport_Do_Call {
126+ _c.Call.Run(func(args mock.Arguments) {
127+ var arg0 time.Time
128+ if args[0] != nil {
129+ arg0 = args[0].(time.Time)
130+ }
131+ var arg1 time.Duration
132+ if args[1] != nil {
133+ arg1 = args[1].(time.Duration)
134+ }
135+ var arg2 []call
136+ if args[2] != nil {
137+ arg2 = args[2].([]call)
138+ }
139+ run(
140+ arg0,
141+ arg1,
142+ arg2,
143+ )
144+ })
145+ return _c
146+}
147+
148+func (_c *mocktransport_Do_Call) Return(replyMoqParams []reply) *mocktransport_Do_Call {
149+ _c.Call.Return(replyMoqParams)
150+ return _c
151+}
152+
153+func (_c *mocktransport_Do_Call) RunAndReturn(run func(deadline time.Time, idle time.Duration, calls []call) []reply) *mocktransport_Do_Call {
154+ _c.Call.Return(run)
155+ return _c
156+}
@@ -0,0 +1,156 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package atspi
6+
7+import (
8+ "time"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMocktransport creates a new instance of mocktransport. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMocktransport(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mocktransport {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mocktransport{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mocktransport is an autogenerated mock type for the transport type
37+type mocktransport struct {
38+ mock.Mock
39+}
40+
41+type mocktransport_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mocktransport) EXPECT() *mocktransport_Expecter {
46+ return &mocktransport_Expecter{mock: &_m.Mock}
47+}
48+
49+// Close provides a mock function for the type mocktransport
50+func (_mock *mocktransport) Close() error {
51+ ret := _mock.Called()
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for Close")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func() error); ok {
59+ r0 = returnFunc()
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// mocktransport_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
67+type mocktransport_Close_Call struct {
68+ *mock.Call
69+}
70+
71+// Close is a helper method to define mock.On call
72+func (_e *mocktransport_Expecter) Close() *mocktransport_Close_Call {
73+ return &mocktransport_Close_Call{Call: _e.mock.On("Close")}
74+}
75+
76+func (_c *mocktransport_Close_Call) Run(run func()) *mocktransport_Close_Call {
77+ _c.Call.Run(func(args mock.Arguments) {
78+ run()
79+ })
80+ return _c
81+}
82+
83+func (_c *mocktransport_Close_Call) Return(err error) *mocktransport_Close_Call {
84+ _c.Call.Return(err)
85+ return _c
86+}
87+
88+func (_c *mocktransport_Close_Call) RunAndReturn(run func() error) *mocktransport_Close_Call {
89+ _c.Call.Return(run)
90+ return _c
91+}
92+
93+// Do provides a mock function for the type mocktransport
94+func (_mock *mocktransport) Do(deadline time.Time, idle time.Duration, calls []call) []reply {
95+ ret := _mock.Called(deadline, idle, calls)
96+
97+ if len(ret) == 0 {
98+ panic("no return value specified for Do")
99+ }
100+
101+ var r0 []reply
102+ if returnFunc, ok := ret.Get(0).(func(time.Time, time.Duration, []call) []reply); ok {
103+ r0 = returnFunc(deadline, idle, calls)
104+ } else {
105+ if ret.Get(0) != nil {
106+ r0 = ret.Get(0).([]reply)
107+ }
108+ }
109+ return r0
110+}
111+
112+// mocktransport_Do_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Do'
113+type mocktransport_Do_Call struct {
114+ *mock.Call
115+}
116+
117+// Do is a helper method to define mock.On call
118+// - deadline time.Time
119+// - idle time.Duration
120+// - calls []call
121+func (_e *mocktransport_Expecter) Do(deadline any, idle any, calls any) *mocktransport_Do_Call {
122+ return &mocktransport_Do_Call{Call: _e.mock.On("Do", deadline, idle, calls)}
123+}
124+
125+func (_c *mocktransport_Do_Call) Run(run func(deadline time.Time, idle time.Duration, calls []call)) *mocktransport_Do_Call {
126+ _c.Call.Run(func(args mock.Arguments) {
127+ var arg0 time.Time
128+ if args[0] != nil {
129+ arg0 = args[0].(time.Time)
130+ }
131+ var arg1 time.Duration
132+ if args[1] != nil {
133+ arg1 = args[1].(time.Duration)
134+ }
135+ var arg2 []call
136+ if args[2] != nil {
137+ arg2 = args[2].([]call)
138+ }
139+ run(
140+ arg0,
141+ arg1,
142+ arg2,
143+ )
144+ })
145+ return _c
146+}
147+
148+func (_c *mocktransport_Do_Call) Return(replyMoqParams []reply) *mocktransport_Do_Call {
149+ _c.Call.Return(replyMoqParams)
150+ return _c
151+}
152+
153+func (_c *mocktransport_Do_Call) RunAndReturn(run func(deadline time.Time, idle time.Duration, calls []call) []reply) *mocktransport_Do_Call {
154+ _c.Call.Return(run)
155+ return _c
156+}

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