feat(atspi): find hint targets in the accessibility tree
- commit
- d9f6a443427399c7820e44c0c9a9d121fa866af0
- author
- brice · · 2026-10-19 16:03 UTC
- committer
- brice · · 2026-10-19 17:36 UTC
- parent
- e368cc0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +1954 -0
A internal/adapters/atspi/bus_test.go +197 -0
| @@ -0,0 +1,197 @@ | |||
| 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 && uint32(roles[2]) == 1<<24|1<<18|1<<31 | |
| 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 | + | } | |
| 167 | + | ||
| 168 | + | // A call left unanswered for idle gets ErrTimeout without breaking the | |
| 169 | + | // connection; a batch that hits its deadline breaks it, and Do returns near | |
| 170 | + | // the deadline even though the idle timeout is longer. | |
| 171 | + | func TestDoDeadlineBreaksConnection(t *testing.T) { | |
| 172 | + | addr := startBus(t) | |
| 173 | + | app, err := zerobus.Dial(addr) | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | t.Cleanup(func() { _ = app.Close() }) | |
| 176 | + | _, err = app.RequestName("org.test.Mute", 0) | |
| 177 | + | require.NoError(t, err) | |
| 178 | + | ||
| 179 | + | cc, err := zerobus.Dial(addr) | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | b, err := newConn(cc) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | t.Cleanup(func() { _ = b.Close() }) | |
| 184 | + | ||
| 185 | + | mute := []call{{op: opRole, name: "org.test.Mute", path: "/x"}} | |
| 186 | + | rep := b.Do(time.Now().Add(time.Second), 40*time.Millisecond, mute) | |
| 187 | + | require.ErrorIs(t, rep[0].err, ErrTimeout) | |
| 188 | + | require.NotErrorIs(t, rep[0].err, ErrBroken) | |
| 189 | + | ||
| 190 | + | t0 := time.Now() | |
| 191 | + | rep = b.Do(time.Now().Add(60*time.Millisecond), time.Second, mute) | |
| 192 | + | require.ErrorIs(t, rep[0].err, ErrBroken) | |
| 193 | + | require.Less(t, time.Since(t0), 300*time.Millisecond) | |
| 194 | + | // The socket is closed: the next batch fails at once. | |
| 195 | + | rep = b.Do(time.Now().Add(time.Second), time.Second, mute) | |
| 196 | + | require.ErrorIs(t, rep[0].err, ErrBroken) | |
| 197 | + | } | |
| @@ -0,0 +1,197 @@ | |||||
| 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 && uint32(roles[2]) == 1<<24|1<<18|1<<31 | |||
| 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 | + | } | |||
| 167 | + | ||||
| 168 | + | // A call left unanswered for idle gets ErrTimeout without breaking the | |||
| 169 | + | // connection; a batch that hits its deadline breaks it, and Do returns near | |||
| 170 | + | // the deadline even though the idle timeout is longer. | |||
| 171 | + | func TestDoDeadlineBreaksConnection(t *testing.T) { | |||
| 172 | + | addr := startBus(t) | |||
| 173 | + | app, err := zerobus.Dial(addr) | |||
| 174 | + | require.NoError(t, err) | |||
| 175 | + | t.Cleanup(func() { _ = app.Close() }) | |||
| 176 | + | _, err = app.RequestName("org.test.Mute", 0) | |||
| 177 | + | require.NoError(t, err) | |||
| 178 | + | ||||
| 179 | + | cc, err := zerobus.Dial(addr) | |||
| 180 | + | require.NoError(t, err) | |||
| 181 | + | b, err := newConn(cc) | |||
| 182 | + | require.NoError(t, err) | |||
| 183 | + | t.Cleanup(func() { _ = b.Close() }) | |||
| 184 | + | ||||
| 185 | + | mute := []call{{op: opRole, name: "org.test.Mute", path: "/x"}} | |||
| 186 | + | rep := b.Do(time.Now().Add(time.Second), 40*time.Millisecond, mute) | |||
| 187 | + | require.ErrorIs(t, rep[0].err, ErrTimeout) | |||
| 188 | + | require.NotErrorIs(t, rep[0].err, ErrBroken) | |||
| 189 | + | ||||
| 190 | + | t0 := time.Now() | |||
| 191 | + | rep = b.Do(time.Now().Add(60*time.Millisecond), time.Second, mute) | |||
| 192 | + | require.ErrorIs(t, rep[0].err, ErrBroken) | |||
| 193 | + | require.Less(t, time.Since(t0), 300*time.Millisecond) | |||
| 194 | + | // The socket is closed: the next batch fails at once. | |||
| 195 | + | rep = b.Do(time.Now().Add(time.Second), time.Second, mute) | |||
| 196 | + | require.ErrorIs(t, rep[0].err, ErrBroken) | |||
| 197 | + | } | |||
A internal/adapters/atspi/connector_mock_test.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package atspi | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | mock "github.com/stretchr/testify/mock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // newMockconnector creates a new instance of mockconnector. 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 newMockconnector(t interface { | |
| 16 | + | mock.TestingT | |
| 17 | + | Cleanup(func()) | |
| 18 | + | }) *mockconnector { | |
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | + | helper.Helper() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | mock := &mockconnector{} | |
| 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 | + | // mockconnector is an autogenerated mock type for the connector type | |
| 37 | + | type mockconnector struct { | |
| 38 | + | mock.Mock | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type mockconnector_Expecter struct { | |
| 42 | + | mock *mock.Mock | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (_m *mockconnector) EXPECT() *mockconnector_Expecter { | |
| 46 | + | return &mockconnector_Expecter{mock: &_m.Mock} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Connect provides a mock function for the type mockconnector | |
| 50 | + | func (_mock *mockconnector) Connect(ctx context.Context) (transport, error) { | |
| 51 | + | ret := _mock.Called(ctx) | |
| 52 | + | ||
| 53 | + | if len(ret) == 0 { | |
| 54 | + | panic("no return value specified for Connect") | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | var r0 transport | |
| 58 | + | var r1 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (transport, error)); ok { | |
| 60 | + | return returnFunc(ctx) | |
| 61 | + | } | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) transport); ok { | |
| 63 | + | r0 = returnFunc(ctx) | |
| 64 | + | } else { | |
| 65 | + | if ret.Get(0) != nil { | |
| 66 | + | r0 = ret.Get(0).(transport) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 70 | + | r1 = returnFunc(ctx) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // mockconnector_Connect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Connect' | |
| 78 | + | type mockconnector_Connect_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Connect is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | func (_e *mockconnector_Expecter) Connect(ctx any) *mockconnector_Connect_Call { | |
| 85 | + | return &mockconnector_Connect_Call{Call: _e.mock.On("Connect", ctx)} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (_c *mockconnector_Connect_Call) Run(run func(ctx context.Context)) *mockconnector_Connect_Call { | |
| 89 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 90 | + | var arg0 context.Context | |
| 91 | + | if args[0] != nil { | |
| 92 | + | arg0 = args[0].(context.Context) | |
| 93 | + | } | |
| 94 | + | run( | |
| 95 | + | arg0, | |
| 96 | + | ) | |
| 97 | + | }) | |
| 98 | + | return _c | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (_c *mockconnector_Connect_Call) Return(transportMoqParam transport, err error) *mockconnector_Connect_Call { | |
| 102 | + | _c.Call.Return(transportMoqParam, err) | |
| 103 | + | return _c | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (_c *mockconnector_Connect_Call) RunAndReturn(run func(ctx context.Context) (transport, error)) *mockconnector_Connect_Call { | |
| 107 | + | _c.Call.Return(run) | |
| 108 | + | return _c | |
| 109 | + | } | |
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package atspi | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // newMockconnector creates a new instance of mockconnector. 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 newMockconnector(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *mockconnector { | |||
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 20 | + | helper.Helper() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | mock := &mockconnector{} | |||
| 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 | + | // mockconnector is an autogenerated mock type for the connector type | |||
| 37 | + | type mockconnector struct { | |||
| 38 | + | mock.Mock | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type mockconnector_Expecter struct { | |||
| 42 | + | mock *mock.Mock | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (_m *mockconnector) EXPECT() *mockconnector_Expecter { | |||
| 46 | + | return &mockconnector_Expecter{mock: &_m.Mock} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Connect provides a mock function for the type mockconnector | |||
| 50 | + | func (_mock *mockconnector) Connect(ctx context.Context) (transport, error) { | |||
| 51 | + | ret := _mock.Called(ctx) | |||
| 52 | + | ||||
| 53 | + | if len(ret) == 0 { | |||
| 54 | + | panic("no return value specified for Connect") | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | var r0 transport | |||
| 58 | + | var r1 error | |||
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (transport, error)); ok { | |||
| 60 | + | return returnFunc(ctx) | |||
| 61 | + | } | |||
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) transport); ok { | |||
| 63 | + | r0 = returnFunc(ctx) | |||
| 64 | + | } else { | |||
| 65 | + | if ret.Get(0) != nil { | |||
| 66 | + | r0 = ret.Get(0).(transport) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 70 | + | r1 = returnFunc(ctx) | |||
| 71 | + | } else { | |||
| 72 | + | r1 = ret.Error(1) | |||
| 73 | + | } | |||
| 74 | + | return r0, r1 | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // mockconnector_Connect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Connect' | |||
| 78 | + | type mockconnector_Connect_Call struct { | |||
| 79 | + | *mock.Call | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Connect is a helper method to define mock.On call | |||
| 83 | + | // - ctx context.Context | |||
| 84 | + | func (_e *mockconnector_Expecter) Connect(ctx any) *mockconnector_Connect_Call { | |||
| 85 | + | return &mockconnector_Connect_Call{Call: _e.mock.On("Connect", ctx)} | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (_c *mockconnector_Connect_Call) Run(run func(ctx context.Context)) *mockconnector_Connect_Call { | |||
| 89 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 90 | + | var arg0 context.Context | |||
| 91 | + | if args[0] != nil { | |||
| 92 | + | arg0 = args[0].(context.Context) | |||
| 93 | + | } | |||
| 94 | + | run( | |||
| 95 | + | arg0, | |||
| 96 | + | ) | |||
| 97 | + | }) | |||
| 98 | + | return _c | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func (_c *mockconnector_Connect_Call) Return(transportMoqParam transport, err error) *mockconnector_Connect_Call { | |||
| 102 | + | _c.Call.Return(transportMoqParam, err) | |||
| 103 | + | return _c | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (_c *mockconnector_Connect_Call) RunAndReturn(run func(ctx context.Context) (transport, error)) *mockconnector_Connect_Call { | |||
| 107 | + | _c.Call.Return(run) | |||
| 108 | + | return _c | |||
| 109 | + | } | |||
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 +206 -0
| @@ -0,0 +1,206 @@ | |||
| 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 | + | roleDocumentFrame = 82 | |
| 20 | + | roleDocumentWeb = 95 | |
| 21 | + | ||
| 22 | + | stateActive = 1 | |
| 23 | + | stateShowing = 25 | |
| 24 | + | stateVisible = 30 | |
| 25 | + | ||
| 26 | + | roleWords = 5 // int32 words of a role set in a MatchRule | |
| 27 | + | stateWords = 2 // int32 words of a state set in a MatchRule | |
| 28 | + | ||
| 29 | + | // AtspiCollectionMatchType and AtspiCollectionSortOrder values. | |
| 30 | + | matchAll = 1 | |
| 31 | + | matchAny = 2 | |
| 32 | + | sortCanonical = 1 | |
| 33 | + | matchCount = 500 | |
| 34 | + | maxDepth = 30 | |
| 35 | + | maxWalkNodes = 3000 | |
| 36 | + | maxFrames = 32 | |
| 37 | + | sizeTolerance = 40 | |
| 38 | + | maxMatchesRead = matchCount | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | // clickableRoles are the roles that become hint targets. | |
| 42 | + | var clickableRoles = []uint32{ | |
| 43 | + | roleLink, rolePushButton, roleToggleButton, roleCheckBox, | |
| 44 | + | roleRadioButton, roleMenuItem, rolePageTab, roleComboBox, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // matchRule is the Collection query: showing and visible objects of the | |
| 48 | + | // clickable roles (only links when linksOnly), plus web documents for coverage. | |
| 49 | + | type matchRule struct{ linksOnly bool } | |
| 50 | + | ||
| 51 | + | func (r matchRule) roles() []uint32 { | |
| 52 | + | if r.linksOnly { | |
| 53 | + | return []uint32{roleLink, roleDocumentFrame, roleDocumentWeb} | |
| 54 | + | } | |
| 55 | + | return append(append([]uint32(nil), clickableRoles...), roleDocumentFrame, roleDocumentWeb) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // roleBits packs role ids into the bit set of a MatchRule: bit id%32 of word | |
| 59 | + | // id/32. | |
| 60 | + | func roleBits(ids []uint32) []int32 { | |
| 61 | + | words := make([]int32, roleWords) | |
| 62 | + | for _, id := range ids { | |
| 63 | + | if w := int(id / 32); w < len(words) { | |
| 64 | + | words[w] |= int32(uint32(1) << (id % 32)) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | return words | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // stateBits packs state ids into the bit set of a MatchRule. | |
| 71 | + | func stateBits(ids ...uint32) []int32 { | |
| 72 | + | words := make([]int32, stateWords) | |
| 73 | + | for _, id := range ids { | |
| 74 | + | if w := int(id / 32); w < len(words) { | |
| 75 | + | words[w] |= int32(uint32(1) << (id % 32)) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | return words | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func hasState(set uint64, state uint) bool { return set&(1<<state) != 0 } | |
| 82 | + | ||
| 83 | + | // isClickable reports whether role makes a target. | |
| 84 | + | func isClickable(role uint32, linksOnly bool) bool { | |
| 85 | + | if linksOnly { | |
| 86 | + | return role == roleLink | |
| 87 | + | } | |
| 88 | + | for _, r := range clickableRoles { | |
| 89 | + | if r == role { | |
| 90 | + | return true | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | return false | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // isWindowRole reports whether role is a top-level window role. | |
| 97 | + | func isWindowRole(role uint32) bool { | |
| 98 | + | return role == roleFrame || role == roleWindow || role == roleDialog | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // kindOf maps a role to the hint kind: links are links, the rest buttons. | |
| 102 | + | func kindOf(role uint32) ports.HintKind { | |
| 103 | + | if role == roleLink { | |
| 104 | + | return ports.HintLink | |
| 105 | + | } | |
| 106 | + | return ports.HintButton | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // frameInfo is a showing top-level window of an app. | |
| 110 | + | type frameInfo struct { | |
| 111 | + | ref ref | |
| 112 | + | active bool | |
| 113 | + | w, h int // size of the frame, from its window extents | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // matchFrames pairs the windows of one app with its frames, one to one. A | |
| 117 | + | // window is paired with a frame when the pairing is certain: | |
| 118 | + | // - one window and one frame; | |
| 119 | + | // - the focused window with the one ACTIVE frame; that frame is then | |
| 120 | + | // reserved and no other window can take it; | |
| 121 | + | // - otherwise the one unreserved frame whose size is within sizeTolerance | |
| 122 | + | // of the window, provided no other unmatched window claims it as well. | |
| 123 | + | // | |
| 124 | + | // Ambiguous windows are left out. The result maps the index of a window in | |
| 125 | + | // wins to the index of its frame in frames. | |
| 126 | + | func matchFrames(wins []ports.HintWindow, frames []frameInfo) map[int]int { | |
| 127 | + | out := map[int]int{} | |
| 128 | + | if len(wins) == 1 && len(frames) == 1 { | |
| 129 | + | out[0] = 0 | |
| 130 | + | return out | |
| 131 | + | } | |
| 132 | + | // Active matches first: they are reserved against the size matches. | |
| 133 | + | active, n := -1, 0 | |
| 134 | + | for j, f := range frames { | |
| 135 | + | if f.active { | |
| 136 | + | active, n = j, n+1 | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | reserved := make([]bool, len(frames)) | |
| 140 | + | done := make([]bool, len(wins)) | |
| 141 | + | if n == 1 { | |
| 142 | + | focused := -1 | |
| 143 | + | count := 0 | |
| 144 | + | for i, w := range wins { | |
| 145 | + | if w.Focused { | |
| 146 | + | focused, count = i, count+1 | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | if count == 1 { | |
| 150 | + | out[focused] = active | |
| 151 | + | reserved[active] = true | |
| 152 | + | done[focused] = true | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | claims := make([][]int, len(wins)) // size candidates per window | |
| 156 | + | owners := make([]int, len(frames)) // windows that size-match a frame | |
| 157 | + | for i, w := range wins { | |
| 158 | + | if done[i] { | |
| 159 | + | continue | |
| 160 | + | } | |
| 161 | + | for j, f := range frames { | |
| 162 | + | if !reserved[j] && absInt(f.w-w.Rect.W) <= sizeTolerance && absInt(f.h-w.Rect.H) <= sizeTolerance { | |
| 163 | + | claims[i] = append(claims[i], j) | |
| 164 | + | owners[j]++ | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | for i := range wins { | |
| 169 | + | if !done[i] && len(claims[i]) == 1 && owners[claims[i][0]] == 1 { | |
| 170 | + | out[i] = claims[i][0] | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | return out | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func absInt(v int) int { | |
| 177 | + | if v < 0 { | |
| 178 | + | return -v | |
| 179 | + | } | |
| 180 | + | return v | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // element is a match with its extents. | |
| 184 | + | type element struct { | |
| 185 | + | role uint32 | |
| 186 | + | x, y, w, h int32 // window-relative | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // toTarget converts an element of a window into a logical output-local | |
| 190 | + | // target: the element extents are relative to the top-level frame, whose own | |
| 191 | + | // window-relative origin is (fx, fy); the result is clipped to the window. | |
| 192 | + | // It reports false for an empty result. | |
| 193 | + | func toTarget(win ports.Rect, fx, fy int32, e element) (ports.HintTarget, bool) { | |
| 194 | + | if e.w <= 0 || e.h <= 0 { | |
| 195 | + | return ports.HintTarget{}, false | |
| 196 | + | } | |
| 197 | + | 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)} | |
| 198 | + | x0, y0 := max(r.X, win.X), max(r.Y, win.Y) | |
| 199 | + | x1, y1 := min(r.X+r.W, win.X+win.W), min(r.Y+r.H, win.Y+win.H) | |
| 200 | + | if x1 <= x0 || y1 <= y0 { | |
| 201 | + | return ports.HintTarget{}, false | |
| 202 | + | } | |
| 203 | + | return ports.HintTarget{Rect: ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}, Kind: kindOf(e.role)}, true | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func isDocument(role uint32) bool { return role == roleDocumentFrame || role == roleDocumentWeb } | |
| @@ -0,0 +1,206 @@ | |||||
| 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 | + | roleDocumentFrame = 82 | |||
| 20 | + | roleDocumentWeb = 95 | |||
| 21 | + | ||||
| 22 | + | stateActive = 1 | |||
| 23 | + | stateShowing = 25 | |||
| 24 | + | stateVisible = 30 | |||
| 25 | + | ||||
| 26 | + | roleWords = 5 // int32 words of a role set in a MatchRule | |||
| 27 | + | stateWords = 2 // int32 words of a state set in a MatchRule | |||
| 28 | + | ||||
| 29 | + | // AtspiCollectionMatchType and AtspiCollectionSortOrder values. | |||
| 30 | + | matchAll = 1 | |||
| 31 | + | matchAny = 2 | |||
| 32 | + | sortCanonical = 1 | |||
| 33 | + | matchCount = 500 | |||
| 34 | + | maxDepth = 30 | |||
| 35 | + | maxWalkNodes = 3000 | |||
| 36 | + | maxFrames = 32 | |||
| 37 | + | sizeTolerance = 40 | |||
| 38 | + | maxMatchesRead = matchCount | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | // clickableRoles are the roles that become hint targets. | |||
| 42 | + | var clickableRoles = []uint32{ | |||
| 43 | + | roleLink, rolePushButton, roleToggleButton, roleCheckBox, | |||
| 44 | + | roleRadioButton, roleMenuItem, rolePageTab, roleComboBox, | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // matchRule is the Collection query: showing and visible objects of the | |||
| 48 | + | // clickable roles (only links when linksOnly), plus web documents for coverage. | |||
| 49 | + | type matchRule struct{ linksOnly bool } | |||
| 50 | + | ||||
| 51 | + | func (r matchRule) roles() []uint32 { | |||
| 52 | + | if r.linksOnly { | |||
| 53 | + | return []uint32{roleLink, roleDocumentFrame, roleDocumentWeb} | |||
| 54 | + | } | |||
| 55 | + | return append(append([]uint32(nil), clickableRoles...), roleDocumentFrame, roleDocumentWeb) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // roleBits packs role ids into the bit set of a MatchRule: bit id%32 of word | |||
| 59 | + | // id/32. | |||
| 60 | + | func roleBits(ids []uint32) []int32 { | |||
| 61 | + | words := make([]int32, roleWords) | |||
| 62 | + | for _, id := range ids { | |||
| 63 | + | if w := int(id / 32); w < len(words) { | |||
| 64 | + | words[w] |= int32(uint32(1) << (id % 32)) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | return words | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // stateBits packs state ids into the bit set of a MatchRule. | |||
| 71 | + | func stateBits(ids ...uint32) []int32 { | |||
| 72 | + | words := make([]int32, stateWords) | |||
| 73 | + | for _, id := range ids { | |||
| 74 | + | if w := int(id / 32); w < len(words) { | |||
| 75 | + | words[w] |= int32(uint32(1) << (id % 32)) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | return words | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func hasState(set uint64, state uint) bool { return set&(1<<state) != 0 } | |||
| 82 | + | ||||
| 83 | + | // isClickable reports whether role makes a target. | |||
| 84 | + | func isClickable(role uint32, linksOnly bool) bool { | |||
| 85 | + | if linksOnly { | |||
| 86 | + | return role == roleLink | |||
| 87 | + | } | |||
| 88 | + | for _, r := range clickableRoles { | |||
| 89 | + | if r == role { | |||
| 90 | + | return true | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | return false | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // isWindowRole reports whether role is a top-level window role. | |||
| 97 | + | func isWindowRole(role uint32) bool { | |||
| 98 | + | return role == roleFrame || role == roleWindow || role == roleDialog | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // kindOf maps a role to the hint kind: links are links, the rest buttons. | |||
| 102 | + | func kindOf(role uint32) ports.HintKind { | |||
| 103 | + | if role == roleLink { | |||
| 104 | + | return ports.HintLink | |||
| 105 | + | } | |||
| 106 | + | return ports.HintButton | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // frameInfo is a showing top-level window of an app. | |||
| 110 | + | type frameInfo struct { | |||
| 111 | + | ref ref | |||
| 112 | + | active bool | |||
| 113 | + | w, h int // size of the frame, from its window extents | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // matchFrames pairs the windows of one app with its frames, one to one. A | |||
| 117 | + | // window is paired with a frame when the pairing is certain: | |||
| 118 | + | // - one window and one frame; | |||
| 119 | + | // - the focused window with the one ACTIVE frame; that frame is then | |||
| 120 | + | // reserved and no other window can take it; | |||
| 121 | + | // - otherwise the one unreserved frame whose size is within sizeTolerance | |||
| 122 | + | // of the window, provided no other unmatched window claims it as well. | |||
| 123 | + | // | |||
| 124 | + | // Ambiguous windows are left out. The result maps the index of a window in | |||
| 125 | + | // wins to the index of its frame in frames. | |||
| 126 | + | func matchFrames(wins []ports.HintWindow, frames []frameInfo) map[int]int { | |||
| 127 | + | out := map[int]int{} | |||
| 128 | + | if len(wins) == 1 && len(frames) == 1 { | |||
| 129 | + | out[0] = 0 | |||
| 130 | + | return out | |||
| 131 | + | } | |||
| 132 | + | // Active matches first: they are reserved against the size matches. | |||
| 133 | + | active, n := -1, 0 | |||
| 134 | + | for j, f := range frames { | |||
| 135 | + | if f.active { | |||
| 136 | + | active, n = j, n+1 | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | reserved := make([]bool, len(frames)) | |||
| 140 | + | done := make([]bool, len(wins)) | |||
| 141 | + | if n == 1 { | |||
| 142 | + | focused := -1 | |||
| 143 | + | count := 0 | |||
| 144 | + | for i, w := range wins { | |||
| 145 | + | if w.Focused { | |||
| 146 | + | focused, count = i, count+1 | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | if count == 1 { | |||
| 150 | + | out[focused] = active | |||
| 151 | + | reserved[active] = true | |||
| 152 | + | done[focused] = true | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | claims := make([][]int, len(wins)) // size candidates per window | |||
| 156 | + | owners := make([]int, len(frames)) // windows that size-match a frame | |||
| 157 | + | for i, w := range wins { | |||
| 158 | + | if done[i] { | |||
| 159 | + | continue | |||
| 160 | + | } | |||
| 161 | + | for j, f := range frames { | |||
| 162 | + | if !reserved[j] && absInt(f.w-w.Rect.W) <= sizeTolerance && absInt(f.h-w.Rect.H) <= sizeTolerance { | |||
| 163 | + | claims[i] = append(claims[i], j) | |||
| 164 | + | owners[j]++ | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | for i := range wins { | |||
| 169 | + | if !done[i] && len(claims[i]) == 1 && owners[claims[i][0]] == 1 { | |||
| 170 | + | out[i] = claims[i][0] | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | return out | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func absInt(v int) int { | |||
| 177 | + | if v < 0 { | |||
| 178 | + | return -v | |||
| 179 | + | } | |||
| 180 | + | return v | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // element is a match with its extents. | |||
| 184 | + | type element struct { | |||
| 185 | + | role uint32 | |||
| 186 | + | x, y, w, h int32 // window-relative | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // toTarget converts an element of a window into a logical output-local | |||
| 190 | + | // target: the element extents are relative to the top-level frame, whose own | |||
| 191 | + | // window-relative origin is (fx, fy); the result is clipped to the window. | |||
| 192 | + | // It reports false for an empty result. | |||
| 193 | + | func toTarget(win ports.Rect, fx, fy int32, e element) (ports.HintTarget, bool) { | |||
| 194 | + | if e.w <= 0 || e.h <= 0 { | |||
| 195 | + | return ports.HintTarget{}, false | |||
| 196 | + | } | |||
| 197 | + | 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)} | |||
| 198 | + | x0, y0 := max(r.X, win.X), max(r.Y, win.Y) | |||
| 199 | + | x1, y1 := min(r.X+r.W, win.X+win.W), min(r.Y+r.H, win.Y+win.H) | |||
| 200 | + | if x1 <= x0 || y1 <= y0 { | |||
| 201 | + | return ports.HintTarget{}, false | |||
| 202 | + | } | |||
| 203 | + | return ports.HintTarget{Rect: ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}, Kind: kindOf(e.role)}, true | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func isDocument(role uint32) bool { return role == roleDocumentFrame || role == roleDocumentWeb } | |||
A internal/adapters/atspi/model_test.go +117 -0
| @@ -0,0 +1,117 @@ | |||
| 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 and reserves it; the other | |
| 77 | + | // window then only has the remaining frame to match. | |
| 78 | + | require.Equal(t, map[int]int{0: 1, 1: 0}, 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 | + | } | |
| 105 | + | ||
| 106 | + | // The active frame is reserved by the focused window: an unfocused window of | |
| 107 | + | // the same size as that frame must not be paired with it as well. | |
| 108 | + | func TestMatchFramesOneToOne(t *testing.T) { | |
| 109 | + | frames := []frameInfo{{ref: ref{"a", "/active"}, active: true, w: 400, h: 300}, {ref: ref{"a", "/idle"}, w: 800, h: 600}} | |
| 110 | + | got := matchFrames(wins(win(1, 800, 600, true), win(2, 400, 300, false)), frames) | |
| 111 | + | seen := map[int]bool{} | |
| 112 | + | for _, f := range got { | |
| 113 | + | require.False(t, seen[f], "frame %d assigned twice: %v", f, got) | |
| 114 | + | seen[f] = true | |
| 115 | + | } | |
| 116 | + | require.Equal(t, map[int]int{0: 0}, got) | |
| 117 | + | } | |
| @@ -0,0 +1,117 @@ | |||||
| 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 and reserves it; the other | |||
| 77 | + | // window then only has the remaining frame to match. | |||
| 78 | + | require.Equal(t, map[int]int{0: 1, 1: 0}, 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 | + | } | |||
| 105 | + | ||||
| 106 | + | // The active frame is reserved by the focused window: an unfocused window of | |||
| 107 | + | // the same size as that frame must not be paired with it as well. | |||
| 108 | + | func TestMatchFramesOneToOne(t *testing.T) { | |||
| 109 | + | frames := []frameInfo{{ref: ref{"a", "/active"}, active: true, w: 400, h: 300}, {ref: ref{"a", "/idle"}, w: 800, h: 600}} | |||
| 110 | + | got := matchFrames(wins(win(1, 800, 600, true), win(2, 400, 300, false)), frames) | |||
| 111 | + | seen := map[int]bool{} | |||
| 112 | + | for _, f := range got { | |||
| 113 | + | require.False(t, seen[f], "frame %d assigned twice: %v", f, got) | |||
| 114 | + | seen[f] = true | |||
| 115 | + | } | |||
| 116 | + | require.Equal(t, map[int]int{0: 0}, got) | |||
| 117 | + | } | |||
A internal/adapters/atspi/source.go +482 -0
| @@ -0,0 +1,482 @@ | |||
| 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 | + | // connectBudget bounds the whole connection setup (session bus, | |
| 24 | + | // GetAddress, IsEnabled, dial of the accessibility bus) of one attempt. | |
| 25 | + | const connectBudget = 300 * time.Millisecond | |
| 26 | + | ||
| 27 | + | // retryEvery is the minimum time between two connection attempts. | |
| 28 | + | const retryEvery = 5 * time.Second | |
| 29 | + | ||
| 30 | + | // connector opens a transport on the accessibility bus. | |
| 31 | + | type 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. | |
| 37 | + | const stallWarnAfter = 5 * time.Second | |
| 38 | + | ||
| 39 | + | // dialResult is the outcome of a connection attempt. | |
| 40 | + | type 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. | |
| 56 | + | type 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. | |
| 72 | + | func New() *Source { return &Source{dial: busConnector{}} } | |
| 73 | + | ||
| 74 | + | // busConnector connects to the session bus, enables accessibility and dials | |
| 75 | + | // the accessibility bus. | |
| 76 | + | type busConnector struct{} | |
| 77 | + | ||
| 78 | + | func (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. | |
| 135 | + | func (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. | |
| 190 | + | func (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. | |
| 206 | + | func (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. | |
| 217 | + | func (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. | |
| 240 | + | type appInfo struct { | |
| 241 | + | root ref | |
| 242 | + | pid int | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // winMatch is a window with the frame it was paired with. | |
| 246 | + | type 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. | |
| 258 | + | func (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. | |
| 390 | + | func (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. | |
| 449 | + | func (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 | + | var next []ref | |
| 463 | + | for i, n := range level { | |
| 464 | + | ch, role, state := rep[3*i], rep[3*i+1], rep[3*i+2] | |
| 465 | + | if state.err == nil && !hasState(state.state, stateShowing) { | |
| 466 | + | continue | |
| 467 | + | } | |
| 468 | + | if role.err == nil && (isClickable(role.u, linksOnly) || isDocument(role.u)) && state.err == nil && hasState(state.state, stateVisible) { | |
| 469 | + | out = append(out, n) | |
| 470 | + | } | |
| 471 | + | if ch.err == nil { | |
| 472 | + | next = append(next, ch.refs...) | |
| 473 | + | } | |
| 474 | + | } | |
| 475 | + | seen += len(level) | |
| 476 | + | if room := maxWalkNodes - seen; len(next) > room { | |
| 477 | + | next = next[:max(room, 0)] | |
| 478 | + | } | |
| 479 | + | level = next | |
| 480 | + | } | |
| 481 | + | return out | |
| 482 | + | } | |
| @@ -0,0 +1,482 @@ | |||||
| 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 | + | // connectBudget bounds the whole connection setup (session bus, | |||
| 24 | + | // GetAddress, IsEnabled, dial of the accessibility bus) of one attempt. | |||
| 25 | + | const connectBudget = 300 * time.Millisecond | |||
| 26 | + | ||||
| 27 | + | // retryEvery is the minimum time between two connection attempts. | |||
| 28 | + | const retryEvery = 5 * time.Second | |||
| 29 | + | ||||
| 30 | + | // connector opens a transport on the accessibility bus. | |||
| 31 | + | type 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. | |||
| 37 | + | const stallWarnAfter = 5 * time.Second | |||
| 38 | + | ||||
| 39 | + | // dialResult is the outcome of a connection attempt. | |||
| 40 | + | type 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. | |||
| 56 | + | type 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. | |||
| 72 | + | func New() *Source { return &Source{dial: busConnector{}} } | |||
| 73 | + | ||||
| 74 | + | // busConnector connects to the session bus, enables accessibility and dials | |||
| 75 | + | // the accessibility bus. | |||
| 76 | + | type busConnector struct{} | |||
| 77 | + | ||||
| 78 | + | func (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. | |||
| 135 | + | func (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. | |||
| 190 | + | func (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. | |||
| 206 | + | func (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. | |||
| 217 | + | func (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. | |||
| 240 | + | type appInfo struct { | |||
| 241 | + | root ref | |||
| 242 | + | pid int | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // winMatch is a window with the frame it was paired with. | |||
| 246 | + | type 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. | |||
| 258 | + | func (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. | |||
| 390 | + | func (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. | |||
| 449 | + | func (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 | + | var next []ref | |||
| 463 | + | for i, n := range level { | |||
| 464 | + | ch, role, state := rep[3*i], rep[3*i+1], rep[3*i+2] | |||
| 465 | + | if state.err == nil && !hasState(state.state, stateShowing) { | |||
| 466 | + | continue | |||
| 467 | + | } | |||
| 468 | + | if role.err == nil && (isClickable(role.u, linksOnly) || isDocument(role.u)) && state.err == nil && hasState(state.state, stateVisible) { | |||
| 469 | + | out = append(out, n) | |||
| 470 | + | } | |||
| 471 | + | if ch.err == nil { | |||
| 472 | + | next = append(next, ch.refs...) | |||
| 473 | + | } | |||
| 474 | + | } | |||
| 475 | + | seen += len(level) | |||
| 476 | + | if room := maxWalkNodes - seen; len(next) > room { | |||
| 477 | + | next = next[:max(room, 0)] | |||
| 478 | + | } | |||
| 479 | + | level = next | |||
| 480 | + | } | |||
| 481 | + | return out | |||
| 482 | + | } | |||
A internal/adapters/atspi/source_test.go +315 -0
| @@ -0,0 +1,315 @@ | |||
| 1 | + | package atspi | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/neferwl/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | const ( | |
| 17 | + | stActive = 1 << stateActive | |
| 18 | + | stShowing = 1 << stateShowing | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // tree answers calls like an app: registry with one app (:1.5, pid 77) with | |
| 22 | + | // one showing frame whose links sit at window-relative extents. | |
| 23 | + | 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 { | |
| 24 | + | return func(_ time.Time, _ time.Duration, calls []call) []reply { | |
| 25 | + | out := make([]reply, len(calls)) | |
| 26 | + | for i, c := range calls { | |
| 27 | + | switch { | |
| 28 | + | case c.op == opChildren && c.name == registryName: | |
| 29 | + | out[i].refs = []ref{{":1.5", "/root"}, {":1.9", "/root"}} | |
| 30 | + | case c.op == opPID: | |
| 31 | + | out[i].u = map[string]uint32{":1.5": 77, ":1.9": 99}[c.name] | |
| 32 | + | case c.op == opChildren && c.path == "/root": | |
| 33 | + | out[i].refs = []ref{{":1.5", "/frame"}} | |
| 34 | + | case c.op == opRole && c.path == "/frame": | |
| 35 | + | out[i].u = roleFrame | |
| 36 | + | case c.op == opState && c.path == "/frame": | |
| 37 | + | out[i].state = stShowing | stActive | |
| 38 | + | case c.op == opExtents && c.path == "/frame": | |
| 39 | + | out[i].rect = frameExt | |
| 40 | + | case c.op == opMatches: | |
| 41 | + | if noCollection { | |
| 42 | + | out[i].err = &busError{name: "org.freedesktop.DBus.Error.UnknownMethod"} | |
| 43 | + | } else { | |
| 44 | + | out[i].refs = matches | |
| 45 | + | } | |
| 46 | + | case c.op == opChildren && c.path == "/frame": | |
| 47 | + | out[i].refs = matches | |
| 48 | + | case c.op == opChildren: | |
| 49 | + | case c.op == opRole: | |
| 50 | + | out[i].u = roles[c.path] | |
| 51 | + | case c.op == opState: | |
| 52 | + | out[i].state = stShowing | 1<<stateVisible | |
| 53 | + | case c.op == opExtents: | |
| 54 | + | out[i].rect = ext[c.path] | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | return out | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestTargetsCoversWindowAndConverts(t *testing.T) { | |
| 62 | + | tr := newMocktransport(t) | |
| 63 | + | matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}, {":1.5", "/zero"}} | |
| 64 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 65 | + | [4]int32{4, 30, 800, 600}, matches, | |
| 66 | + | map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}, "/zero": {5, 5, 0, 0}}, | |
| 67 | + | map[string]uint32{"/a": roleLink, "/b": rolePushButton, "/zero": roleLink}, false)) | |
| 68 | + | s := &Source{tr: tr} | |
| 69 | + | ||
| 70 | + | windows := []ports.HintWindow{ | |
| 71 | + | {ID: 1, PID: 77, Rect: ports.Rect{X: 100, Y: 200, W: 800, H: 600}, Focused: true}, | |
| 72 | + | {ID: 2, PID: 5, Rect: ports.Rect{X: 0, Y: 0, W: 50, H: 50}}, | |
| 73 | + | } | |
| 74 | + | targets, covered, err := s.Targets(context.Background(), windows, false) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | require.Equal(t, map[ports.WindowID]bool{1: true}, covered) | |
| 77 | + | require.Equal(t, []ports.HintTarget{ | |
| 78 | + | {Rect: ports.Rect{X: 110, Y: 300, W: 50, H: 16}, Kind: ports.HintLink, Window: 1}, | |
| 79 | + | {Rect: ports.Rect{X: 200, Y: 400, W: 20, H: 20}, Kind: ports.HintButton, Window: 1}, | |
| 80 | + | }, targets) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestTargetsLinksOnlyDropsButtons(t *testing.T) { | |
| 84 | + | tr := newMocktransport(t) | |
| 85 | + | matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}} | |
| 86 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 87 | + | [4]int32{0, 0, 800, 600}, matches, | |
| 88 | + | map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}}, | |
| 89 | + | map[string]uint32{"/a": roleLink, "/b": rolePushButton}, false)) | |
| 90 | + | s := &Source{tr: tr} | |
| 91 | + | targets, _, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, true) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | require.Len(t, targets, 1) | |
| 94 | + | require.Equal(t, ports.HintLink, targets[0].Kind) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestTargetsWalkWithoutCollection(t *testing.T) { | |
| 98 | + | tr := newMocktransport(t) | |
| 99 | + | matches := []ref{{":1.5", "/a"}} | |
| 100 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 101 | + | [4]int32{0, 0, 800, 600}, matches, | |
| 102 | + | map[string][4]int32{"/a": {14, 130, 50, 16}}, | |
| 103 | + | map[string]uint32{"/a": roleLink}, true)) | |
| 104 | + | s := &Source{tr: tr} | |
| 105 | + | targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | require.Len(t, targets, 1) | |
| 108 | + | require.True(t, covered[1]) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestTargetsNoPIDMatchCoversNothing(t *testing.T) { | |
| 112 | + | tr := newMocktransport(t) | |
| 113 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree([4]int32{}, nil, nil, nil, false)) | |
| 114 | + | s := &Source{tr: tr} | |
| 115 | + | targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 1234, Rect: ports.Rect{W: 8, H: 6}}}, false) | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | require.Empty(t, targets) | |
| 118 | + | require.Empty(t, covered) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestTargetsWithoutPIDsAsksNothing(t *testing.T) { | |
| 122 | + | s := &Source{tr: newMocktransport(t)} | |
| 123 | + | _, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 8, H: 6}}}, false) | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | require.Empty(t, covered) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestCoverageRequiresLinkOrDocument(t *testing.T) { | |
| 129 | + | for _, collection := range []bool{true, false} { | |
| 130 | + | for _, role := range []uint32{rolePushButton, roleDocumentFrame, roleDocumentWeb, roleLink} { | |
| 131 | + | t.Run(fmt.Sprintf("collection=%t/role=%d", collection, role), func(t *testing.T) { | |
| 132 | + | tr := newMocktransport(t) | |
| 133 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 134 | + | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {10, 20, 30, 40}}, map[string]uint32{"/a": role}, !collection)) | |
| 135 | + | s := &Source{tr: tr} | |
| 136 | + | targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false) | |
| 137 | + | require.NoError(t, err) | |
| 138 | + | require.Equal(t, role != rolePushButton, covered[1]) | |
| 139 | + | if isDocument(role) { | |
| 140 | + | require.Empty(t, targets) | |
| 141 | + | } else { | |
| 142 | + | require.Len(t, targets, 1) | |
| 143 | + | } | |
| 144 | + | }) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | var oneWindow = []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}} | |
| 150 | + | ||
| 151 | + | // New does not touch the bus; the first Targets call connects, once. | |
| 152 | + | func TestConnectsLazilyOnFirstTargets(t *testing.T) { | |
| 153 | + | tr := newMocktransport(t) | |
| 154 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 155 | + | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false)) | |
| 156 | + | c := newMockconnector(t) | |
| 157 | + | c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once() | |
| 158 | + | s := &Source{dial: c} | |
| 159 | + | for range 2 { | |
| 160 | + | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 161 | + | require.NoError(t, err) | |
| 162 | + | require.Len(t, targets, 1) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // Without PIDs nothing is asked and nothing is connected. | |
| 167 | + | func TestNoConnectWithoutWindows(t *testing.T) { | |
| 168 | + | s := &Source{dial: newMockconnector(t)} | |
| 169 | + | _, _, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1}}, false) | |
| 170 | + | require.NoError(t, err) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // A failed connection is retried no sooner than retryEvery; meanwhile no | |
| 174 | + | // targets are returned. | |
| 175 | + | func TestConnectRetriesWithBackoff(t *testing.T) { | |
| 176 | + | c := newMockconnector(t) | |
| 177 | + | c.EXPECT().Connect(mock.Anything).Return(nil, errors.New("down")).Once() | |
| 178 | + | s := &Source{dial: c} | |
| 179 | + | for range 3 { | |
| 180 | + | targets, covered, err := s.Targets(context.Background(), oneWindow, false) | |
| 181 | + | require.NoError(t, err) | |
| 182 | + | require.Empty(t, targets) | |
| 183 | + | require.Empty(t, covered) | |
| 184 | + | } | |
| 185 | + | tr := newMocktransport(t) | |
| 186 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 187 | + | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false)) | |
| 188 | + | c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once() | |
| 189 | + | s.retryAt = time.Now().Add(-time.Second) | |
| 190 | + | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 191 | + | require.NoError(t, err) | |
| 192 | + | require.Len(t, targets, 1) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // A broken connection is closed and dropped; the next attempt reconnects. | |
| 196 | + | func TestBrokenConnectionIsDropped(t *testing.T) { | |
| 197 | + | tr := newMocktransport(t) | |
| 198 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 199 | + | func(_ time.Time, _ time.Duration, calls []call) []reply { | |
| 200 | + | out := make([]reply, len(calls)) | |
| 201 | + | for i := range out { | |
| 202 | + | out[i].err = ErrBroken | |
| 203 | + | } | |
| 204 | + | return out | |
| 205 | + | }).Once() | |
| 206 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 207 | + | c := newMockconnector(t) | |
| 208 | + | c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once() | |
| 209 | + | s := &Source{dial: c} | |
| 210 | + | _, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 211 | + | require.Error(t, err) | |
| 212 | + | require.Nil(t, s.tr) | |
| 213 | + | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 214 | + | require.NoError(t, err) | |
| 215 | + | require.Empty(t, targets) // backoff: no new attempt yet | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | const testWait = 20 * time.Millisecond | |
| 219 | + | ||
| 220 | + | // blockedConnector returns a connector whose Connect blocks until release is | |
| 221 | + | func blockedConnector(t *testing.T, release <-chan struct{}, tr transport, err error, times int) *mockconnector { | |
| 222 | + | t.Helper() | |
| 223 | + | c := newMockconnector(t) | |
| 224 | + | c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) { | |
| 225 | + | <-release | |
| 226 | + | return tr, err | |
| 227 | + | }).Times(times) | |
| 228 | + | return c | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // A silent service cannot hold Targets beyond the wait, is not doubled while | |
| 232 | + | // in flight, and its late transport is used. | |
| 233 | + | func TestConnectIsBoundedAndNotDoubled(t *testing.T) { | |
| 234 | + | release := make(chan struct{}) | |
| 235 | + | tr := newMocktransport(t) | |
| 236 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 237 | + | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false)) | |
| 238 | + | c := blockedConnector(t, release, tr, nil, 1) | |
| 239 | + | s := &Source{dial: c, wait: testWait} | |
| 240 | + | t0 := time.Now() | |
| 241 | + | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 242 | + | require.NoError(t, err) | |
| 243 | + | require.Empty(t, targets) | |
| 244 | + | require.Less(t, time.Since(t0), testWait+200*time.Millisecond) | |
| 245 | + | require.NotNil(t, s.attempt) | |
| 246 | + | // Still in flight: no second Connect (Times(1)), and no blocking. | |
| 247 | + | targets, _, err = s.Targets(context.Background(), oneWindow, false) | |
| 248 | + | require.NoError(t, err) | |
| 249 | + | require.Empty(t, targets) | |
| 250 | + | // It connects late: the next call uses it. | |
| 251 | + | close(release) | |
| 252 | + | require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond) | |
| 253 | + | targets, _, err = s.Targets(context.Background(), oneWindow, false) | |
| 254 | + | require.NoError(t, err) | |
| 255 | + | require.Len(t, targets, 1) | |
| 256 | + | require.Nil(t, s.attempt) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // An abandoned attempt that fails applies the backoff, then one new attempt. | |
| 260 | + | func TestAbandonedAttemptErrorBacksOff(t *testing.T) { | |
| 261 | + | release := make(chan struct{}) | |
| 262 | + | tr := newMocktransport(t) | |
| 263 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |
| 264 | + | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false)) | |
| 265 | + | c := newMockconnector(t) | |
| 266 | + | c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) { | |
| 267 | + | <-release | |
| 268 | + | return nil, errors.New("down") | |
| 269 | + | }).Once() | |
| 270 | + | c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once() | |
| 271 | + | s := &Source{dial: c, wait: testWait} | |
| 272 | + | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 273 | + | require.NoError(t, err) | |
| 274 | + | require.Empty(t, targets) | |
| 275 | + | close(release) | |
| 276 | + | require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond) | |
| 277 | + | // The error is collected; the backoff runs from its finish time. | |
| 278 | + | targets, _, err = s.Targets(context.Background(), oneWindow, false) | |
| 279 | + | require.NoError(t, err) | |
| 280 | + | require.Empty(t, targets) | |
| 281 | + | require.Nil(t, s.attempt) | |
| 282 | + | require.WithinDuration(t, time.Now().Add(retryEvery), s.retryAt, time.Second) | |
| 283 | + | targets, _, err = s.Targets(context.Background(), oneWindow, false) // backoff: no Connect | |
| 284 | + | require.NoError(t, err) | |
| 285 | + | require.Empty(t, targets) | |
| 286 | + | s.retryAt = time.Now().Add(-time.Second) | |
| 287 | + | targets, _, err = s.Targets(context.Background(), oneWindow, false) | |
| 288 | + | require.NoError(t, err) | |
| 289 | + | require.Len(t, targets, 1) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // Close with an attempt in flight closes the late transport; afterwards | |
| 293 | + | // Targets finds nothing and never connects. | |
| 294 | + | func TestCloseReapsLateTransport(t *testing.T) { | |
| 295 | + | release := make(chan struct{}) | |
| 296 | + | closed := make(chan struct{}) | |
| 297 | + | tr := newMocktransport(t) | |
| 298 | + | tr.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once() | |
| 299 | + | c := blockedConnector(t, release, tr, nil, 1) | |
| 300 | + | s := &Source{dial: c, wait: testWait} | |
| 301 | + | _, _, err := s.Targets(context.Background(), oneWindow, false) | |
| 302 | + | require.NoError(t, err) | |
| 303 | + | require.NoError(t, s.Close()) | |
| 304 | + | close(release) | |
| 305 | + | select { | |
| 306 | + | case <-closed: | |
| 307 | + | case <-time.After(5 * time.Second): | |
| 308 | + | t.Fatal("late transport not closed") | |
| 309 | + | } | |
| 310 | + | targets, covered, err := s.Targets(context.Background(), oneWindow, false) | |
| 311 | + | require.NoError(t, err) | |
| 312 | + | require.Empty(t, targets) | |
| 313 | + | require.Empty(t, covered) | |
| 314 | + | require.NoError(t, s.Close()) // idempotent | |
| 315 | + | } | |
| @@ -0,0 +1,315 @@ | |||||
| 1 | + | package atspi | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | stActive = 1 << stateActive | |||
| 18 | + | stShowing = 1 << stateShowing | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // tree answers calls like an app: registry with one app (:1.5, pid 77) with | |||
| 22 | + | // one showing frame whose links sit at window-relative extents. | |||
| 23 | + | 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 { | |||
| 24 | + | return func(_ time.Time, _ time.Duration, calls []call) []reply { | |||
| 25 | + | out := make([]reply, len(calls)) | |||
| 26 | + | for i, c := range calls { | |||
| 27 | + | switch { | |||
| 28 | + | case c.op == opChildren && c.name == registryName: | |||
| 29 | + | out[i].refs = []ref{{":1.5", "/root"}, {":1.9", "/root"}} | |||
| 30 | + | case c.op == opPID: | |||
| 31 | + | out[i].u = map[string]uint32{":1.5": 77, ":1.9": 99}[c.name] | |||
| 32 | + | case c.op == opChildren && c.path == "/root": | |||
| 33 | + | out[i].refs = []ref{{":1.5", "/frame"}} | |||
| 34 | + | case c.op == opRole && c.path == "/frame": | |||
| 35 | + | out[i].u = roleFrame | |||
| 36 | + | case c.op == opState && c.path == "/frame": | |||
| 37 | + | out[i].state = stShowing | stActive | |||
| 38 | + | case c.op == opExtents && c.path == "/frame": | |||
| 39 | + | out[i].rect = frameExt | |||
| 40 | + | case c.op == opMatches: | |||
| 41 | + | if noCollection { | |||
| 42 | + | out[i].err = &busError{name: "org.freedesktop.DBus.Error.UnknownMethod"} | |||
| 43 | + | } else { | |||
| 44 | + | out[i].refs = matches | |||
| 45 | + | } | |||
| 46 | + | case c.op == opChildren && c.path == "/frame": | |||
| 47 | + | out[i].refs = matches | |||
| 48 | + | case c.op == opChildren: | |||
| 49 | + | case c.op == opRole: | |||
| 50 | + | out[i].u = roles[c.path] | |||
| 51 | + | case c.op == opState: | |||
| 52 | + | out[i].state = stShowing | 1<<stateVisible | |||
| 53 | + | case c.op == opExtents: | |||
| 54 | + | out[i].rect = ext[c.path] | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | return out | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestTargetsCoversWindowAndConverts(t *testing.T) { | |||
| 62 | + | tr := newMocktransport(t) | |||
| 63 | + | matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}, {":1.5", "/zero"}} | |||
| 64 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |||
| 65 | + | [4]int32{4, 30, 800, 600}, matches, | |||
| 66 | + | map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}, "/zero": {5, 5, 0, 0}}, | |||
| 67 | + | map[string]uint32{"/a": roleLink, "/b": rolePushButton, "/zero": roleLink}, false)) | |||
| 68 | + | s := &Source{tr: tr} | |||
| 69 | + | ||||
| 70 | + | windows := []ports.HintWindow{ | |||
| 71 | + | {ID: 1, PID: 77, Rect: ports.Rect{X: 100, Y: 200, W: 800, H: 600}, Focused: true}, | |||
| 72 | + | {ID: 2, PID: 5, Rect: ports.Rect{X: 0, Y: 0, W: 50, H: 50}}, | |||
| 73 | + | } | |||
| 74 | + | targets, covered, err := s.Targets(context.Background(), windows, false) | |||
| 75 | + | require.NoError(t, err) | |||
| 76 | + | require.Equal(t, map[ports.WindowID]bool{1: true}, covered) | |||
| 77 | + | require.Equal(t, []ports.HintTarget{ | |||
| 78 | + | {Rect: ports.Rect{X: 110, Y: 300, W: 50, H: 16}, Kind: ports.HintLink, Window: 1}, | |||
| 79 | + | {Rect: ports.Rect{X: 200, Y: 400, W: 20, H: 20}, Kind: ports.HintButton, Window: 1}, | |||
| 80 | + | }, targets) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestTargetsLinksOnlyDropsButtons(t *testing.T) { | |||
| 84 | + | tr := newMocktransport(t) | |||
| 85 | + | matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}} | |||
| 86 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |||
| 87 | + | [4]int32{0, 0, 800, 600}, matches, | |||
| 88 | + | map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}}, | |||
| 89 | + | map[string]uint32{"/a": roleLink, "/b": rolePushButton}, false)) | |||
| 90 | + | s := &Source{tr: tr} | |||
| 91 | + | targets, _, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, true) | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | require.Len(t, targets, 1) | |||
| 94 | + | require.Equal(t, ports.HintLink, targets[0].Kind) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestTargetsWalkWithoutCollection(t *testing.T) { | |||
| 98 | + | tr := newMocktransport(t) | |||
| 99 | + | matches := []ref{{":1.5", "/a"}} | |||
| 100 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |||
| 101 | + | [4]int32{0, 0, 800, 600}, matches, | |||
| 102 | + | map[string][4]int32{"/a": {14, 130, 50, 16}}, | |||
| 103 | + | map[string]uint32{"/a": roleLink}, true)) | |||
| 104 | + | s := &Source{tr: tr} | |||
| 105 | + | targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | require.Len(t, targets, 1) | |||
| 108 | + | require.True(t, covered[1]) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestTargetsNoPIDMatchCoversNothing(t *testing.T) { | |||
| 112 | + | tr := newMocktransport(t) | |||
| 113 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree([4]int32{}, nil, nil, nil, false)) | |||
| 114 | + | s := &Source{tr: tr} | |||
| 115 | + | targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 1234, Rect: ports.Rect{W: 8, H: 6}}}, false) | |||
| 116 | + | require.NoError(t, err) | |||
| 117 | + | require.Empty(t, targets) | |||
| 118 | + | require.Empty(t, covered) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func TestTargetsWithoutPIDsAsksNothing(t *testing.T) { | |||
| 122 | + | s := &Source{tr: newMocktransport(t)} | |||
| 123 | + | _, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 8, H: 6}}}, false) | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | require.Empty(t, covered) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestCoverageRequiresLinkOrDocument(t *testing.T) { | |||
| 129 | + | for _, collection := range []bool{true, false} { | |||
| 130 | + | for _, role := range []uint32{rolePushButton, roleDocumentFrame, roleDocumentWeb, roleLink} { | |||
| 131 | + | t.Run(fmt.Sprintf("collection=%t/role=%d", collection, role), func(t *testing.T) { | |||
| 132 | + | tr := newMocktransport(t) | |||
| 133 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |||
| 134 | + | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {10, 20, 30, 40}}, map[string]uint32{"/a": role}, !collection)) | |||
| 135 | + | s := &Source{tr: tr} | |||
| 136 | + | targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false) | |||
| 137 | + | require.NoError(t, err) | |||
| 138 | + | require.Equal(t, role != rolePushButton, covered[1]) | |||
| 139 | + | if isDocument(role) { | |||
| 140 | + | require.Empty(t, targets) | |||
| 141 | + | } else { | |||
| 142 | + | require.Len(t, targets, 1) | |||
| 143 | + | } | |||
| 144 | + | }) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | var oneWindow = []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}} | |||
| 150 | + | ||||
| 151 | + | // New does not touch the bus; the first Targets call connects, once. | |||
| 152 | + | func TestConnectsLazilyOnFirstTargets(t *testing.T) { | |||
| 153 | + | tr := newMocktransport(t) | |||
| 154 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |||
| 155 | + | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false)) | |||
| 156 | + | c := newMockconnector(t) | |||
| 157 | + | c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once() | |||
| 158 | + | s := &Source{dial: c} | |||
| 159 | + | for range 2 { | |||
| 160 | + | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |||
| 161 | + | require.NoError(t, err) | |||
| 162 | + | require.Len(t, targets, 1) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // Without PIDs nothing is asked and nothing is connected. | |||
| 167 | + | func TestNoConnectWithoutWindows(t *testing.T) { | |||
| 168 | + | s := &Source{dial: newMockconnector(t)} | |||
| 169 | + | _, _, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1}}, false) | |||
| 170 | + | require.NoError(t, err) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // A failed connection is retried no sooner than retryEvery; meanwhile no | |||
| 174 | + | // targets are returned. | |||
| 175 | + | func TestConnectRetriesWithBackoff(t *testing.T) { | |||
| 176 | + | c := newMockconnector(t) | |||
| 177 | + | c.EXPECT().Connect(mock.Anything).Return(nil, errors.New("down")).Once() | |||
| 178 | + | s := &Source{dial: c} | |||
| 179 | + | for range 3 { | |||
| 180 | + | targets, covered, err := s.Targets(context.Background(), oneWindow, false) | |||
| 181 | + | require.NoError(t, err) | |||
| 182 | + | require.Empty(t, targets) | |||
| 183 | + | require.Empty(t, covered) | |||
| 184 | + | } | |||
| 185 | + | tr := newMocktransport(t) | |||
| 186 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |||
| 187 | + | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false)) | |||
| 188 | + | c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once() | |||
| 189 | + | s.retryAt = time.Now().Add(-time.Second) | |||
| 190 | + | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |||
| 191 | + | require.NoError(t, err) | |||
| 192 | + | require.Len(t, targets, 1) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // A broken connection is closed and dropped; the next attempt reconnects. | |||
| 196 | + | func TestBrokenConnectionIsDropped(t *testing.T) { | |||
| 197 | + | tr := newMocktransport(t) | |||
| 198 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 199 | + | func(_ time.Time, _ time.Duration, calls []call) []reply { | |||
| 200 | + | out := make([]reply, len(calls)) | |||
| 201 | + | for i := range out { | |||
| 202 | + | out[i].err = ErrBroken | |||
| 203 | + | } | |||
| 204 | + | return out | |||
| 205 | + | }).Once() | |||
| 206 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 207 | + | c := newMockconnector(t) | |||
| 208 | + | c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once() | |||
| 209 | + | s := &Source{dial: c} | |||
| 210 | + | _, _, err := s.Targets(context.Background(), oneWindow, false) | |||
| 211 | + | require.Error(t, err) | |||
| 212 | + | require.Nil(t, s.tr) | |||
| 213 | + | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |||
| 214 | + | require.NoError(t, err) | |||
| 215 | + | require.Empty(t, targets) // backoff: no new attempt yet | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | const testWait = 20 * time.Millisecond | |||
| 219 | + | ||||
| 220 | + | // blockedConnector returns a connector whose Connect blocks until release is | |||
| 221 | + | func blockedConnector(t *testing.T, release <-chan struct{}, tr transport, err error, times int) *mockconnector { | |||
| 222 | + | t.Helper() | |||
| 223 | + | c := newMockconnector(t) | |||
| 224 | + | c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) { | |||
| 225 | + | <-release | |||
| 226 | + | return tr, err | |||
| 227 | + | }).Times(times) | |||
| 228 | + | return c | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // A silent service cannot hold Targets beyond the wait, is not doubled while | |||
| 232 | + | // in flight, and its late transport is used. | |||
| 233 | + | func TestConnectIsBoundedAndNotDoubled(t *testing.T) { | |||
| 234 | + | release := make(chan struct{}) | |||
| 235 | + | tr := newMocktransport(t) | |||
| 236 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |||
| 237 | + | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false)) | |||
| 238 | + | c := blockedConnector(t, release, tr, nil, 1) | |||
| 239 | + | s := &Source{dial: c, wait: testWait} | |||
| 240 | + | t0 := time.Now() | |||
| 241 | + | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |||
| 242 | + | require.NoError(t, err) | |||
| 243 | + | require.Empty(t, targets) | |||
| 244 | + | require.Less(t, time.Since(t0), testWait+200*time.Millisecond) | |||
| 245 | + | require.NotNil(t, s.attempt) | |||
| 246 | + | // Still in flight: no second Connect (Times(1)), and no blocking. | |||
| 247 | + | targets, _, err = s.Targets(context.Background(), oneWindow, false) | |||
| 248 | + | require.NoError(t, err) | |||
| 249 | + | require.Empty(t, targets) | |||
| 250 | + | // It connects late: the next call uses it. | |||
| 251 | + | close(release) | |||
| 252 | + | require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond) | |||
| 253 | + | targets, _, err = s.Targets(context.Background(), oneWindow, false) | |||
| 254 | + | require.NoError(t, err) | |||
| 255 | + | require.Len(t, targets, 1) | |||
| 256 | + | require.Nil(t, s.attempt) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // An abandoned attempt that fails applies the backoff, then one new attempt. | |||
| 260 | + | func TestAbandonedAttemptErrorBacksOff(t *testing.T) { | |||
| 261 | + | release := make(chan struct{}) | |||
| 262 | + | tr := newMocktransport(t) | |||
| 263 | + | tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree( | |||
| 264 | + | [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false)) | |||
| 265 | + | c := newMockconnector(t) | |||
| 266 | + | c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) { | |||
| 267 | + | <-release | |||
| 268 | + | return nil, errors.New("down") | |||
| 269 | + | }).Once() | |||
| 270 | + | c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once() | |||
| 271 | + | s := &Source{dial: c, wait: testWait} | |||
| 272 | + | targets, _, err := s.Targets(context.Background(), oneWindow, false) | |||
| 273 | + | require.NoError(t, err) | |||
| 274 | + | require.Empty(t, targets) | |||
| 275 | + | close(release) | |||
| 276 | + | require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond) | |||
| 277 | + | // The error is collected; the backoff runs from its finish time. | |||
| 278 | + | targets, _, err = s.Targets(context.Background(), oneWindow, false) | |||
| 279 | + | require.NoError(t, err) | |||
| 280 | + | require.Empty(t, targets) | |||
| 281 | + | require.Nil(t, s.attempt) | |||
| 282 | + | require.WithinDuration(t, time.Now().Add(retryEvery), s.retryAt, time.Second) | |||
| 283 | + | targets, _, err = s.Targets(context.Background(), oneWindow, false) // backoff: no Connect | |||
| 284 | + | require.NoError(t, err) | |||
| 285 | + | require.Empty(t, targets) | |||
| 286 | + | s.retryAt = time.Now().Add(-time.Second) | |||
| 287 | + | targets, _, err = s.Targets(context.Background(), oneWindow, false) | |||
| 288 | + | require.NoError(t, err) | |||
| 289 | + | require.Len(t, targets, 1) | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // Close with an attempt in flight closes the late transport; afterwards | |||
| 293 | + | // Targets finds nothing and never connects. | |||
| 294 | + | func TestCloseReapsLateTransport(t *testing.T) { | |||
| 295 | + | release := make(chan struct{}) | |||
| 296 | + | closed := make(chan struct{}) | |||
| 297 | + | tr := newMocktransport(t) | |||
| 298 | + | tr.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once() | |||
| 299 | + | c := blockedConnector(t, release, tr, nil, 1) | |||
| 300 | + | s := &Source{dial: c, wait: testWait} | |||
| 301 | + | _, _, err := s.Targets(context.Background(), oneWindow, false) | |||
| 302 | + | require.NoError(t, err) | |||
| 303 | + | require.NoError(t, s.Close()) | |||
| 304 | + | close(release) | |||
| 305 | + | select { | |||
| 306 | + | case <-closed: | |||
| 307 | + | case <-time.After(5 * time.Second): | |||
| 308 | + | t.Fatal("late transport not closed") | |||
| 309 | + | } | |||
| 310 | + | targets, covered, err := s.Targets(context.Background(), oneWindow, false) | |||
| 311 | + | require.NoError(t, err) | |||
| 312 | + | require.Empty(t, targets) | |||
| 313 | + | require.Empty(t, covered) | |||
| 314 | + | require.NoError(t, s.Close()) // idempotent | |||
| 315 | + | } | |||
A internal/adapters/atspi/transport.go +362 -0
| @@ -0,0 +1,362 @@ | |||
| 1 | + | package atspi | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | "sync/atomic" | |
| 8 | + | "syscall" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zerobus" | |
| 12 | + | "golang.org/x/sys/unix" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // D-Bus names, paths and interfaces. | |
| 16 | + | const ( | |
| 17 | + | busDaemon = "org.freedesktop.DBus" | |
| 18 | + | busDaemonPath = "/org/freedesktop/DBus" | |
| 19 | + | ifaceProps = "org.freedesktop.DBus.Properties" | |
| 20 | + | ||
| 21 | + | a11yBusName = "org.a11y.Bus" | |
| 22 | + | a11yBusPath = "/org/a11y/bus" | |
| 23 | + | a11yStatus = "org.a11y.Status" | |
| 24 | + | registryName = "org.a11y.atspi.Registry" | |
| 25 | + | registryRoot = "/org/a11y/atspi/accessible/root" | |
| 26 | + | ||
| 27 | + | ifaceAccessible = "org.a11y.atspi.Accessible" | |
| 28 | + | ifaceComponent = "org.a11y.atspi.Component" | |
| 29 | + | ifaceCollection = "org.a11y.atspi.Collection" | |
| 30 | + | ||
| 31 | + | // coordWindow is ATSPI_COORD_TYPE_WINDOW: extents relative to the | |
| 32 | + | // top-level window. | |
| 33 | + | coordWindow = 1 | |
| 34 | + | ||
| 35 | + | // maxInflight bounds the calls sent before a reply is read, so the bus | |
| 36 | + | // never waits on a full socket while this side waits on it. | |
| 37 | + | maxInflight = 64 | |
| 38 | + | // maxRefs bounds the objects read from one reply. | |
| 39 | + | maxRefs = 4096 | |
| 40 | + | ) | |
| 41 | + | ||
| 42 | + | // op is a call the source makes. | |
| 43 | + | type op uint8 | |
| 44 | + | ||
| 45 | + | const ( | |
| 46 | + | opChildren op = iota // Accessible.GetChildren -> refs | |
| 47 | + | opPID // GetConnectionUnixProcessID(name) -> u | |
| 48 | + | opRole // Accessible.GetRole -> u | |
| 49 | + | opState // Accessible.GetState -> state | |
| 50 | + | opExtents // Component.GetExtents(arg) -> rect | |
| 51 | + | opMatches // Collection.GetMatches(rule) -> refs | |
| 52 | + | ) | |
| 53 | + | ||
| 54 | + | // ref is an accessible object: a bus name and a path. | |
| 55 | + | type ref struct{ name, path string } | |
| 56 | + | ||
| 57 | + | // call is one request of a batch. name and path address the object; for | |
| 58 | + | // opPID, name is the bus name asked about. | |
| 59 | + | type call struct { | |
| 60 | + | op op | |
| 61 | + | name, path string | |
| 62 | + | arg uint32 | |
| 63 | + | rule matchRule | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // reply answers the call at the same index of the batch. | |
| 67 | + | type reply struct { | |
| 68 | + | err error | |
| 69 | + | refs []ref | |
| 70 | + | u uint32 | |
| 71 | + | state uint64 | |
| 72 | + | rect [4]int32 // x, y, w, h | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // transport sends batches of calls on one bus connection. It is used by one | |
| 76 | + | // goroutine at a time. | |
| 77 | + | type transport interface { | |
| 78 | + | // Do sends calls pipelined and returns one reply per call, in order. | |
| 79 | + | // The batch ends at deadline. A call not answered idle after it was | |
| 80 | + | // sent gets ErrTimeout. When the connection fails or the deadline | |
| 81 | + | // finds calls unanswered, those calls get an error wrapping | |
| 82 | + | // ErrBroken: the connection must be dropped. | |
| 83 | + | Do(deadline time.Time, idle time.Duration, calls []call) []reply | |
| 84 | + | Close() error | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // ErrTimeout is the error of a call not answered in time. | |
| 88 | + | var ErrTimeout = errors.New("atspi: call timed out") | |
| 89 | + | ||
| 90 | + | // busError is a D-Bus error reply. | |
| 91 | + | type busError struct{ name, msg string } | |
| 92 | + | ||
| 93 | + | func (e *busError) Error() string { | |
| 94 | + | if e.msg == "" { | |
| 95 | + | return e.name | |
| 96 | + | } | |
| 97 | + | return e.name + ": " + e.msg | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // conn is a transport on a zerobus connection. | |
| 101 | + | type conn struct { | |
| 102 | + | c *zerobus.Conn | |
| 103 | + | raw syscall.RawConn | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func newConn(c *zerobus.Conn) (*conn, error) { | |
| 107 | + | raw, err := c.SyscallConn() | |
| 108 | + | if err != nil { | |
| 109 | + | _ = c.Close() | |
| 110 | + | return nil, err | |
| 111 | + | } | |
| 112 | + | return &conn{c: c, raw: raw}, nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (b *conn) Close() error { return b.c.Close() } | |
| 116 | + | ||
| 117 | + | // readable waits until a message can be read or d passes. | |
| 118 | + | func (b *conn) readable(d time.Duration) (bool, error) { | |
| 119 | + | if b.c.Buffered() { | |
| 120 | + | return true, nil | |
| 121 | + | } | |
| 122 | + | ms := int((d + time.Millisecond - 1) / time.Millisecond) | |
| 123 | + | if ms < 1 { | |
| 124 | + | ms = 1 | |
| 125 | + | } | |
| 126 | + | var n int | |
| 127 | + | var perr error | |
| 128 | + | if err := b.raw.Control(func(fd uintptr) { | |
| 129 | + | fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} | |
| 130 | + | for { | |
| 131 | + | n, perr = unix.Poll(fds, ms) | |
| 132 | + | if perr != unix.EINTR { | |
| 133 | + | return | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | }); err != nil { | |
| 137 | + | return false, err | |
| 138 | + | } | |
| 139 | + | return n > 0, perr | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // ErrBroken is the error of calls on a connection that failed or was closed | |
| 143 | + | // at the deadline; the owner must drop it and connect again. | |
| 144 | + | var ErrBroken = errors.New("atspi: connection broken") | |
| 145 | + | ||
| 146 | + | // errDeadline is the error of calls still unanswered at the batch deadline: | |
| 147 | + | // late replies and half-read messages leave the connection unusable. | |
| 148 | + | var errDeadline = fmt.Errorf("%w: %w", ErrBroken, ErrTimeout) | |
| 149 | + | ||
| 150 | + | // watchdogGrace is how long after the deadline the watchdog closes a socket | |
| 151 | + | // that the batch loop could not leave by itself (a stuck read or write). | |
| 152 | + | const watchdogGrace = 20 * time.Millisecond | |
| 153 | + | ||
| 154 | + | // Do sends the calls pipelined. A watchdog closes the socket shortly after | |
| 155 | + | // deadline, so neither a blocked write nor a read stuck in the middle of a | |
| 156 | + | // message can outlive the batch; the connection is then broken. Each call | |
| 157 | + | // has its own idle timeout, counted from its send: traffic for other calls | |
| 158 | + | // does not extend it. | |
| 159 | + | func (b *conn) Do(deadline time.Time, idle time.Duration, calls []call) []reply { | |
| 160 | + | out := make([]reply, len(calls)) | |
| 161 | + | answered := make([]bool, len(calls)) | |
| 162 | + | type inflight struct { | |
| 163 | + | idx int | |
| 164 | + | sent time.Time | |
| 165 | + | } | |
| 166 | + | pending := make(map[uint32]inflight, min(len(calls), maxInflight)) | |
| 167 | + | next, done := 0, 0 | |
| 168 | + | var fired atomic.Bool | |
| 169 | + | watchdog := time.AfterFunc(time.Until(deadline)+watchdogGrace, func() { | |
| 170 | + | fired.Store(true) | |
| 171 | + | _ = b.c.Close() | |
| 172 | + | }) | |
| 173 | + | defer watchdog.Stop() | |
| 174 | + | // finish gives err to every call without an answer. | |
| 175 | + | finish := func(err error) []reply { | |
| 176 | + | if !errors.Is(err, ErrBroken) && (fired.Load() || !errors.Is(err, ErrTimeout)) { | |
| 177 | + | err = fmt.Errorf("%w: %w", ErrBroken, err) | |
| 178 | + | } | |
| 179 | + | if errors.Is(err, ErrBroken) { | |
| 180 | + | _ = b.c.Close() // late replies would desynchronize the next batch | |
| 181 | + | } | |
| 182 | + | for i := range out { | |
| 183 | + | if !answered[i] { | |
| 184 | + | out[i].err = err | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | return out | |
| 188 | + | } | |
| 189 | + | for done < len(calls) { | |
| 190 | + | for next < len(calls) && len(pending) < maxInflight { | |
| 191 | + | if !time.Now().Before(deadline) { | |
| 192 | + | return finish(errDeadline) | |
| 193 | + | } | |
| 194 | + | serial, err := b.send(calls[next]) | |
| 195 | + | if err != nil { | |
| 196 | + | return finish(err) | |
| 197 | + | } | |
| 198 | + | pending[serial] = inflight{idx: next, sent: time.Now()} | |
| 199 | + | next++ | |
| 200 | + | } | |
| 201 | + | // Calls that waited longer than idle get no reply. | |
| 202 | + | now := time.Now() | |
| 203 | + | var oldest time.Time | |
| 204 | + | for serial, p := range pending { | |
| 205 | + | if now.Sub(p.sent) >= idle { | |
| 206 | + | out[p.idx].err = ErrTimeout | |
| 207 | + | answered[p.idx] = true | |
| 208 | + | done++ | |
| 209 | + | delete(pending, serial) | |
| 210 | + | } else if oldest.IsZero() || p.sent.Before(oldest) { | |
| 211 | + | oldest = p.sent | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | if len(pending) == 0 { | |
| 215 | + | continue | |
| 216 | + | } | |
| 217 | + | wait := min(time.Until(oldest.Add(idle)), time.Until(deadline)) | |
| 218 | + | if wait <= 0 { | |
| 219 | + | if !time.Now().Before(deadline) { | |
| 220 | + | break | |
| 221 | + | } | |
| 222 | + | continue | |
| 223 | + | } | |
| 224 | + | ok, err := b.readable(wait) | |
| 225 | + | if err != nil { | |
| 226 | + | return finish(err) | |
| 227 | + | } | |
| 228 | + | if !ok { | |
| 229 | + | continue | |
| 230 | + | } | |
| 231 | + | m, err := b.c.ReadMessage() | |
| 232 | + | if err != nil { | |
| 233 | + | return finish(err) | |
| 234 | + | } | |
| 235 | + | p, found := pending[m.ReplySerial] | |
| 236 | + | if !found || (m.Type != zerobus.TypeMethodReturn && m.Type != zerobus.TypeError) { | |
| 237 | + | // A signal, or the late reply of an earlier batch. | |
| 238 | + | continue | |
| 239 | + | } | |
| 240 | + | delete(pending, m.ReplySerial) | |
| 241 | + | decode(m, calls[p.idx], &out[p.idx]) | |
| 242 | + | answered[p.idx] = true | |
| 243 | + | done++ | |
| 244 | + | } | |
| 245 | + | if done == len(calls) { | |
| 246 | + | return out | |
| 247 | + | } | |
| 248 | + | return finish(errDeadline) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // send builds and sends one call and returns its serial. | |
| 252 | + | func (b *conn) send(c call) (uint32, error) { | |
| 253 | + | switch c.op { | |
| 254 | + | case opChildren: | |
| 255 | + | b.c.NewCall(c.name, c.path, ifaceAccessible, "GetChildren", "") | |
| 256 | + | case opPID: | |
| 257 | + | b.c.NewCall(busDaemon, busDaemonPath, busDaemon, "GetConnectionUnixProcessID", "s").Str(c.name) | |
| 258 | + | case opRole: | |
| 259 | + | b.c.NewCall(c.name, c.path, ifaceAccessible, "GetRole", "") | |
| 260 | + | case opState: | |
| 261 | + | b.c.NewCall(c.name, c.path, ifaceAccessible, "GetState", "") | |
| 262 | + | case opExtents: | |
| 263 | + | b.c.NewCall(c.name, c.path, ifaceComponent, "GetExtents", "u").Uint32(c.arg) | |
| 264 | + | case opMatches: | |
| 265 | + | e := b.c.NewCall(c.name, c.path, ifaceCollection, "GetMatches", "(aiia{ss}iaiiasib)uib") | |
| 266 | + | encodeRule(e, c.rule) | |
| 267 | + | e.Uint32(sortCanonical) | |
| 268 | + | e.Int32(matchCount) | |
| 269 | + | e.Bool(true) | |
| 270 | + | } | |
| 271 | + | return b.c.Send() | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // encodeRule writes the MatchRule struct (aiia{ss}iaiiasib): states, state | |
| 275 | + | // match, attributes, attribute match, roles, role match, interfaces, | |
| 276 | + | // interface match, invert. | |
| 277 | + | func encodeRule(e *zerobus.Encoder, r matchRule) { | |
| 278 | + | e.Struct() | |
| 279 | + | a := e.BeginArray('i') | |
| 280 | + | for _, w := range stateBits(stateShowing, stateVisible) { | |
| 281 | + | e.Int32(w) | |
| 282 | + | } | |
| 283 | + | e.EndArray(a) | |
| 284 | + | e.Int32(matchAll) | |
| 285 | + | m := e.BeginArray('{') | |
| 286 | + | e.EndArray(m) | |
| 287 | + | e.Int32(matchAll) | |
| 288 | + | a = e.BeginArray('i') | |
| 289 | + | for _, w := range roleBits(r.roles()) { | |
| 290 | + | e.Int32(w) | |
| 291 | + | } | |
| 292 | + | e.EndArray(a) | |
| 293 | + | e.Int32(matchAny) | |
| 294 | + | s := e.BeginArray('s') | |
| 295 | + | e.EndArray(s) | |
| 296 | + | e.Int32(matchAll) | |
| 297 | + | e.Bool(false) | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // decode reads the reply m of c into out. | |
| 301 | + | func decode(m *zerobus.Message, c call, out *reply) { | |
| 302 | + | if m.Type == zerobus.TypeError { | |
| 303 | + | be := &busError{name: strings.Clone(m.ErrorName)} | |
| 304 | + | if strings.HasPrefix(m.Signature, "s") { | |
| 305 | + | be.msg = strings.Clone(m.Body().Str()) | |
| 306 | + | } | |
| 307 | + | out.err = be | |
| 308 | + | return | |
| 309 | + | } | |
| 310 | + | r := m.Body() | |
| 311 | + | want := func(sig string) bool { | |
| 312 | + | if m.Signature != sig { | |
| 313 | + | out.err = fmt.Errorf("atspi: reply signature %q, want %q", m.Signature, sig) | |
| 314 | + | return false | |
| 315 | + | } | |
| 316 | + | return true | |
| 317 | + | } | |
| 318 | + | switch c.op { | |
| 319 | + | case opChildren, opMatches: | |
| 320 | + | if !want("a(so)") { | |
| 321 | + | return | |
| 322 | + | } | |
| 323 | + | end := r.Array('(') | |
| 324 | + | for r.More(end) { | |
| 325 | + | if len(out.refs) >= maxRefs { | |
| 326 | + | break | |
| 327 | + | } | |
| 328 | + | r.Struct() | |
| 329 | + | name := r.Str() | |
| 330 | + | path := r.ObjectPath() | |
| 331 | + | if r.Err() != nil { | |
| 332 | + | break | |
| 333 | + | } | |
| 334 | + | out.refs = append(out.refs, ref{strings.Clone(name), strings.Clone(path)}) | |
| 335 | + | } | |
| 336 | + | case opPID, opRole: | |
| 337 | + | if want("u") { | |
| 338 | + | out.u = r.Uint32() | |
| 339 | + | } | |
| 340 | + | case opState: | |
| 341 | + | if !want("au") { | |
| 342 | + | return | |
| 343 | + | } | |
| 344 | + | end := r.Array('u') | |
| 345 | + | var words [2]uint32 | |
| 346 | + | for i := 0; r.More(end); i++ { | |
| 347 | + | w := r.Uint32() | |
| 348 | + | if i < len(words) { | |
| 349 | + | words[i] = w | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | out.state = uint64(words[0]) | uint64(words[1])<<32 | |
| 353 | + | case opExtents: | |
| 354 | + | if want("(iiii)") { | |
| 355 | + | r.Struct() | |
| 356 | + | out.rect = [4]int32{r.Int32(), r.Int32(), r.Int32(), r.Int32()} | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | if out.err == nil && r.Err() != nil { | |
| 360 | + | out.err = r.Err() | |
| 361 | + | } | |
| 362 | + | } | |
| @@ -0,0 +1,362 @@ | |||||
| 1 | + | package atspi | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "syscall" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerobus" | |||
| 12 | + | "golang.org/x/sys/unix" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // D-Bus names, paths and interfaces. | |||
| 16 | + | const ( | |||
| 17 | + | busDaemon = "org.freedesktop.DBus" | |||
| 18 | + | busDaemonPath = "/org/freedesktop/DBus" | |||
| 19 | + | ifaceProps = "org.freedesktop.DBus.Properties" | |||
| 20 | + | ||||
| 21 | + | a11yBusName = "org.a11y.Bus" | |||
| 22 | + | a11yBusPath = "/org/a11y/bus" | |||
| 23 | + | a11yStatus = "org.a11y.Status" | |||
| 24 | + | registryName = "org.a11y.atspi.Registry" | |||
| 25 | + | registryRoot = "/org/a11y/atspi/accessible/root" | |||
| 26 | + | ||||
| 27 | + | ifaceAccessible = "org.a11y.atspi.Accessible" | |||
| 28 | + | ifaceComponent = "org.a11y.atspi.Component" | |||
| 29 | + | ifaceCollection = "org.a11y.atspi.Collection" | |||
| 30 | + | ||||
| 31 | + | // coordWindow is ATSPI_COORD_TYPE_WINDOW: extents relative to the | |||
| 32 | + | // top-level window. | |||
| 33 | + | coordWindow = 1 | |||
| 34 | + | ||||
| 35 | + | // maxInflight bounds the calls sent before a reply is read, so the bus | |||
| 36 | + | // never waits on a full socket while this side waits on it. | |||
| 37 | + | maxInflight = 64 | |||
| 38 | + | // maxRefs bounds the objects read from one reply. | |||
| 39 | + | maxRefs = 4096 | |||
| 40 | + | ) | |||
| 41 | + | ||||
| 42 | + | // op is a call the source makes. | |||
| 43 | + | type op uint8 | |||
| 44 | + | ||||
| 45 | + | const ( | |||
| 46 | + | opChildren op = iota // Accessible.GetChildren -> refs | |||
| 47 | + | opPID // GetConnectionUnixProcessID(name) -> u | |||
| 48 | + | opRole // Accessible.GetRole -> u | |||
| 49 | + | opState // Accessible.GetState -> state | |||
| 50 | + | opExtents // Component.GetExtents(arg) -> rect | |||
| 51 | + | opMatches // Collection.GetMatches(rule) -> refs | |||
| 52 | + | ) | |||
| 53 | + | ||||
| 54 | + | // ref is an accessible object: a bus name and a path. | |||
| 55 | + | type ref struct{ name, path string } | |||
| 56 | + | ||||
| 57 | + | // call is one request of a batch. name and path address the object; for | |||
| 58 | + | // opPID, name is the bus name asked about. | |||
| 59 | + | type call struct { | |||
| 60 | + | op op | |||
| 61 | + | name, path string | |||
| 62 | + | arg uint32 | |||
| 63 | + | rule matchRule | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // reply answers the call at the same index of the batch. | |||
| 67 | + | type reply struct { | |||
| 68 | + | err error | |||
| 69 | + | refs []ref | |||
| 70 | + | u uint32 | |||
| 71 | + | state uint64 | |||
| 72 | + | rect [4]int32 // x, y, w, h | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // transport sends batches of calls on one bus connection. It is used by one | |||
| 76 | + | // goroutine at a time. | |||
| 77 | + | type transport interface { | |||
| 78 | + | // Do sends calls pipelined and returns one reply per call, in order. | |||
| 79 | + | // The batch ends at deadline. A call not answered idle after it was | |||
| 80 | + | // sent gets ErrTimeout. When the connection fails or the deadline | |||
| 81 | + | // finds calls unanswered, those calls get an error wrapping | |||
| 82 | + | // ErrBroken: the connection must be dropped. | |||
| 83 | + | Do(deadline time.Time, idle time.Duration, calls []call) []reply | |||
| 84 | + | Close() error | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // ErrTimeout is the error of a call not answered in time. | |||
| 88 | + | var ErrTimeout = errors.New("atspi: call timed out") | |||
| 89 | + | ||||
| 90 | + | // busError is a D-Bus error reply. | |||
| 91 | + | type busError struct{ name, msg string } | |||
| 92 | + | ||||
| 93 | + | func (e *busError) Error() string { | |||
| 94 | + | if e.msg == "" { | |||
| 95 | + | return e.name | |||
| 96 | + | } | |||
| 97 | + | return e.name + ": " + e.msg | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // conn is a transport on a zerobus connection. | |||
| 101 | + | type conn struct { | |||
| 102 | + | c *zerobus.Conn | |||
| 103 | + | raw syscall.RawConn | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func newConn(c *zerobus.Conn) (*conn, error) { | |||
| 107 | + | raw, err := c.SyscallConn() | |||
| 108 | + | if err != nil { | |||
| 109 | + | _ = c.Close() | |||
| 110 | + | return nil, err | |||
| 111 | + | } | |||
| 112 | + | return &conn{c: c, raw: raw}, nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (b *conn) Close() error { return b.c.Close() } | |||
| 116 | + | ||||
| 117 | + | // readable waits until a message can be read or d passes. | |||
| 118 | + | func (b *conn) readable(d time.Duration) (bool, error) { | |||
| 119 | + | if b.c.Buffered() { | |||
| 120 | + | return true, nil | |||
| 121 | + | } | |||
| 122 | + | ms := int((d + time.Millisecond - 1) / time.Millisecond) | |||
| 123 | + | if ms < 1 { | |||
| 124 | + | ms = 1 | |||
| 125 | + | } | |||
| 126 | + | var n int | |||
| 127 | + | var perr error | |||
| 128 | + | if err := b.raw.Control(func(fd uintptr) { | |||
| 129 | + | fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} | |||
| 130 | + | for { | |||
| 131 | + | n, perr = unix.Poll(fds, ms) | |||
| 132 | + | if perr != unix.EINTR { | |||
| 133 | + | return | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | }); err != nil { | |||
| 137 | + | return false, err | |||
| 138 | + | } | |||
| 139 | + | return n > 0, perr | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // ErrBroken is the error of calls on a connection that failed or was closed | |||
| 143 | + | // at the deadline; the owner must drop it and connect again. | |||
| 144 | + | var ErrBroken = errors.New("atspi: connection broken") | |||
| 145 | + | ||||
| 146 | + | // errDeadline is the error of calls still unanswered at the batch deadline: | |||
| 147 | + | // late replies and half-read messages leave the connection unusable. | |||
| 148 | + | var errDeadline = fmt.Errorf("%w: %w", ErrBroken, ErrTimeout) | |||
| 149 | + | ||||
| 150 | + | // watchdogGrace is how long after the deadline the watchdog closes a socket | |||
| 151 | + | // that the batch loop could not leave by itself (a stuck read or write). | |||
| 152 | + | const watchdogGrace = 20 * time.Millisecond | |||
| 153 | + | ||||
| 154 | + | // Do sends the calls pipelined. A watchdog closes the socket shortly after | |||
| 155 | + | // deadline, so neither a blocked write nor a read stuck in the middle of a | |||
| 156 | + | // message can outlive the batch; the connection is then broken. Each call | |||
| 157 | + | // has its own idle timeout, counted from its send: traffic for other calls | |||
| 158 | + | // does not extend it. | |||
| 159 | + | func (b *conn) Do(deadline time.Time, idle time.Duration, calls []call) []reply { | |||
| 160 | + | out := make([]reply, len(calls)) | |||
| 161 | + | answered := make([]bool, len(calls)) | |||
| 162 | + | type inflight struct { | |||
| 163 | + | idx int | |||
| 164 | + | sent time.Time | |||
| 165 | + | } | |||
| 166 | + | pending := make(map[uint32]inflight, min(len(calls), maxInflight)) | |||
| 167 | + | next, done := 0, 0 | |||
| 168 | + | var fired atomic.Bool | |||
| 169 | + | watchdog := time.AfterFunc(time.Until(deadline)+watchdogGrace, func() { | |||
| 170 | + | fired.Store(true) | |||
| 171 | + | _ = b.c.Close() | |||
| 172 | + | }) | |||
| 173 | + | defer watchdog.Stop() | |||
| 174 | + | // finish gives err to every call without an answer. | |||
| 175 | + | finish := func(err error) []reply { | |||
| 176 | + | if !errors.Is(err, ErrBroken) && (fired.Load() || !errors.Is(err, ErrTimeout)) { | |||
| 177 | + | err = fmt.Errorf("%w: %w", ErrBroken, err) | |||
| 178 | + | } | |||
| 179 | + | if errors.Is(err, ErrBroken) { | |||
| 180 | + | _ = b.c.Close() // late replies would desynchronize the next batch | |||
| 181 | + | } | |||
| 182 | + | for i := range out { | |||
| 183 | + | if !answered[i] { | |||
| 184 | + | out[i].err = err | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | return out | |||
| 188 | + | } | |||
| 189 | + | for done < len(calls) { | |||
| 190 | + | for next < len(calls) && len(pending) < maxInflight { | |||
| 191 | + | if !time.Now().Before(deadline) { | |||
| 192 | + | return finish(errDeadline) | |||
| 193 | + | } | |||
| 194 | + | serial, err := b.send(calls[next]) | |||
| 195 | + | if err != nil { | |||
| 196 | + | return finish(err) | |||
| 197 | + | } | |||
| 198 | + | pending[serial] = inflight{idx: next, sent: time.Now()} | |||
| 199 | + | next++ | |||
| 200 | + | } | |||
| 201 | + | // Calls that waited longer than idle get no reply. | |||
| 202 | + | now := time.Now() | |||
| 203 | + | var oldest time.Time | |||
| 204 | + | for serial, p := range pending { | |||
| 205 | + | if now.Sub(p.sent) >= idle { | |||
| 206 | + | out[p.idx].err = ErrTimeout | |||
| 207 | + | answered[p.idx] = true | |||
| 208 | + | done++ | |||
| 209 | + | delete(pending, serial) | |||
| 210 | + | } else if oldest.IsZero() || p.sent.Before(oldest) { | |||
| 211 | + | oldest = p.sent | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | if len(pending) == 0 { | |||
| 215 | + | continue | |||
| 216 | + | } | |||
| 217 | + | wait := min(time.Until(oldest.Add(idle)), time.Until(deadline)) | |||
| 218 | + | if wait <= 0 { | |||
| 219 | + | if !time.Now().Before(deadline) { | |||
| 220 | + | break | |||
| 221 | + | } | |||
| 222 | + | continue | |||
| 223 | + | } | |||
| 224 | + | ok, err := b.readable(wait) | |||
| 225 | + | if err != nil { | |||
| 226 | + | return finish(err) | |||
| 227 | + | } | |||
| 228 | + | if !ok { | |||
| 229 | + | continue | |||
| 230 | + | } | |||
| 231 | + | m, err := b.c.ReadMessage() | |||
| 232 | + | if err != nil { | |||
| 233 | + | return finish(err) | |||
| 234 | + | } | |||
| 235 | + | p, found := pending[m.ReplySerial] | |||
| 236 | + | if !found || (m.Type != zerobus.TypeMethodReturn && m.Type != zerobus.TypeError) { | |||
| 237 | + | // A signal, or the late reply of an earlier batch. | |||
| 238 | + | continue | |||
| 239 | + | } | |||
| 240 | + | delete(pending, m.ReplySerial) | |||
| 241 | + | decode(m, calls[p.idx], &out[p.idx]) | |||
| 242 | + | answered[p.idx] = true | |||
| 243 | + | done++ | |||
| 244 | + | } | |||
| 245 | + | if done == len(calls) { | |||
| 246 | + | return out | |||
| 247 | + | } | |||
| 248 | + | return finish(errDeadline) | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | // send builds and sends one call and returns its serial. | |||
| 252 | + | func (b *conn) send(c call) (uint32, error) { | |||
| 253 | + | switch c.op { | |||
| 254 | + | case opChildren: | |||
| 255 | + | b.c.NewCall(c.name, c.path, ifaceAccessible, "GetChildren", "") | |||
| 256 | + | case opPID: | |||
| 257 | + | b.c.NewCall(busDaemon, busDaemonPath, busDaemon, "GetConnectionUnixProcessID", "s").Str(c.name) | |||
| 258 | + | case opRole: | |||
| 259 | + | b.c.NewCall(c.name, c.path, ifaceAccessible, "GetRole", "") | |||
| 260 | + | case opState: | |||
| 261 | + | b.c.NewCall(c.name, c.path, ifaceAccessible, "GetState", "") | |||
| 262 | + | case opExtents: | |||
| 263 | + | b.c.NewCall(c.name, c.path, ifaceComponent, "GetExtents", "u").Uint32(c.arg) | |||
| 264 | + | case opMatches: | |||
| 265 | + | e := b.c.NewCall(c.name, c.path, ifaceCollection, "GetMatches", "(aiia{ss}iaiiasib)uib") | |||
| 266 | + | encodeRule(e, c.rule) | |||
| 267 | + | e.Uint32(sortCanonical) | |||
| 268 | + | e.Int32(matchCount) | |||
| 269 | + | e.Bool(true) | |||
| 270 | + | } | |||
| 271 | + | return b.c.Send() | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | // encodeRule writes the MatchRule struct (aiia{ss}iaiiasib): states, state | |||
| 275 | + | // match, attributes, attribute match, roles, role match, interfaces, | |||
| 276 | + | // interface match, invert. | |||
| 277 | + | func encodeRule(e *zerobus.Encoder, r matchRule) { | |||
| 278 | + | e.Struct() | |||
| 279 | + | a := e.BeginArray('i') | |||
| 280 | + | for _, w := range stateBits(stateShowing, stateVisible) { | |||
| 281 | + | e.Int32(w) | |||
| 282 | + | } | |||
| 283 | + | e.EndArray(a) | |||
| 284 | + | e.Int32(matchAll) | |||
| 285 | + | m := e.BeginArray('{') | |||
| 286 | + | e.EndArray(m) | |||
| 287 | + | e.Int32(matchAll) | |||
| 288 | + | a = e.BeginArray('i') | |||
| 289 | + | for _, w := range roleBits(r.roles()) { | |||
| 290 | + | e.Int32(w) | |||
| 291 | + | } | |||
| 292 | + | e.EndArray(a) | |||
| 293 | + | e.Int32(matchAny) | |||
| 294 | + | s := e.BeginArray('s') | |||
| 295 | + | e.EndArray(s) | |||
| 296 | + | e.Int32(matchAll) | |||
| 297 | + | e.Bool(false) | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | // decode reads the reply m of c into out. | |||
| 301 | + | func decode(m *zerobus.Message, c call, out *reply) { | |||
| 302 | + | if m.Type == zerobus.TypeError { | |||
| 303 | + | be := &busError{name: strings.Clone(m.ErrorName)} | |||
| 304 | + | if strings.HasPrefix(m.Signature, "s") { | |||
| 305 | + | be.msg = strings.Clone(m.Body().Str()) | |||
| 306 | + | } | |||
| 307 | + | out.err = be | |||
| 308 | + | return | |||
| 309 | + | } | |||
| 310 | + | r := m.Body() | |||
| 311 | + | want := func(sig string) bool { | |||
| 312 | + | if m.Signature != sig { | |||
| 313 | + | out.err = fmt.Errorf("atspi: reply signature %q, want %q", m.Signature, sig) | |||
| 314 | + | return false | |||
| 315 | + | } | |||
| 316 | + | return true | |||
| 317 | + | } | |||
| 318 | + | switch c.op { | |||
| 319 | + | case opChildren, opMatches: | |||
| 320 | + | if !want("a(so)") { | |||
| 321 | + | return | |||
| 322 | + | } | |||
| 323 | + | end := r.Array('(') | |||
| 324 | + | for r.More(end) { | |||
| 325 | + | if len(out.refs) >= maxRefs { | |||
| 326 | + | break | |||
| 327 | + | } | |||
| 328 | + | r.Struct() | |||
| 329 | + | name := r.Str() | |||
| 330 | + | path := r.ObjectPath() | |||
| 331 | + | if r.Err() != nil { | |||
| 332 | + | break | |||
| 333 | + | } | |||
| 334 | + | out.refs = append(out.refs, ref{strings.Clone(name), strings.Clone(path)}) | |||
| 335 | + | } | |||
| 336 | + | case opPID, opRole: | |||
| 337 | + | if want("u") { | |||
| 338 | + | out.u = r.Uint32() | |||
| 339 | + | } | |||
| 340 | + | case opState: | |||
| 341 | + | if !want("au") { | |||
| 342 | + | return | |||
| 343 | + | } | |||
| 344 | + | end := r.Array('u') | |||
| 345 | + | var words [2]uint32 | |||
| 346 | + | for i := 0; r.More(end); i++ { | |||
| 347 | + | w := r.Uint32() | |||
| 348 | + | if i < len(words) { | |||
| 349 | + | words[i] = w | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | out.state = uint64(words[0]) | uint64(words[1])<<32 | |||
| 353 | + | case opExtents: | |||
| 354 | + | if want("(iiii)") { | |||
| 355 | + | r.Struct() | |||
| 356 | + | out.rect = [4]int32{r.Int32(), r.Int32(), r.Int32(), r.Int32()} | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | if out.err == nil && r.Err() != nil { | |||
| 360 | + | out.err = r.Err() | |||
| 361 | + | } | |||
| 362 | + | } | |||
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 | + | } | |||