├── .fsw.yml ├── .gitignore ├── README.md ├── minitouch_test.go ├── stf_test.go ├── utils_test.go ├── minicap_test.go ├── stf.go ├── rotation_test.go ├── LICENSE ├── utils.go ├── servicer.go ├── rotation.go ├── minitouch.go └── minicap.go /.fsw.yml: -------------------------------------------------------------------------------- 1 | desc: Auto generated by fswatch [go-stf] 2 | triggers: 3 | - name: "stf" 4 | pattens: 5 | - '**/*.go' 6 | - '**/*.c' 7 | - '**/*.py' 8 | env: 9 | DEBUG: "1" 10 | cmd: go test -v -run Mini 11 | shell: true 12 | delay: 100ms 13 | stop_timeout: 500ms 14 | signal: KILL 15 | kill_signal: "" 16 | watch_paths: 17 | - . 18 | watch_depth: 0 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-stf 2 | Golang wraps for some [stf](https://github.com/openstf/stf) good stuff. 3 | 4 | ## Includes 5 | - stf [minicap](https://github.com/openstf/minicap) 6 | - stf [minitouch](https://github.com/openstf/minicap) 7 | - android [uiautomator](https://developer.android.com/training/testing/ui-testing/uiautomator-testing.html) 8 | 9 | ## LICENSE 10 | Under LICENSE [MIT](LICENSE) -------------------------------------------------------------------------------- /minitouch_test.go: -------------------------------------------------------------------------------- 1 | package stf 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestTouch(t *testing.T) { 10 | touch := NewSTFTouch(dev) 11 | err := touch.Start() 12 | assert.NoError(t, err) 13 | touch.Down(0, 100, 330) 14 | touch.Up(0) 15 | err = touch.Stop() 16 | assert.NoError(t, err) 17 | err = touch.Wait() 18 | assert.NoError(t, err) 19 | } 20 | -------------------------------------------------------------------------------- /stf_test.go: -------------------------------------------------------------------------------- 1 | package stf 2 | 3 | import ( 4 | "log" 5 | 6 | adb "github.com/openatx/go-adb" 7 | ) 8 | 9 | var dev *adb.Device 10 | 11 | func init() { 12 | // adbc, err := adb.New() 13 | adbc, err := adb.NewWithConfig(adb.ServerConfig{ 14 | Host: "127.0.0.1", 15 | }) 16 | // Host: "10.240.187.174", 17 | // Port: 5555, 18 | // }) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | dev = adbc.Device(adb.AnyUsbDevice()) 23 | log.Println(dev) 24 | } 25 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package stf 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAdbFileExists(t *testing.T) { 10 | exists := AdbFileExists(dev, "/system/bin/ls") 11 | assert.Equal(t, true, exists) 12 | } 13 | 14 | func TestAdbCheckOutput(t *testing.T) { 15 | outStr, err := AdbCheckOutput(dev, "echo", "hello") 16 | assert.NoError(t, err) 17 | assert.Equal(t, "hello\n", outStr) 18 | } 19 | 20 | func TestPushFileFromHTTP(t *testing.T) { 21 | err := PushFileFromHTTP(dev, "/data/local/tmp/tt.txt", 0644, "") 22 | assert.Error(t, err) 23 | } 24 | -------------------------------------------------------------------------------- /minicap_test.go: -------------------------------------------------------------------------------- 1 | package stf 2 | 3 | import ( 4 | "bytes" 5 | "image/jpeg" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestSTFCapturer(t *testing.T) { 13 | cap := NewSTFCapturer(dev, nil) 14 | err := cap.Start() 15 | assert.NoError(t, err) 16 | 17 | for i := 0; i < 20; i++ { 18 | select { 19 | case jpgData := <-cap.C: 20 | _, err := jpeg.Decode(bytes.NewReader(jpgData)) 21 | assert.NoError(t, err) 22 | case <-time.After(time.Second * 2): 23 | t.Error("no image captured") 24 | } 25 | } 26 | 27 | err = cap.Stop() 28 | assert.NoError(t, err) 29 | } 30 | -------------------------------------------------------------------------------- /stf.go: -------------------------------------------------------------------------------- 1 | package stf 2 | 3 | import "image" 4 | 5 | // type Device interface { 6 | // Capture CaptureService 7 | // // Touch TouchService 8 | // // UIAuto UIAService 9 | // // WebView WebViewService 10 | // } 11 | 12 | type ScreenReader interface { 13 | Servicer 14 | NextImage() (image.Image, error) 15 | LastImage() (image.Image, error) 16 | Subscribe() (chan image.Image, error) 17 | } 18 | 19 | type Toucher interface { 20 | Servicer 21 | Down(x, y int) error 22 | Move(x, y int) error 23 | Up() error 24 | } 25 | 26 | type UITester interface { 27 | Servicer 28 | Address() string 29 | } 30 | 31 | type RotationWatcher interface { 32 | Servicer 33 | Rotation() (int, error) 34 | Subscribe() chan int 35 | Unsubscribe(chan int) 36 | } 37 | -------------------------------------------------------------------------------- /rotation_test.go: -------------------------------------------------------------------------------- 1 | package stf 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestRotation(t *testing.T) { 11 | assert := assert.New(t) 12 | 13 | r := NewSTFRotation(dev) 14 | subC := r.Subscribe() 15 | 16 | start := time.Now() 17 | err := r.Start() 18 | assert.Nil(err) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | t.Logf("start time used: %v", time.Since(start)) 23 | select { 24 | case v := <-subC: 25 | t.Logf("read value: %d", v) 26 | case <-time.After(10 * time.Second): 27 | t.Fatal("get rotation timeout") 28 | } 29 | r.Unsubscribe(subC) 30 | time.Sleep(5 * time.Second) 31 | err = r.Stop() 32 | assert.Nil(err) 33 | } 34 | 35 | // func TestSleepLong(t *testing.T) { 36 | // assert := assert.New(t) 37 | // fio, err := dev.Command("sleep", "100") 38 | // assert.Nil(err) 39 | // t.Log(fio.Close()) 40 | // time.Sleep(5 * time.Second) 41 | // } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 openatx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package stf 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | adb "github.com/openatx/go-adb" 15 | ) 16 | 17 | func PushFileFromHTTP(d *adb.Device, dst string, perms os.FileMode, urlStr string) error { 18 | wc, err := d.OpenWrite(dst, perms, time.Now()) 19 | if err != nil { 20 | return err 21 | } 22 | resp, err := http.Get(urlStr) 23 | if err != nil { 24 | return err 25 | } 26 | if resp.StatusCode != http.StatusOK { 27 | return fmt.Errorf("http download <%s> status %v", urlStr, resp.Status) 28 | } 29 | defer resp.Body.Close() 30 | log.Printf("downloading to %s ...", dst) 31 | if _, err = io.Copy(wc, resp.Body); err != nil { 32 | return err 33 | } 34 | if err := wc.Close(); err != nil { 35 | return err 36 | } 37 | return nil 38 | } 39 | 40 | func AdbCheckOutput(d *adb.Device, name string, args ...string) (outStr string, err error) { 41 | args = append(args, ";", "echo", ":$?") 42 | outStr, err = d.RunCommand(name, args...) 43 | if err != nil { 44 | return 45 | } 46 | idx := strings.LastIndexByte(outStr, ':') 47 | if idx == -1 { 48 | return outStr, errors.New("adb shell error, parse exit code failed") 49 | } 50 | exitCode, _ := strconv.Atoi(strings.TrimSpace(outStr[idx+1:])) 51 | if exitCode != 0 { 52 | err = fmt.Errorf("[adb shell %s %s] exit code %d", name, strings.Join(args, " "), exitCode) 53 | } 54 | return outStr[0:idx], err 55 | } 56 | 57 | func AdbFileExists(d *adb.Device, path string) bool { 58 | _, err := AdbCheckOutput(d, "test", "-f", path) 59 | return err == nil 60 | } 61 | 62 | func GoFunc(f func() error) chan error { 63 | ch := make(chan error) 64 | go func() { 65 | ch <- f() 66 | }() 67 | return ch 68 | } 69 | 70 | type multiError struct { 71 | errs []error 72 | } 73 | 74 | func (m multiError) Error() string { 75 | var errStrs = make([]string, 0, len(m.errs)) 76 | for _, err := range m.errs { 77 | errStrs = append(errStrs, err.Error()) 78 | } 79 | return strings.Join(errStrs, "; ") 80 | } 81 | 82 | func wrapMultiError(errs ...error) error { 83 | merr := multiError{make([]error, 0)} 84 | for _, err := range errs { 85 | if err == nil { 86 | continue 87 | } 88 | merr.errs = append(merr.errs, err) 89 | } 90 | if len(merr.errs) == 0 { 91 | return nil 92 | } 93 | return merr 94 | } 95 | -------------------------------------------------------------------------------- /servicer.go: -------------------------------------------------------------------------------- 1 | package stf 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | ) 7 | 8 | var ( 9 | ErrServiceAlreadyStarted = errors.New("Service already started") 10 | ErrServiceNotStarted = errors.New("Service not started") 11 | ) 12 | 13 | type Servicer interface { 14 | Start() error 15 | Stop() error 16 | Wait() error 17 | } 18 | 19 | type multiServ struct { 20 | ss []Servicer 21 | } 22 | 23 | func (m *multiServ) Start() error { 24 | for _, s := range m.ss { 25 | if err := s.Start(); err != nil { 26 | return err 27 | } 28 | } 29 | return nil 30 | } 31 | 32 | func (m *multiServ) Stop() error { 33 | var err error 34 | for _, s := range m.ss { 35 | if er := s.Stop(); er != nil { 36 | err = er 37 | } 38 | } 39 | return err 40 | } 41 | 42 | func (m *multiServ) Wait() error { 43 | errC := make(chan error, len(m.ss)) 44 | for _, s := range m.ss { 45 | go func(s Servicer) { 46 | errC <- s.Wait() 47 | }(s) 48 | } 49 | return <-errC 50 | } 51 | 52 | // Combine servicers into one servicer 53 | func MultiServicer(ss ...Servicer) Servicer { 54 | return &multiServ{ss} 55 | } 56 | 57 | // Mutex 58 | const ( 59 | _ACTION_START = iota 60 | _ACTION_STOP 61 | ) 62 | 63 | type safeMixin struct { 64 | mu sync.Mutex 65 | started bool 66 | } 67 | 68 | func (t *safeMixin) safeDo(action int, f func() error) error { 69 | t.mu.Lock() 70 | defer t.mu.Unlock() 71 | if t.started && action == _ACTION_START { 72 | return ErrServiceAlreadyStarted 73 | } 74 | if !t.started && action == _ACTION_STOP { 75 | return ErrServiceNotStarted 76 | } 77 | t.started = (action == _ACTION_START) 78 | return f() 79 | } 80 | 81 | func (t *safeMixin) IsStarted() bool { 82 | return t.started 83 | } 84 | 85 | // Mutex retry 86 | // type safeErrorMixin struct { 87 | // safeMixin 88 | // errorMixin 89 | // errC chan error 90 | // maxRetry int 91 | // f func() error 92 | // startFunc func() error 93 | // stopFunc func() error 94 | // } 95 | 96 | // func (t *safeErrorMixin) safeRetryDo(maxRetry int, dur time.Duration, 97 | // startFunc func() error, stopFunc func() error) error { 98 | // return t.safeDo(action, func() error { 99 | // t.maxRetry = maxRetry 100 | // t.startFunc = startFunc 101 | // t.stopFunc = stopFunc 102 | // t.errC = make(chan error, 1) 103 | // return t.doWithRetry() 104 | // }) 105 | // } 106 | 107 | // func (t *safeErrorMixin) doWithRetry() error { 108 | // if err := t.startFunc(); err != nil { 109 | // return err 110 | // } 111 | // go func() { 112 | // leftRetry := t.maxRetry 113 | // for leftRetry > 0 { 114 | // startTime := time.Now() 115 | // err := t.Wait() 116 | // if err != nil { 117 | // leftRetry -= 1 118 | // if time.Since(startTime) > 20*time.Second { 119 | // leftRetry = t.maxRetry 120 | // } 121 | // t.stopFunc() 122 | // // t.Stop() 123 | // } 124 | // } 125 | // }() 126 | // } 127 | 128 | // // if exit retry again 129 | // func (t *safeErrorMixin) Wait() error { 130 | // err := t.errorMixin.Wait() 131 | // // errC := GoFunc(t.errorMixin.Wait) 132 | // // select { 133 | // // case <- errC: 134 | // // } 135 | // return err 136 | // } 137 | 138 | // func (t *mutexRetryMixin) Wait() error { 139 | // } 140 | 141 | // Mixin helper to easy write Servicer 142 | type errorMixin struct { 143 | errC chan error 144 | once *sync.Once 145 | wg *sync.WaitGroup 146 | err error 147 | } 148 | 149 | // this func must be called before use other functions 150 | func (e *errorMixin) resetError() { 151 | e.once = &sync.Once{} 152 | e.wg = &sync.WaitGroup{} 153 | e.wg.Add(1) 154 | } 155 | 156 | func (e *errorMixin) Wait() error { 157 | e.wg.Wait() 158 | return e.err 159 | } 160 | 161 | func (e *errorMixin) doneError(err error) { 162 | e.once.Do(func() { 163 | e.err = err 164 | e.wg.Done() 165 | }) 166 | } 167 | 168 | func (e *errorMixin) doneNilError() { 169 | e.doneError(nil) 170 | } 171 | -------------------------------------------------------------------------------- /rotation.go: -------------------------------------------------------------------------------- 1 | // RotationWatcher.apk Service 2 | package stf 3 | 4 | import ( 5 | "bufio" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | adb "github.com/openatx/go-adb" 16 | "github.com/openatx/go-adb/wire" 17 | "github.com/pkg/errors" 18 | ) 19 | 20 | const ( 21 | defaultRotationPkgName = "jp.co.cyberagent.stf.rotationwatcher" 22 | defaultRotationMaxRetry = 3 23 | ) 24 | 25 | type STFRotation struct { 26 | d *adb.Device 27 | mu sync.Mutex 28 | lastValue int 29 | subscribers map[chan int]bool 30 | cmdConn *wire.Conn 31 | wg sync.WaitGroup 32 | stopped bool 33 | leftRetry int 34 | } 35 | 36 | func NewSTFRotation(d *adb.Device) *STFRotation { 37 | return &STFRotation{ 38 | d: d, 39 | subscribers: make(map[chan int]bool), 40 | leftRetry: defaultRotationMaxRetry, 41 | lastValue: -1, 42 | } 43 | } 44 | 45 | // 0, 90, 180, 270 46 | func (s *STFRotation) Rotation() (int, error) { 47 | if s.lastValue == -1 || s.stopped { 48 | return 0, errors.New("Rotation not ready") 49 | } 50 | return s.lastValue, nil 51 | } 52 | 53 | func (s *STFRotation) Start() error { 54 | pmPath, err := s.preparePackage() 55 | if err != nil { 56 | return err 57 | } 58 | 59 | go func() { 60 | var ok = true 61 | for ok { 62 | s.wg.Add(1) 63 | err := s.consoleStartProcess(pmPath) 64 | if err == nil { 65 | s.leftRetry = defaultRotationMaxRetry 66 | } else { 67 | log.Printf("rotation run failed: %v, left retry %d", err, s.leftRetry) 68 | } 69 | 70 | s.mu.Lock() 71 | s.leftRetry -= 1 72 | if s.stopped || s.leftRetry <= 0 { 73 | for subC := range s.subscribers { 74 | s.Unsubscribe(subC) 75 | } 76 | ok = false 77 | } 78 | s.wg.Done() 79 | s.mu.Unlock() 80 | } 81 | }() 82 | return nil 83 | } 84 | 85 | func (s *STFRotation) Stop() error { 86 | // cancel retry and wait until stop 87 | s.mu.Lock() 88 | s.stopped = true 89 | s.mu.Unlock() 90 | if s.cmdConn != nil { 91 | s.cmdConn.Close() 92 | s.cmdConn = nil 93 | } 94 | s.wg.Wait() 95 | return nil 96 | } 97 | 98 | func (s *STFRotation) Subscribe() chan int { 99 | s.mu.Lock() 100 | defer s.mu.Unlock() 101 | C := make(chan int, 1) 102 | s.subscribers[C] = true 103 | return C 104 | } 105 | 106 | // unsubscribe will also close channel 107 | func (s *STFRotation) Unsubscribe(C chan int) { 108 | s.mu.Lock() 109 | defer s.mu.Unlock() 110 | delete(s.subscribers, C) 111 | close(C) 112 | } 113 | 114 | func (s *STFRotation) pub(v int) { 115 | s.lastValue = v 116 | for subC := range s.subscribers { 117 | select { 118 | case subC <- v: 119 | case <-time.After(1 * time.Second): 120 | s.Unsubscribe(subC) 121 | } 122 | } 123 | } 124 | 125 | func (s *STFRotation) preparePackage() (pmPath string, err error) { 126 | if err := s.pushApk(); err != nil { 127 | return "", err 128 | } 129 | return s.getPackagePath(defaultRotationPkgName) 130 | } 131 | 132 | func (s *STFRotation) consoleStartProcess(pmPath string) error { 133 | fio, err := s.d.OpenCommand("CLASSPATH="+pmPath, "exec", "app_process", "/system/bin", defaultRotationPkgName+".RotationWatcher") 134 | if err != nil { 135 | return errors.Wrap(err, "start rotation.apk") 136 | } 137 | s.cmdConn = fio 138 | defer fio.Close() 139 | readCount := 0 140 | scanner := bufio.NewScanner(fio) 141 | for scanner.Scan() { 142 | val, err := strconv.Atoi(scanner.Text()) 143 | if err != nil { 144 | return err 145 | } 146 | readCount += 1 147 | s.pub(val) 148 | } 149 | if readCount > 0 { 150 | return nil 151 | } 152 | return errors.New("Rotation got nothing") 153 | } 154 | 155 | func (s *STFRotation) pushApk() error { 156 | _, err := s.getPackagePath(defaultRotationPkgName) // If already installed, then skip 157 | if err == nil { 158 | return nil 159 | } 160 | phoneApkPath := "/data/local/tmp/RotationWatcher.apk" 161 | wc, err := s.d.OpenWrite(phoneApkPath, 0644, time.Now()) 162 | if err != nil { 163 | return err 164 | } 165 | resp, err := http.Get("https://github.com/openatx/RotationWatcher.apk/releases/download/1.0/RotationWatcher.apk") 166 | if err != nil { 167 | return err 168 | } 169 | if resp.StatusCode != http.StatusOK { 170 | return errors.New("http download rotation watcher status " + resp.Status) 171 | } 172 | defer resp.Body.Close() 173 | log.Println("downloading RotationWatcher.apk ...") 174 | if _, err = io.Copy(wc, resp.Body); err != nil { 175 | return err 176 | } 177 | log.Println("Done") 178 | if err := wc.Close(); err != nil { 179 | return err 180 | } 181 | _, err = s.checkCmdOutput("pm", "install", "-rt", phoneApkPath) 182 | return err 183 | } 184 | 185 | func (s *STFRotation) getPackagePath(name string) (path string, err error) { 186 | path, err = s.checkCmdOutput("pm", "path", name) 187 | if err != nil { 188 | return 189 | } 190 | if strings.HasPrefix(path, "package:") { 191 | path = strings.TrimSpace(path[len("package:"):]) 192 | return 193 | } 194 | return "", errors.New("not rotationwatcher package found") 195 | } 196 | 197 | func (s *STFRotation) checkCmdOutput(name string, args ...string) (outStr string, err error) { 198 | args = append(args, ";", "echo", ":$?") 199 | outStr, err = s.d.RunCommand(name, args...) 200 | if err != nil { 201 | return 202 | } 203 | idx := strings.LastIndexByte(outStr, ':') 204 | if idx == -1 { 205 | return outStr, errors.New("adb shell error, parse exit code failed") 206 | } 207 | exitCode, _ := strconv.Atoi(strings.TrimSpace(outStr[idx+1:])) 208 | if exitCode != 0 { 209 | err = fmt.Errorf("[adb shell %s %s] exit code %d", name, strings.Join(args, " "), exitCode) 210 | } 211 | return outStr[0:idx], err 212 | } 213 | -------------------------------------------------------------------------------- /minitouch.go: -------------------------------------------------------------------------------- 1 | package stf 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net" 9 | "os" 10 | "strconv" 11 | "strings" 12 | "syscall" 13 | "time" 14 | 15 | adb "github.com/openatx/go-adb" 16 | "github.com/pkg/errors" 17 | ) 18 | 19 | type TouchAction int 20 | 21 | const ( 22 | TOUCH_DOWN = TouchAction(iota) 23 | TOUCH_MOVE 24 | TOUCH_UP 25 | ) 26 | 27 | type STFTouch struct { 28 | cmdC chan string 29 | conn net.Conn 30 | maxX, maxY int 31 | rotation int 32 | 33 | *adb.Device 34 | errorMixin 35 | safeMixin 36 | } 37 | 38 | func NewSTFTouch(device *adb.Device) *STFTouch { 39 | return &STFTouch{ 40 | Device: device, 41 | cmdC: make(chan string, 0), 42 | } 43 | } 44 | 45 | func (s *STFTouch) Start() error { 46 | return s.safeDo(_ACTION_START, func() error { 47 | s.resetError() 48 | if err := s.prepare(); err != nil { 49 | return err 50 | } 51 | go s.runBinary() 52 | go func() { 53 | time.Sleep(time.Second) 54 | s.drainCmd() 55 | }() 56 | return nil 57 | }) 58 | } 59 | 60 | func (s *STFTouch) Stop() error { 61 | return s.safeDo(_ACTION_STOP, func() error { 62 | s.killProc("minitouch", syscall.SIGKILL) 63 | return s.Wait() 64 | }) 65 | } 66 | 67 | func (s *STFTouch) SetRotation(r int) { 68 | s.rotation = r 69 | } 70 | 71 | func (s *STFTouch) width() float64 { 72 | if s.rotation == 0 || s.rotation == 180 { 73 | return float64(s.maxX) 74 | } else { 75 | return float64(s.maxY) 76 | } 77 | } 78 | 79 | func (s *STFTouch) height() float64 { 80 | if s.rotation == 0 || s.rotation == 180 { 81 | return float64(s.maxY) 82 | } else { 83 | return float64(s.maxX) 84 | } 85 | } 86 | 87 | /** 88 | * Rotation affects the screen as follows: 89 | * 90 | * 0deg 91 | * |------| 92 | * | MENU | 93 | * |------| 94 | * --> | | --| 95 | * | | | v 96 | * | | 97 | * | | 98 | * |------| 99 | * |----|-| |-|----| 100 | * | |M| | | | 101 | * | |E| | | | 102 | * 90deg | |N| |U| | 270deg 103 | * | |U| |N| | 104 | * | | | |E| | 105 | * | | | |M| | 106 | * |----|-| |-|----| 107 | * |------| 108 | * ^ | | | 109 | * |-- | | <-- 110 | * | | 111 | * | | 112 | * |------| 113 | * | UNEM | 114 | * |------| 115 | * 180deg 116 | * 117 | * Which leads to the following mapping: 118 | * 119 | * |--------------|------|---------|---------|---------| 120 | * | | 0deg | 90deg | 180deg | 270deg | 121 | * |--------------|------|---------|---------|---------| 122 | * | CSS rotate() | 0deg | -90deg | -180deg | 90deg | 123 | * | bounding w | w | h | w | h | 124 | * | bounding h | h | w | h | w | 125 | * | pos x | x | h-y | w-x | y | 126 | * | pos y | y | x | h-y | h-x | 127 | * |--------------|------|---------|---------|---------| 128 | */ 129 | 130 | func (s *STFTouch) coords(xP, yP float64) (x, y int) { 131 | switch s.rotation { 132 | case 90: 133 | xP, yP = 1-yP, xP 134 | case 180: 135 | xP, yP = 1-xP, 1-yP 136 | case 270: 137 | xP, yP = yP, 1-xP 138 | } 139 | w, h := float64(s.maxX), float64(s.maxY) 140 | return int(w * xP), int(h * yP) 141 | } 142 | 143 | func (s *STFTouch) Down(index int, xP, yP float64) { 144 | posX, posY := s.coords(xP, yP) 145 | s.cmdC <- fmt.Sprintf("d %v %v %v 50", index, posX, posY) 146 | } 147 | 148 | func (s *STFTouch) Move(index int, xP, yP float64) { 149 | posX, posY := s.coords(xP, yP) 150 | s.cmdC <- fmt.Sprintf("m %v %v %v 50", index, posX, posY) 151 | } 152 | 153 | func (s *STFTouch) Up(index int) { 154 | s.cmdC <- fmt.Sprintf("u %d", index) 155 | } 156 | 157 | func (s *STFTouch) prepare() error { 158 | dst := "/data/local/tmp/minitouch" 159 | if AdbFileExists(s.Device, dst) { 160 | return nil 161 | } 162 | props, err := s.Properties() 163 | if err != nil { 164 | return err 165 | } 166 | abi, ok := props["ro.product.cpu.abi"] 167 | if !ok { 168 | return errors.New("No ro.product.cpu.abi propery") 169 | } 170 | urlStr := "https://github.com/openstf/stf/raw/master/vendor/minitouch/" + abi + "/minitouch" 171 | return PushFileFromHTTP(s.Device, dst, 0755, urlStr) 172 | } 173 | 174 | func (s *STFTouch) runBinary() (err error) { 175 | defer s.doneError(err) 176 | c, err := s.OpenCommand("/data/local/tmp/minitouch") 177 | if err != nil { 178 | return 179 | } 180 | defer c.Close() 181 | // _, err = io.Copy(ioutil.Discard, c) 182 | _, err = io.Copy(os.Stdout, c) 183 | return nil 184 | } 185 | 186 | func (s *STFTouch) drainCmd() { 187 | if err := s.dialWithRetry(); err != nil { 188 | s.doneError(errors.Wrap(err, "dial minitouch")) 189 | return 190 | } 191 | for c := range s.cmdC { 192 | c = strings.TrimSpace(c) + "\nc\n" // c: commit 193 | _, err := io.WriteString(s.conn, c) 194 | if err != nil { 195 | s.doneError(errors.Wrap(err, "write command to minitouch tcp")) 196 | s.conn.Close() 197 | s.conn = nil 198 | break 199 | } 200 | } 201 | } 202 | 203 | type lineFormatReader struct { 204 | bufrd *bufio.Reader 205 | err error 206 | } 207 | 208 | func (r *lineFormatReader) Scanf(format string, args ...interface{}) error { 209 | if r.err != nil { 210 | return r.err 211 | } 212 | var line []byte 213 | line, _, r.err = r.bufrd.ReadLine() 214 | if r.err != nil { 215 | return r.err 216 | } 217 | _, r.err = fmt.Sscanf(string(line), format, args...) 218 | return r.err 219 | } 220 | 221 | func (s *STFTouch) dialWithRetry() error { 222 | var err error 223 | for i := 0; i < 10; i++ { 224 | err = s.dialTouch() 225 | if err == nil { 226 | return nil 227 | } 228 | log.Println("dial minitouch service fail, reconnect, err is", err) 229 | time.Sleep(100 * time.Millisecond) 230 | } 231 | return err 232 | } 233 | 234 | func (s *STFTouch) dialTouch() error { 235 | port, err := s.ForwardToFreePort(adb.ForwardSpec{adb.FProtocolAbstract, "minitouch"}) 236 | if err != nil { 237 | return err 238 | } 239 | s.conn, err = net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port)) 240 | if err != nil { 241 | return err 242 | } 243 | lineRd := lineFormatReader{bufrd: bufio.NewReader(s.conn)} 244 | var flag string 245 | var ver int 246 | var maxContacts, maxPressure int 247 | var pid int 248 | lineRd.Scanf("%s %d", &flag, &ver) 249 | lineRd.Scanf("%s %d %d %d %d", &flag, &maxContacts, &s.maxX, &s.maxY, &maxPressure) 250 | if err := lineRd.Scanf("%s %d", &flag, &pid); err != nil { 251 | s.conn.Close() 252 | return err 253 | } 254 | return nil 255 | } 256 | 257 | // FIXME(ssx): maybe need to put into go-adb 258 | func (s *STFTouch) killProc(psName string, sig syscall.Signal) (err error) { 259 | out, err := s.RunCommand("ps", "-C", psName) 260 | if err != nil { 261 | return 262 | } 263 | lines := strings.Split(strings.TrimSpace(out), "\n") 264 | if len(lines) <= 1 { 265 | return errors.New("No process named " + psName + " founded.") 266 | } 267 | var pidIndex int 268 | for idx, val := range strings.Fields(lines[0]) { 269 | if val == "PID" { 270 | pidIndex = idx 271 | break 272 | } 273 | } 274 | for _, line := range lines[1:] { 275 | fields := strings.Fields(line) 276 | if !strings.Contains(line, psName) { 277 | continue 278 | } 279 | pid := fields[pidIndex] 280 | s.RunCommand("kill", "-"+strconv.Itoa(int(sig)), pid) 281 | } 282 | return 283 | } 284 | -------------------------------------------------------------------------------- /minicap.go: -------------------------------------------------------------------------------- 1 | package stf 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/binary" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net" 11 | "os" 12 | "strconv" 13 | "strings" 14 | "syscall" 15 | "time" 16 | 17 | "io/ioutil" 18 | 19 | "image/jpeg" 20 | 21 | adb "github.com/openatx/go-adb" 22 | "github.com/pkg/errors" 23 | ) 24 | 25 | const ( 26 | QUALITY_1080P = 1 27 | QUALITY_720P = 2 28 | QUALITY_480P = 3 29 | QUALITY_240P = 4 30 | ) 31 | 32 | type minicapInfo struct { 33 | Id int `json:"id"` 34 | Width int `json:"width"` 35 | Height int `json:"height"` 36 | Xdpi float32 `json:"xdpi"` 37 | Ydpi float32 `json:"ydpi"` 38 | Size float32 `json:"size"` 39 | Density float32 `json:"density"` 40 | Fps float32 `json:"fps"` 41 | Secure bool `json:"secure"` 42 | Rotation int `json:"rotation"` 43 | } 44 | 45 | type minicapDaemon struct { 46 | width, height int 47 | maxWidth, maxHeight int 48 | rotation int 49 | port int 50 | quitC chan bool 51 | rotationC chan int 52 | binaryPath string 53 | 54 | *adb.Device 55 | errorMixin 56 | safeMixin 57 | } 58 | 59 | func newMinicapDaemon(rotationC chan int, device *adb.Device) *minicapDaemon { 60 | if rotationC == nil { 61 | rotationC = make(chan int) 62 | } 63 | return &minicapDaemon{ 64 | rotationC: rotationC, 65 | Device: device, 66 | maxWidth: 720, 67 | maxHeight: 720, 68 | } 69 | } 70 | 71 | func (m *minicapDaemon) Start() error { 72 | return m.safeDo(_ACTION_START, 73 | func() error { 74 | m.resetError() 75 | m.quitC = make(chan bool, 1) 76 | m.killMinicap() 77 | if err := m.prepareSafe(); err != nil { 78 | return errors.Wrap(err, "prepare minicap") 79 | } 80 | go m.runScreenCaptureWithRotate() // TODO 81 | return nil 82 | }) 83 | } 84 | 85 | func (m *minicapDaemon) Stop() error { 86 | return m.safeDo(_ACTION_STOP, 87 | func() error { 88 | m.quitC <- true 89 | return m.Wait() 90 | }) 91 | } 92 | 93 | // minicap may say resource is busy .. 94 | func (m *minicapDaemon) prepareSafe() (err error) { 95 | n := 0 96 | for { 97 | err = m.prepare() 98 | if err == nil || n >= 3 { 99 | return 100 | } 101 | m.killMinicap() 102 | time.Sleep(100 * time.Millisecond) 103 | n++ 104 | } 105 | } 106 | 107 | // Check whether minicap is supported on the device 108 | // Check adb forward 109 | // For more information, see: https://github.com/openstf/minicap 110 | func (m *minicapDaemon) prepare() (err error) { 111 | if err = m.pushFiles(); err != nil { 112 | return 113 | } 114 | switch { 115 | case m.checkMinicap() == nil: 116 | m.binaryPath = "/data/local/tmp/minicap" 117 | case m.checkSlowMinicap() == nil: 118 | m.binaryPath = "/data/local/tmp/slow-minicap" 119 | default: 120 | err = errors.New("no suitable screen capture method found") 121 | return 122 | } 123 | return 124 | } 125 | 126 | // first check the minicap -i output 127 | // then update device basic info 128 | // at last take an screenshot, it may take some time, but it is worth of time 129 | func (m *minicapDaemon) checkMinicap() error { 130 | var mi minicapInfo 131 | out, err := m.RunCommand("LD_LIBRARY_PATH=/data/local/tmp", "/data/local/tmp/minicap", "-i", "2>/dev/null") 132 | if err != nil { 133 | return errors.Wrap(err, "run minicap -i") 134 | } 135 | err = json.Unmarshal([]byte(out), &mi) 136 | if err != nil { 137 | return err 138 | } 139 | m.width = mi.Width 140 | m.height = mi.Height 141 | m.rotation = mi.Rotation 142 | data, err := m.takeScreenshot() 143 | if err != nil { 144 | return errors.Wrap(err, "check minicap") 145 | } 146 | _, err = jpeg.Decode(bytes.NewBuffer(data)) 147 | if err != nil { 148 | return errors.Wrap(err, "check minicap") 149 | } 150 | return nil 151 | } 152 | 153 | func (m *minicapDaemon) checkSlowMinicap() error { 154 | var mi minicapInfo 155 | out, err := m.RunCommand("/data/local/tmp/slow-minicap", "-i", "2>/dev/null") 156 | if err != nil { 157 | return errors.Wrap(err, "run slow-minicap -i") 158 | } 159 | err = json.Unmarshal([]byte(out), &mi) 160 | if err != nil { 161 | return err 162 | } 163 | m.width = mi.Width 164 | m.height = mi.Height 165 | m.rotation = mi.Rotation 166 | return nil 167 | } 168 | 169 | // takeScreenshot output jpeg binary 170 | func (m *minicapDaemon) takeScreenshot() (data []byte, err error) { 171 | tmpFile := "/data/local/tmp/minicap_check.jpg" 172 | _, err = m.RunCommand("LD_LIBRARY_PATH=/data/local/tmp", "/data/local/tmp/minicap", "-s", "-P", fmt.Sprintf( 173 | "%dx%d@%dx%d/0", m.width, m.height, m.width, m.height), ">"+tmpFile) 174 | if err != nil { 175 | return 176 | } 177 | defer m.RunCommand("rm", tmpFile) 178 | rd, err := m.OpenRead(tmpFile) 179 | if err != nil { 180 | return 181 | } 182 | data, err = ioutil.ReadAll(rd) 183 | return 184 | } 185 | 186 | func (m *minicapDaemon) isRemoteExists(path string) bool { 187 | _, err := m.Stat(path) 188 | return err == nil 189 | } 190 | 191 | func (m *minicapDaemon) pushFiles() error { 192 | props, err := m.Properties() 193 | if err != nil { 194 | return err 195 | } 196 | abi, ok := props["ro.product.cpu.abi"] 197 | if !ok { 198 | return errors.New("No ro.product.cpu.abi propery") 199 | } 200 | sdk, ok := props["ro.build.version.sdk"] 201 | if !ok { 202 | return errors.New("No ro.build.version.sdk propery") 203 | } 204 | for _, filename := range []string{"minicap.so", "minicap"} { 205 | dst := "/data/local/tmp/" + filename 206 | if m.isRemoteExists(dst) { 207 | continue 208 | } 209 | var urlStr string 210 | var perms os.FileMode = 0644 211 | baseUrl := "https://gohttp.nie.netease.com/openstf/vendor" 212 | if filename == "minicap.so" { 213 | urlStr = baseUrl + "/minicap/shared/android-" + sdk + "/" + abi + "/minicap.so" 214 | } else { 215 | perms = 0755 216 | urlStr = baseUrl + "/minicap/bin/" + abi + "/minicap" 217 | } 218 | err := PushFileFromHTTP(m.Device, dst, perms, urlStr) 219 | if err != nil { 220 | return err 221 | } 222 | } 223 | err = PushFileFromHTTP(m.Device, "/data/local/tmp/slow-minicap", 0755, "https://gohttp.nie.netease.com/yosemite/slow-minicap/"+abi+"/slow-minicap") 224 | if err != nil { 225 | return errors.Wrap(err, "push files") 226 | } 227 | return nil 228 | } 229 | 230 | // TODO(ssx): setQuality 231 | func (m *minicapDaemon) SetQuality(quality int) { 232 | switch quality { 233 | case QUALITY_1080P: 234 | m.maxHeight, m.maxHeight = 1080, 1080 235 | case QUALITY_720P: 236 | m.maxHeight, m.maxHeight = 720, 720 237 | case QUALITY_480P: 238 | m.maxHeight, m.maxHeight = 480, 480 239 | case QUALITY_240P: 240 | m.maxHeight, m.maxHeight = 240, 240 241 | default: 242 | return 243 | } 244 | m.rotationC <- m.rotation // force restart minicap 245 | } 246 | 247 | func (m *minicapDaemon) SetRotation(r int) { 248 | select { 249 | case m.rotationC <- r: 250 | case <-time.After(100 * time.Millisecond): 251 | } 252 | } 253 | 254 | func (m *minicapDaemon) runScreenCaptureWithRotate() { 255 | m.killMinicap() 256 | var err error 257 | defer func() { 258 | m.doneError(errors.Wrap(err, "minicap")) 259 | }() 260 | errC := GoFunc(m.runScreenCapture) 261 | var needRestart bool 262 | for { 263 | select { 264 | case err = <-errC: // when normal exit, that is an error 265 | if !needRestart { 266 | return 267 | } 268 | needRestart = false 269 | err = nil 270 | errC = GoFunc(m.runScreenCapture) 271 | case r := <-m.rotationC: 272 | needRestart = true 273 | m.rotation = r 274 | m.killMinicap() 275 | case <-m.quitC: 276 | m.killMinicap() 277 | return 278 | } 279 | } 280 | } 281 | 282 | func (m *minicapDaemon) runScreenCapture() (err error) { 283 | param := fmt.Sprintf("%dx%d@%dx%d/%d", m.width, m.height, m.maxWidth, m.maxHeight, m.rotation) 284 | c, err := m.OpenCommand("LD_LIBRARY_PATH=/data/local/tmp", m.binaryPath, "-P", param, "-S") 285 | if err != nil { 286 | return 287 | } 288 | defer c.Close() 289 | buf := bufio.NewReader(c) 290 | 291 | // Example output below --. 292 | // WARNING: ... 293 | // PID: 9355 294 | // INFO: Using projection 720x1280@720x1280/0 295 | // INFO: (jni/minicap/JpgEncoder.cpp:64) Allocating 2766852 bytes for JPG encoder 296 | for { 297 | line, _, err := buf.ReadLine() 298 | if err != nil { 299 | return err 300 | } 301 | if strings.HasPrefix(string(line), "WARNING") { 302 | continue 303 | } 304 | if !strings.Contains(string(line), "PID:") { 305 | err = errors.New("expect PID: actually: " + strconv.Quote(string(line))) 306 | return errors.Wrap(err, "run minicap") 307 | } 308 | break 309 | } 310 | for { 311 | _, _, err = buf.ReadLine() 312 | if err != nil { 313 | break 314 | } 315 | } 316 | return errors.New("minicap quit") 317 | } 318 | 319 | func (m *minicapDaemon) killMinicap() error { 320 | m.killProc("minicap", syscall.SIGKILL) 321 | m.killProc("slow-minicap", syscall.SIGKILL) 322 | return nil 323 | } 324 | 325 | // FIXME(ssx): maybe need to put into go-adb 326 | func (m *minicapDaemon) killProc(psName string, sig syscall.Signal) (err error) { 327 | out, err := m.RunCommand("ps", "-C", psName) 328 | if err != nil { 329 | return 330 | } 331 | lines := strings.Split(strings.TrimSpace(out), "\n") 332 | if len(lines) <= 1 { 333 | return errors.New("No process named " + psName + " founded.") 334 | } 335 | var pidIndex int 336 | for idx, val := range strings.Fields(lines[0]) { 337 | if val == "PID" { 338 | pidIndex = idx 339 | break 340 | } 341 | } 342 | for _, line := range lines[1:] { 343 | fields := strings.Fields(line) 344 | if !strings.Contains(line, psName) { 345 | continue 346 | } 347 | pid := fields[pidIndex] 348 | m.RunCommand("kill", "-"+strconv.Itoa(int(sig)), pid) 349 | } 350 | return 351 | } 352 | 353 | type jpgTcpSucker struct { 354 | port int 355 | conn net.Conn 356 | quitC chan bool 357 | C chan []byte 358 | forwardSpec adb.ForwardSpec 359 | 360 | errorMixin 361 | safeMixin 362 | *adb.Device 363 | } 364 | 365 | func (s *jpgTcpSucker) Start() error { 366 | return s.safeDo(_ACTION_START, func() error { 367 | s.resetError() 368 | var err error 369 | s.C = make(chan []byte, 3) 370 | s.quitC = make(chan bool, 1) 371 | s.port, err = s.ForwardToFreePort(s.forwardSpec) 372 | if err != nil { 373 | return err 374 | } 375 | go s.keepReadFromTcp() 376 | return nil 377 | }) 378 | } 379 | 380 | func (s *jpgTcpSucker) Stop() error { 381 | return s.safeDo(_ACTION_STOP, func() error { 382 | s.quitC <- true 383 | if s.conn != nil { 384 | s.conn.Close() 385 | } 386 | return s.Wait() 387 | }) 388 | } 389 | 390 | type errorBinaryReader struct { 391 | rd io.Reader 392 | err error 393 | } 394 | 395 | func (r *errorBinaryReader) ReadInto(datas ...interface{}) error { 396 | if r.err != nil { 397 | return r.err 398 | } 399 | for _, data := range datas { 400 | r.err = binary.Read(r.rd, binary.LittleEndian, data) 401 | if r.err != nil { 402 | return r.err 403 | } 404 | } 405 | return nil 406 | } 407 | 408 | // TODO(ssx): Do not add retry for now 409 | func (s *jpgTcpSucker) keepReadFromTcp() (err error) { 410 | defer func() { 411 | s.doneError(errors.Wrap(err, "readFromTcp")) 412 | }() 413 | leftRetry := 10 414 | for { 415 | select { 416 | case err = <-GoFunc(s.readFromTcp): 417 | case <-s.quitC: 418 | return nil 419 | } 420 | select { 421 | case <-time.After(500 * time.Millisecond): 422 | case <-s.quitC: 423 | return nil 424 | } 425 | if leftRetry <= 0 { 426 | err = errors.New("jpgTcpSucker reach max retry(10)") 427 | return 428 | } 429 | leftRetry -= 1 430 | } 431 | } 432 | 433 | func (s *jpgTcpSucker) readFromTcp() (err error) { 434 | conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(s.port)) 435 | if err != nil { 436 | return 437 | } 438 | s.conn = conn 439 | defer conn.Close() 440 | 441 | var pid, rw, rh, vw, vh uint32 442 | var version, unused, orientation, quirkFlag uint8 443 | 444 | rd := bufio.NewReader(conn) 445 | binRd := errorBinaryReader{rd: rd} 446 | err = binRd.ReadInto(&version, &unused, &pid, &rw, &rh, &vw, &vh, &orientation, &quirkFlag) 447 | if err != nil { 448 | return err 449 | } 450 | 451 | for { 452 | var size uint32 453 | if err = binRd.ReadInto(&size); err != nil { 454 | break 455 | } 456 | 457 | lr := &io.LimitedReader{rd, int64(size)} 458 | buf := bytes.NewBuffer(nil) 459 | _, err = io.Copy(buf, lr) 460 | if err != nil { 461 | break 462 | } 463 | if string(buf.Bytes()[:2]) != "\xff\xd8" { 464 | err = errors.New("jpeg format error, not starts with 0xff,0xd8") 465 | break 466 | } 467 | select { 468 | case s.C <- buf.Bytes(): // Maybe should use buffer instead 469 | default: 470 | // image should not wait or it will stuck here 471 | } 472 | } 473 | return err 474 | } 475 | 476 | type STFCapturer struct { 477 | *minicapDaemon 478 | *jpgTcpSucker 479 | } 480 | 481 | func NewSTFCapturer(device *adb.Device) *STFCapturer { 482 | return &STFCapturer{ 483 | minicapDaemon: newMinicapDaemon(nil, device), 484 | jpgTcpSucker: &jpgTcpSucker{Device: device}, 485 | } 486 | } 487 | 488 | func (s *STFCapturer) Start() error { 489 | err := s.minicapDaemon.Start() 490 | if err != nil { 491 | return err 492 | } 493 | if s.minicapDaemon.binaryPath == "/data/local/tmp/slow-minicap" { 494 | s.jpgTcpSucker.forwardSpec = adb.ForwardSpec{adb.FProtocolTcp, "2016"} 495 | } else { 496 | s.jpgTcpSucker.forwardSpec = adb.ForwardSpec{adb.FProtocolAbstract, "minicap"} 497 | } 498 | return s.jpgTcpSucker.Start() 499 | } 500 | 501 | func (s *STFCapturer) Stop() error { 502 | return wrapMultiError( 503 | s.minicapDaemon.Stop(), 504 | s.jpgTcpSucker.Stop()) 505 | } 506 | 507 | func (s *STFCapturer) Wait() error { 508 | select { 509 | case err := <-GoFunc(s.minicapDaemon.Wait): 510 | return err 511 | case err := <-GoFunc(s.jpgTcpSucker.Wait): 512 | return err 513 | } 514 | // return wrapMultiError( 515 | // s.minicapDaemon.Wait(), 516 | // s.jpgTcpSucker.Wait()) 517 | } 518 | --------------------------------------------------------------------------------