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