├── web ├── assets │ ├── images │ │ └── favicon.ico │ ├── index.html │ ├── packages.html │ └── remote-control.html ├── assets_dev.go └── assets_generate.go ├── .travis.yml ├── .fsw.yml ├── .gitignore ├── httplog.go ├── .goreleaser.yml ├── .github └── workflows │ └── release.yml ├── go.mod ├── ioskit.go ├── LICENSE ├── installer.go ├── API.md ├── revproxy.go ├── README.md ├── connector └── connector.go ├── main.go └── provider.go /web/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openatx/wdaproxy/HEAD/web/assets/images/favicon.ico -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.13.x 4 | install: go get -v -t 5 | script: 6 | - go generate ./web 7 | - go test -v 8 | after_success: 9 | test -n "$TRAVIS_TAG" && curl -sL https://git.io/goreleaser | bash 10 | -------------------------------------------------------------------------------- /web/assets_dev.go: -------------------------------------------------------------------------------- 1 | // +build !vfs 2 | //go:generate go run assets_generate.go 3 | 4 | package web 5 | 6 | import "net/http" 7 | 8 | // Assets contains project assets. 9 | var Assets http.FileSystem = http.Dir("web/assets") 10 | -------------------------------------------------------------------------------- /.fsw.yml: -------------------------------------------------------------------------------- 1 | desc: Auto generated by fswatch [wdaproxy] 2 | triggers: 3 | - name: "" 4 | pattens: 5 | - '**/*.go' 6 | - '**/*.c' 7 | - '**/*.py' 8 | env: 9 | DEBUG: "1" 10 | cmd: go build && ./wdaproxy -p 8200 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 | -------------------------------------------------------------------------------- /web/assets_generate.go: -------------------------------------------------------------------------------- 1 | // +build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "log" 7 | "net/http" 8 | 9 | "github.com/shurcooL/vfsgen" 10 | ) 11 | 12 | func main() { 13 | var fs http.FileSystem = http.Dir("assets") 14 | 15 | err := vfsgen.Generate(fs, vfsgen.Options{ 16 | PackageName: "web", 17 | BuildTags: "vfs", 18 | VariableName: "Assets", 19 | }) 20 | if err != nil { 21 | log.Fatalln(err) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.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 | 26 | # Manual 27 | *.py 28 | *.zip 29 | wdaproxy 30 | assets_vfsdata.go 31 | 32 | go.sum 33 | dist/ 34 | -------------------------------------------------------------------------------- /httplog.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | accesslog "github.com/mash/go-accesslog" 9 | ) 10 | 11 | var logger = log.New(os.Stdout, "\033[0;32m[", log.Ltime) 12 | 13 | type HTTPLogger struct { 14 | } 15 | 16 | // Example 17 | // [I 170227 14:47:16 web:1946] 200 GET /api/v1/devices (10.240.185.65) 28.00ms 18 | func (l HTTPLogger) Log(record accesslog.LogRecord) { 19 | logger.Println(fmt.Sprintf("\b] \033[0;m%d %s %s (%s) %.2fms", record.Status, record.Method, record.Uri, record.Ip, 20 | float64(record.ElapsedTime.Nanoseconds()/1000)/1000.0)) 21 | } 22 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | before: 4 | hooks: 5 | # you may remove this if you don't use vgo 6 | - go mod tidy 7 | # you may remove this if you don't need go generate 8 | - go generate ./web 9 | builds: 10 | - env: 11 | - CGO_ENABLED=0 12 | main: . 13 | flags: 14 | - -tags=vfs 15 | goos: 16 | - darwin 17 | goarch: 18 | - amd64 19 | brews: 20 | - 21 | tap: 22 | owner: openatx 23 | name: homebrew-tap 24 | folder: Formula 25 | dependencies: 26 | - name: usbmuxd 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | jobs: 8 | goreleaser: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - 12 | name: Checkout 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | - 17 | name: Set up Go 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: 1.15 21 | - 22 | name: Run GoReleaser 23 | uses: goreleaser/goreleaser-action@v2 24 | with: 25 | version: latest 26 | args: release --rm-dist 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/openatx/wdaproxy 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/codeskyblue/muuid v0.0.0-20170401091614-44f8dfd4b3a9 7 | github.com/facebookgo/freeport v0.0.0-20150612182905-d4adf43b75b9 8 | github.com/gobuild/log v1.0.0 9 | github.com/gorilla/mux v1.8.0 10 | github.com/gorilla/websocket v1.4.2 11 | github.com/mash/go-accesslog v1.2.0 12 | github.com/ogier/pflag v0.0.1 13 | github.com/pkg/errors v0.9.1 14 | github.com/satori/go.uuid v1.2.0 // indirect 15 | github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect 16 | github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 17 | github.com/stretchr/testify v1.6.1 // indirect 18 | golang.org/x/tools v0.0.0-20201121010211-780cb80bd7fb // indirect 19 | howett.net/plist v0.0.0-20201026045517-117a925f2150 20 | ) 21 | 22 | replace github.com/qiniu/log => github.com/gobuild/log v0.1.0 23 | -------------------------------------------------------------------------------- /ioskit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "os/exec" 6 | 7 | plist "howett.net/plist" 8 | ) 9 | 10 | type DeviceInfo struct { 11 | MacAddress string `plist:"EthernetAddress" json:"-"` // eg: a0:11:28:31:42:21 12 | ProductName string `plist:"ProductName" json:"-"` // eg: iPhone OS 13 | HardwareModel string `plist:"HardwareModel" json:"-"` // eg: N56AP 14 | ProductType string `plist:"ProductType" json:"model"` // eg: iPhone7,1 15 | Version string `plist:"ProductVersion" json:"version"` // eg: 10.2 16 | CPUArchitecture string `plist:"CPUArchitecture" json:"abi"` // eg: arm64 17 | Udid string `plist:"UniqueDeviceID" json:"serial"` 18 | Manufacturer string `json:"manufacturer"` 19 | } 20 | 21 | func GetDeviceInfo(udid string) (v DeviceInfo, err error) { 22 | c := exec.Command("ideviceinfo", "--udid", udid, "--xml") 23 | output, err := c.Output() 24 | if err != nil { 25 | return 26 | } 27 | v.Manufacturer = "Apple" 28 | err = plist.NewDecoder(bytes.NewReader(output)).Decode(&v) 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 shengxiang 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 | -------------------------------------------------------------------------------- /installer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "os/exec" 6 | 7 | plist "howett.net/plist" 8 | ) 9 | 10 | type Package struct { 11 | Name string `plist:"CFBundleDisplayName" json:"name"` 12 | BundleId string `plist:"CFBundleIdentifier" json:"bundleId"` 13 | Version string `plist:"CFBundleVersion" json:"version"` 14 | MinOSVersion string `plist:"MinimumOSVersion" json:"miniOSVersion"` 15 | SupportedDevices []string `plist:"UISupportedDevices" json:"UISupportedDevices"` 16 | } 17 | 18 | func ListPackages(udid string) (pkgs []Package, err error) { 19 | pkgs = make([]Package, 0) 20 | c := exec.Command("ideviceinstaller", "--udid", udid, "-l", "-o", "xml") 21 | data, err := c.Output() 22 | if err != nil { 23 | return nil, err 24 | } 25 | err = plist.NewDecoder(bytes.NewReader(data)).Decode(&pkgs) 26 | return 27 | } 28 | 29 | func UninstallPackage(udid, bundleId string) (output string, err error) { 30 | c := exec.Command("ideviceinstaller", "--udid", udid, "--uninstall", bundleId) 31 | data, err := c.CombinedOutput() 32 | return string(data), err 33 | } 34 | -------------------------------------------------------------------------------- /web/assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | WDAProxy Index 7 | 19 | 20 | 21 | 22 |

WDA Proxy

23 |
24 | 30 |

31 |   
32 | 33 | 34 | 48 | 49 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # Interface 2 | API which offer to FE 3 | 4 | ## File operation 5 | Get file list 6 | 7 | ``` 8 | $ curl -X GET /api/v1/files 9 | { 10 | "success": true, 11 | "value": [{ 12 | "name": "a/f.txt" 13 | }, { 14 | "name": "b/f.py" 15 | }] 16 | } 17 | ``` 18 | 19 | Delete file 20 | 21 | ``` 22 | $ curl -X DELETE /api/v1/files/{name} 23 | { 24 | "success": true, 25 | "description": "file deleted" 26 | } 27 | ``` 28 | 29 | Add file 30 | 31 | ``` 32 | $ curl -X POST /api/v1/files < 2 | 3 | 4 | 5 | 6 | App Manager 7 | 19 | 20 | 21 | 22 |

Packages

23 |
24 |
  • 25 | {{v.name}} 26 | 30 |
  • 31 |
    32 | 33 | 34 | 35 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /revproxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "net/http/httputil" 11 | "net/url" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/gorilla/mux" 16 | ) 17 | 18 | type transport struct { 19 | http.RoundTripper 20 | } 21 | 22 | func (t *transport) RoundTrip(req *http.Request) (resp *http.Response, err error) { 23 | // rewrite url 24 | if strings.HasPrefix(req.RequestURI, "/origin/") { 25 | req.URL.Path = req.RequestURI[len("/origin"):] 26 | return t.RoundTripper.RoundTrip(req) 27 | } 28 | 29 | // request 30 | resp, err = t.RoundTripper.RoundTrip(req) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | // rewrite body 36 | if req.URL.Path == "/status" { 37 | jsonResp := &statusResp{} 38 | err = json.NewDecoder(resp.Body).Decode(jsonResp) 39 | if err != nil { 40 | return nil, err 41 | } 42 | resp.Body.Close() 43 | jsonResp.Value["device"] = map[string]interface{}{ 44 | "udid": udid, 45 | "name": udidNames[udid], 46 | } 47 | data, _ := json.Marshal(jsonResp) 48 | // update body and fix length 49 | resp.Body = ioutil.NopCloser(bytes.NewReader(data)) 50 | resp.ContentLength = int64(len(data)) 51 | resp.Header.Set("Content-Length", strconv.Itoa(len(data))) 52 | return resp, nil 53 | } 54 | return resp, nil 55 | } 56 | 57 | func NewReverseProxyHandlerFunc(targetURL *url.URL) http.HandlerFunc { 58 | httpProxy := httputil.NewSingleHostReverseProxy(targetURL) 59 | httpProxy.Transport = &transport{http.DefaultTransport} 60 | return func(rw http.ResponseWriter, r *http.Request) { 61 | httpProxy.ServeHTTP(rw, r) 62 | } 63 | } 64 | 65 | type fakeProxy struct { 66 | } 67 | 68 | func (p *fakeProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { 69 | log.Println("FAKE", r.RequestURI) 70 | log.Println("P", r.URL.Path) 71 | io.WriteString(w, "Fake") 72 | } 73 | 74 | func NewAppiumProxyHandlerFunc(targetURL *url.URL) http.HandlerFunc { 75 | httpProxy := httputil.NewSingleHostReverseProxy(targetURL) 76 | rt := mux.NewRouter() 77 | rt.HandleFunc("/wd/hub/sessions", func(w http.ResponseWriter, r *http.Request) { 78 | data, _ := json.MarshalIndent(map[string]interface{}{ 79 | "status": 0, 80 | "value": []string{}, 81 | "sessionId": nil, 82 | }, "", " ") 83 | w.Header().Set("Content-Type", "application/json") 84 | w.Header().Set("Content-Length", strconv.Itoa(len(data))) 85 | w.Write(data) 86 | }) 87 | rt.HandleFunc("/wd/hub/session/{sessionId}/window/current/size", func(w http.ResponseWriter, r *http.Request) { 88 | r.URL.Path = strings.Replace(r.URL.Path, "/current/size", "/size", -1) 89 | r.URL.Path = r.URL.Path[len("/wd/hub"):] 90 | httpProxy.ServeHTTP(w, r) 91 | }) 92 | rt.Handle("/wd/hub/{subpath:.*}", http.StripPrefix("/wd/hub", httpProxy)) 93 | return rt.ServeHTTP 94 | } 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wdaproxy 2 | [![Powered By: GoReleaser](https://img.shields.io/badge/powered%20by-goreleaser-green.svg?style=flat-square)](https://github.com/goreleaser) 3 | 4 | Make [WebDriverAgent](https://github.com/facebook/WebDriverAgent) more powerful. 5 | 6 | # Platform 7 | Limited in Mac 8 | 9 | # Features 10 | - [x] Launch iproxy when start. Listen on `0.0.0.0` instead of `localhost` 11 | - [x] Create http proxy for WDA server 12 | - [x] add udid into `GET /status` 13 | - [x] forward all url starts with `/origin/` to `/` 14 | - [x] Add the missing Index page 15 | - [x] Support Package management API 16 | - [x] Support launch WDA 17 | - [x] iOS device remote control 18 | - [x] Support Appium Desktop (Beta) 19 | 20 | # Installl 21 | ``` 22 | $ brew install openatx/tap/wdaproxy 23 | ``` 24 | 25 | # Usage 26 | Simple run 27 | 28 | ``` 29 | $ wdaproxy -p 8100 -u $UDID 30 | ``` 31 | 32 | Run with WDA 33 | 34 | ``` 35 | $ wdaproxy -W ../WebDriverAgent 36 | ``` 37 | 38 | For more run `wdaproxy -h` 39 | 40 | Strong recommended use [python facebook-wda](https://github.com/openatx/facebook-wda) to write tests. 41 | But if you have to use appium. Just keep reading. 42 | 43 | ## Simulate appium server 44 | Since WDA implements WebDriver protocol. 45 | Even through many API not implemented. But it still works. `wdaproxy` implemented a few api listed bellow. 46 | 47 | - wdaproxy "/wd/hubs/sessions" 48 | - wdaproxy "/wd/hubs/session/$sessionId/window/current/size" 49 | 50 | Launch wdaproxy with command `wdaproxy -p 8100 -u $UDID` 51 | 52 | Here is sample `Python-Appium-Client` code. 53 | 54 | ```python 55 | from appium import webdriver 56 | import time 57 | 58 | driver = webdriver.Remote("http://127.0.0.1:8100/wd/hub", {"bundleId": "com.apple.Preferences"}) 59 | 60 | def wait_element(xpath, timeout=10): 61 | print("Wait ELEMENT", xpath) 62 | deadline = time.time() + timeout 63 | while time.time() <= deadline: 64 | el = driver.find_element_by_xpath(xpath) 65 | if el: 66 | return el 67 | time.sleep(.2) 68 | raise RuntimeError("Element for " + xpath + " not found") 69 | 70 | wait_element('//XCUIElementTypeCell[@name="蓝牙"]').click() 71 | ``` 72 | 73 | Not working well code 74 | 75 | ```python 76 | driver.background_app(3) 77 | driver.implicitly_wait(30) 78 | driver.get_window_rect() 79 | # many a lot. 80 | ``` 81 | 82 | # Extended API 83 | Package install 84 | 85 | ``` 86 | $ curl -X POST -F file=@some.ipa http://localhost:8100/api/v1/packages 87 | $ curl -X POST -F url="http://somehost.com/some.ipa" http://localhost:8100/api/v1/packages 88 | ``` 89 | 90 | Package uninstall 91 | 92 | ``` 93 | $ curl -X DELETE http://localhost:8100/api/v1/packages/${BUNDLE_ID} 94 | ``` 95 | 96 | Package list (parse from `ideviceinstaller -l`) 97 | 98 | ``` 99 | $ curl -X GET http://localhost:8100/api/v1/packages 100 | ``` 101 | 102 | # For developer 103 | First checkout this repository 104 | 105 | ```bash 106 | git clone https://github.com/openatx/wdaproxy $GOPATH/src/github.com/openatx/wdaproxy 107 | cd $GOPATH/src/github.com/openatx/wdaproxy 108 | ``` 109 | 110 | Update golang vendor 111 | ```bash 112 | brew install glide 113 | glide up 114 | ``` 115 | 116 | Package web resources into binary 117 | 118 | ```bash 119 | go generate ./web 120 | go build -tags vfs 121 | ``` 122 | 123 | # LICENSE 124 | Under [MIT](LICENSE) 125 | -------------------------------------------------------------------------------- /connector/connector.go: -------------------------------------------------------------------------------- 1 | package connector 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | "runtime" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/codeskyblue/muuid" 12 | "github.com/gorilla/websocket" 13 | "github.com/pkg/errors" 14 | qlog "github.com/gobuild/log" 15 | ) 16 | 17 | var log = qlog.New(os.Stdout, "", qlog.Llevel|qlog.Lshortfile|qlog.LstdFlags) 18 | 19 | const ( 20 | ActionInit = "init" 21 | ActionDeviceAdd = "addDevice" 22 | ActionDeviceRemove = "removeDevice" 23 | ActionDeviceRelease = "releaseDevice" 24 | ) 25 | 26 | type Connector struct { 27 | ws *websocket.Conn 28 | host string 29 | listenPort int 30 | msgC chan interface{} 31 | 32 | Id string `json:"id"` 33 | Name string `json:"name"` 34 | OS string `json:"os"` 35 | Arch string `json:"arch"` 36 | Group string `json:"group"` 37 | Address string `json:"address"` 38 | 39 | RemoteIp string `json:"-"` 40 | devices map[string]interface{} 41 | } 42 | 43 | func New(host string, group string, listenPort int) *Connector { 44 | c := &Connector{ 45 | host: host, 46 | msgC: make(chan interface{}), 47 | Group: group, 48 | listenPort: listenPort, 49 | Id: muuid.UUID() + ":" + strconv.Itoa(listenPort), 50 | OS: runtime.GOOS, 51 | Arch: runtime.GOARCH, 52 | devices: make(map[string]interface{}), 53 | } 54 | c.Name, _ = os.Hostname() 55 | return c 56 | } 57 | 58 | func (w *Connector) KeepOnline() { 59 | if w.host == "" { 60 | return 61 | } 62 | for { 63 | w.keepOnline() 64 | log.Println("Retry connect to center after 3.0s") 65 | time.Sleep(3 * time.Second) 66 | } 67 | } 68 | 69 | func (w *Connector) keepOnline() error { 70 | u := url.URL{ 71 | Scheme: "ws", 72 | Host: w.host, 73 | Path: "/websocket", 74 | } 75 | log.Printf("connecting to %s", u.String()) 76 | 77 | ws, _, err := websocket.DefaultDialer.Dial(u.String(), nil) 78 | if err != nil { 79 | return errors.Wrap(err, "dial websocket") 80 | } 81 | w.ws = ws 82 | defer ws.Close() 83 | 84 | // Step 1: get remote ip 85 | var t = struct { 86 | RemoteIp string `json:"remoteIp"` 87 | }{} 88 | if err := ws.ReadJSON(&t); err != nil { 89 | return errors.Wrap(err, "read remoteIp") 90 | } 91 | w.RemoteIp = t.RemoteIp 92 | w.Address = fmt.Sprintf("http://%s:%d", t.RemoteIp, w.listenPort) 93 | 94 | // Step 2: send provider info 95 | err = ws.WriteJSON(map[string]interface{}{ 96 | "type": ActionInit, 97 | "data": w, 98 | }) 99 | if err != nil { 100 | return errors.Wrap(err, "send init data") 101 | } 102 | 103 | done := make(chan bool, 1) 104 | go w.keepPing(done) 105 | defer func() { 106 | done <- true 107 | }() 108 | 109 | // resend registed device data 110 | for _, device := range w.devices { 111 | w.Do(ActionDeviceAdd, device) 112 | } 113 | 114 | for { 115 | _, message, err := ws.ReadMessage() 116 | if err != nil { 117 | return errors.Wrap(err, "read message") 118 | } 119 | log.Printf("recv: %s", message) 120 | } 121 | return nil 122 | } 123 | 124 | func (w *Connector) keepPing(done chan bool) { 125 | ticker := time.NewTicker(20 * time.Second) 126 | defer ticker.Stop() 127 | for { 128 | select { 129 | case <-ticker.C: 130 | if err := w.ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil { 131 | log.Warnf("send ping: %v", err) 132 | return 133 | } 134 | case msg := <-w.msgC: 135 | if err := w.ws.WriteJSON(msg); err != nil { 136 | log.Warnf("send data: %v", err) 137 | return 138 | } 139 | case <-done: 140 | return 141 | } 142 | } 143 | } 144 | 145 | func (w *Connector) WriteJSON(v interface{}) { 146 | w.msgC <- v 147 | } 148 | 149 | func (w *Connector) Do(action string, data interface{}) { 150 | w.msgC <- map[string]interface{}{ 151 | "type": action, 152 | "data": data, 153 | } 154 | } 155 | 156 | func (w *Connector) AddDevice(id string, device interface{}) { 157 | w.Do(ActionDeviceAdd, device) 158 | w.devices[id] = device 159 | } 160 | 161 | // func (w *Connector) ReleaseDevice(serial string, oneOffToken string) { 162 | // w.msgC <- map[string]interface{}{ 163 | // "type": "releaseDevice", 164 | // "data": map[string]string{ 165 | // "serial": serial, 166 | // "oneOffToken": oneOffToken, 167 | // }, 168 | // } 169 | // } 170 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "strconv" 15 | "strings" 16 | 17 | "github.com/facebookgo/freeport" 18 | "github.com/gobuild/log" 19 | "github.com/gorilla/mux" 20 | accesslog "github.com/mash/go-accesslog" 21 | flag "github.com/ogier/pflag" 22 | "github.com/openatx/wdaproxy/web" 23 | _ "github.com/shurcooL/vfsgen" 24 | ) 25 | 26 | func init() { 27 | log.SetFlags(log.Lshortfile | log.LstdFlags) 28 | } 29 | 30 | var ( 31 | version = "develop" 32 | lisPort = 8100 33 | pWda string 34 | udid string 35 | yosemiteServer string 36 | yosemiteGroup string 37 | debug bool 38 | 39 | rt = mux.NewRouter() 40 | udidNames = map[string]string{} 41 | ) 42 | 43 | type statusResp struct { 44 | Value map[string]interface{} `json:"value,omitempty"` 45 | SessionId string `json:"sessionId,omitempty"` 46 | Status int `json:"status"` 47 | } 48 | 49 | func getUdid() string { 50 | if udid != "" { 51 | return udid 52 | } 53 | output, err := exec.Command("idevice_id", "-l").Output() 54 | if err != nil { 55 | panic(err) 56 | } 57 | return strings.TrimSpace(string(output)) 58 | } 59 | 60 | func assetsContent(name string) string { 61 | fd, err := web.Assets.Open(name) 62 | if err != nil { 63 | panic(err) 64 | } 65 | data, err := ioutil.ReadAll(fd) 66 | if err != nil { 67 | panic(err) 68 | } 69 | return string(data) 70 | } 71 | 72 | type Device struct { 73 | Udid string `json:"serial"` 74 | Manufacturer string `json:"manufacturer"` 75 | } 76 | 77 | // LocalIP returns the non loopback local IP of the host 78 | func LocalIP() string { 79 | addrs, err := net.InterfaceAddrs() 80 | if err != nil { 81 | return "" 82 | } 83 | for _, address := range addrs { 84 | // check the address type and if it is not a loopback the display it 85 | if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { 86 | if ipnet.IP.To4() != nil { 87 | return ipnet.IP.String() 88 | } 89 | } 90 | } 91 | return "" 92 | } 93 | 94 | func main() { 95 | showVer := flag.BoolP("version", "v", false, "Print version") 96 | flag.IntVarP(&lisPort, "port", "p", 8100, "Proxy listen port") 97 | flag.StringVarP(&udid, "udid", "u", "", "device udid") 98 | flag.StringVarP(&pWda, "wda", "W", "", "WebDriverAgent project directory [optional]") 99 | flag.BoolVarP(&debug, "debug", "d", false, "Open debug mode") 100 | 101 | // flag.StringVarP(&yosemiteServer, "yosemite-server", "S", 102 | // os.Getenv("YOSEMITE_SERVER"), 103 | // "server center(not open source yet") 104 | // flag.StringVarP(&yosemiteGroup, "yosemite-group", "G", 105 | // "everyone", 106 | // "server center group") 107 | flag.Parse() 108 | if udid == "" { 109 | udid = getUdid() 110 | } 111 | 112 | if *showVer { 113 | println(version) 114 | return 115 | } 116 | 117 | lis, err := net.Listen("tcp", ":"+strconv.Itoa(lisPort)) 118 | if err != nil { 119 | log.Fatal(err) 120 | } 121 | 122 | // if yosemiteServer != "" { 123 | // mockIOSProvider() 124 | // } 125 | 126 | errC := make(chan error) 127 | freePort, err := freeport.Get() 128 | if err != nil { 129 | log.Fatal(err) 130 | } 131 | log.Printf("freeport %d", freePort) 132 | 133 | go func() { 134 | log.Printf("launch tcp-proxy, listen on %d", lisPort) 135 | targetURL, _ := url.Parse("http://127.0.0.1:" + strconv.Itoa(freePort)) 136 | rt.HandleFunc("/wd/hub/{path:.*}", NewAppiumProxyHandlerFunc(targetURL)) 137 | rt.HandleFunc("/{path:.*}", NewReverseProxyHandlerFunc(targetURL)) 138 | errC <- http.Serve(lis, accesslog.NewLoggingHandler(rt, HTTPLogger{})) 139 | }() 140 | go func() { 141 | log.Printf("launch iproxy (udid: %s)", strconv.Quote(udid)) 142 | c := exec.Command("iproxy", strconv.Itoa(freePort), "8100") 143 | if udid != "" { 144 | c.Args = append(c.Args, udid) 145 | } 146 | errC <- c.Run() 147 | }() 148 | go func(udid string) { 149 | if pWda == "" { 150 | return 151 | } 152 | // device name 153 | nameBytes, _ := exec.Command("idevicename", "-u", udid).Output() 154 | deviceName := strings.TrimSpace(string(nameBytes)) 155 | udidNames[udid] = deviceName 156 | log.Printf("device name: %s", deviceName) 157 | 158 | log.Printf("launch WebDriverAgent(dir=%s)", pWda) 159 | c := exec.Command("xcodebuild", 160 | "-verbose", 161 | "-project", "WebDriverAgent.xcodeproj", 162 | "-scheme", "WebDriverAgentRunner", 163 | "-destination", "id="+udid, "test-without-building") // test-without-building 164 | c.Dir, _ = filepath.Abs(pWda) 165 | // Test Suite 'All tests' started at 2017-02-27 15:55:35.263 166 | // Test Suite 'WebDriverAgentRunner.xctest' started at 2017-02-27 15:55:35.266 167 | // Test Suite 'UITestingUITests' started at 2017-02-27 15:55:35.267 168 | // Test Case '-[UITestingUITests testRunner]' started. 169 | // t = 0.00s Start Test at 2017-02-27 15:55:35.270 170 | // t = 0.01s Set Up 171 | pipeReader, writer := io.Pipe() 172 | c.Stdout = writer 173 | c.Stderr = writer 174 | c.Stdin = os.Stdin 175 | 176 | bufrd := bufio.NewReader(pipeReader) 177 | if err = c.Start(); err != nil { 178 | log.Fatal(err) 179 | } 180 | 181 | // close writers when xcodebuild exit 182 | go func() { 183 | c.Wait() 184 | writer.Close() 185 | }() 186 | 187 | lineStr := "" 188 | for { 189 | line, isPrefix, err := bufrd.ReadLine() 190 | if isPrefix { 191 | lineStr = lineStr + string(line) 192 | continue 193 | } else { 194 | lineStr = string(line) 195 | } 196 | lineStr = strings.TrimSpace(string(line)) 197 | 198 | if debug { 199 | fmt.Printf("[WDA] %s\n", lineStr) 200 | } 201 | if err != nil { 202 | log.Fatal("[WDA] exit", err) 203 | } 204 | if strings.Contains(lineStr, "Successfully wrote Manifest cache to") { 205 | log.Println("[WDA] test ipa successfully generated") 206 | } 207 | if strings.HasPrefix(lineStr, "Test Case '-[UITestingUITests testRunner]' started") { 208 | log.Println("[WDA] successfully started") 209 | } 210 | lineStr = "" // reset str 211 | } 212 | }(udid) 213 | 214 | log.Printf("Open webbrower with http://%s:%d", LocalIP(), lisPort) 215 | log.Fatal(<-errC) 216 | } 217 | -------------------------------------------------------------------------------- /provider.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | "io" 9 | "io/ioutil" 10 | "net/http" 11 | "os" 12 | "os/exec" 13 | "time" 14 | 15 | "github.com/gorilla/mux" 16 | "github.com/gorilla/websocket" 17 | "github.com/openatx/wdaproxy/connector" 18 | "github.com/openatx/wdaproxy/web" 19 | "github.com/gobuild/log" 20 | ) 21 | 22 | var ( 23 | upgrader = websocket.Upgrader{ 24 | ReadBufferSize: 1024, 25 | WriteBufferSize: 1024, 26 | } 27 | ) 28 | 29 | func init() { 30 | rt.HandleFunc("/devices/{udid}", func(w http.ResponseWriter, r *http.Request) { 31 | http.Redirect(w, r, "/", 302) 32 | // io.WriteString(w, "Not finished yet") 33 | }) 34 | 35 | rt.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 36 | t := template.Must(template.New("index").Parse(assetsContent("/index.html"))) 37 | t.Execute(w, nil) 38 | }) 39 | 40 | rt.HandleFunc("/packages", func(w http.ResponseWriter, r *http.Request) { 41 | t := template.Must(template.New("pkgs").Delims("[[", "]]").Parse(assetsContent("/packages.html"))) 42 | t.Execute(w, nil) 43 | }) 44 | 45 | rt.HandleFunc("/remote", func(w http.ResponseWriter, r *http.Request) { 46 | t := template.Must(template.New("pkgs").Delims("[[", "]]").Parse(assetsContent("/remote-control.html"))) 47 | t.Execute(w, nil) 48 | }) 49 | 50 | rt.PathPrefix("/res/").Handler(http.StripPrefix("/res/", http.FileServer(web.Assets))) 51 | rt.PathPrefix("/recorddata/").Handler(http.StripPrefix("/recorddata/", http.FileServer(http.Dir("./recorddata")))) 52 | 53 | rt.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { 54 | favicon, _ := web.Assets.Open("images/favicon.ico") 55 | http.ServeContent(w, r, "favicon.ico", time.Now(), favicon) 56 | }) 57 | 58 | v1Rounter(rt) 59 | } 60 | 61 | func v1Rounter(rt *mux.Router) { 62 | rt.HandleFunc("/api/v1/packages", func(w http.ResponseWriter, r *http.Request) { 63 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 64 | pkgs, err := ListPackages(udid) 65 | if err != nil { 66 | json.NewEncoder(w).Encode(map[string]interface{}{ 67 | "success": false, 68 | "description": err.Error(), 69 | }) 70 | return 71 | } 72 | json.NewEncoder(w).Encode(map[string]interface{}{ 73 | "success": true, 74 | "value": pkgs, 75 | }) 76 | }).Methods("GET") 77 | 78 | rt.HandleFunc("/api/v1/packages/{bundleId}", func(w http.ResponseWriter, r *http.Request) { 79 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 80 | bundleId := mux.Vars(r)["bundleId"] 81 | output, err := UninstallPackage(udid, bundleId) 82 | json.NewEncoder(w).Encode(map[string]interface{}{ 83 | "success": err == nil, 84 | "description": output, 85 | }) 86 | }).Methods("DELETE") 87 | 88 | rt.HandleFunc("/api/v1/packages", func(w http.ResponseWriter, r *http.Request) { 89 | r.ParseMultipartForm(0) 90 | defer r.MultipartForm.RemoveAll() 91 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 92 | 93 | renderError := func(err error, description string) { 94 | json.NewEncoder(w).Encode(map[string]interface{}{ 95 | "success": false, 96 | "description": fmt.Sprintf("%s: %v", description, err), 97 | }) 98 | } 99 | var reader io.Reader 100 | url := r.FormValue("url") 101 | if url != "" { 102 | resp, err := http.Get(url) 103 | if err != nil { 104 | renderError(err, "download from url") 105 | return 106 | } 107 | reader = resp.Body 108 | defer resp.Body.Close() 109 | } else { 110 | file, _, err := r.FormFile("file") 111 | if err != nil { 112 | renderError(err, "parse form 'file'") 113 | return 114 | } 115 | reader = file 116 | defer file.Close() 117 | } 118 | os.Mkdir("uploads", 0755) 119 | tmpfile, err := ioutil.TempFile("uploads", "tempipa-") 120 | if err != nil { 121 | renderError(err, "create tmpfile") 122 | return 123 | } 124 | defer os.Remove(tmpfile.Name()) 125 | 126 | log.Println("[pkg] create tmpfile", tmpfile.Name()) 127 | _, err = io.Copy(tmpfile, reader) 128 | if err != nil { 129 | renderError(err, "read upload file") 130 | return 131 | } 132 | if err := tmpfile.Close(); err != nil { 133 | renderError(err, "finish write tmpfile") 134 | return 135 | } 136 | log.Println("[pkg] install ipa") 137 | cmd := exec.Command("ideviceinstaller", "--udid", udid, "-i", tmpfile.Name()) 138 | output, err := cmd.CombinedOutput() 139 | if err != nil { 140 | renderError(errors.New(string(output)), "install ipa") 141 | return 142 | } 143 | json.NewEncoder(w).Encode(map[string]interface{}{ 144 | "success": true, 145 | "description": "Successfully installed ipa", 146 | "value": string(output), 147 | }) 148 | }).Methods("POST") 149 | 150 | rt.HandleFunc("/api/v1/records", func(w http.ResponseWriter, r *http.Request) { 151 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 152 | if r.Method == "POST" { 153 | os.MkdirAll("./recorddata/20170603-test", 0755) 154 | err := launchXrecord("./recorddata/20170603-test/camera.mp4") 155 | if err != nil { 156 | json.NewEncoder(w).Encode(map[string]interface{}{ 157 | "success": false, 158 | "description": "Launch xrecord failed: " + err.Error(), 159 | }) 160 | } else { 161 | json.NewEncoder(w).Encode(map[string]interface{}{ 162 | "success": true, 163 | "description": "Record started", 164 | }) 165 | } 166 | } else { 167 | if xrecordCmd != nil && xrecordCmd.Process != nil { 168 | xrecordCmd.Process.Signal(os.Interrupt) 169 | } 170 | select { 171 | case <-GoFunc(xrecordCmd.Wait): 172 | case <-time.After(5 * time.Second): 173 | xrecordCmd.Process.Kill() 174 | json.NewEncoder(w).Encode(map[string]interface{}{ 175 | "success": false, 176 | "description": "xrecord handle Ctrl-C longer than 5 second", 177 | }) 178 | return 179 | } 180 | json.NewEncoder(w).Encode(map[string]interface{}{ 181 | "success": true, 182 | "description": "Record stopped", 183 | }) 184 | } 185 | }).Methods("POST", "DELETE") 186 | } 187 | 188 | func mockIOSProvider() { 189 | c := connector.New(yosemiteServer, yosemiteGroup, lisPort) 190 | go c.KeepOnline() 191 | 192 | device, err := GetDeviceInfo(udid) 193 | if err != nil { 194 | log.Fatal(err) 195 | } 196 | 197 | c.AddDevice(device.Udid, device) 198 | // c.WriteJSON(map[string]interface{}{ 199 | // "type": "addDevice", 200 | // "data": device, 201 | // }) 202 | rt.HandleFunc("/api/devices/{udid}/remoteConnect", func(w http.ResponseWriter, r *http.Request) { 203 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 204 | if r.Method == "POST" { 205 | json.NewEncoder(w).Encode(map[string]interface{}{ 206 | "success": true, 207 | "description": "notice this is mock data", 208 | "remoteConnectUrl": fmt.Sprintf("http://%s:%d/", c.RemoteIp, lisPort), 209 | }) 210 | } 211 | if r.Method == "DELETE" { 212 | json.NewEncoder(w).Encode(map[string]interface{}{ 213 | "success": true, 214 | "description": "Device remote disconnected successfully", 215 | }) 216 | } 217 | }).Methods("POST", "DELETE") 218 | } 219 | 220 | var xrecordCmd *exec.Cmd 221 | 222 | func launchXrecord(output string) error { 223 | rpath, err := exec.LookPath("xrecord") 224 | if err != nil { 225 | return err 226 | } 227 | xrecordCmd = exec.Command(rpath, "-i", "0x14100000046d082d", "-o", output, "-f") 228 | xrecordCmd.Stdout = os.Stdout 229 | xrecordCmd.Stderr = os.Stderr 230 | return xrecordCmd.Start() 231 | } 232 | 233 | func GoFunc(f func() error) chan error { 234 | errc := make(chan error) 235 | go func() { 236 | errc <- f() 237 | }() 238 | return errc 239 | } 240 | -------------------------------------------------------------------------------- /web/assets/remote-control.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Remote Control 7 | 8 | 9 | 10 | 11 | 12 | 91 | 92 | 93 |
    94 |
    95 |
    96 |
    97 | 98 | 99 | 100 |
    101 |
    102 |
    103 |

    {{device.name}}

    104 |
    105 |
    106 |
    107 |

    设备详情

    108 |
    109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 125 | 126 | 127 | 128 | 129 | 130 | 131 |
    UDID
    ScreenSizex
    SessionId 122 | 123 | 124 |
    RefreshCount
    132 |
    133 |
    134 | 137 |
    138 |
    139 |
    140 | 141 | 142 | 143 | 144 | 145 |
    146 |
    147 | 148 |
    149 |
    150 |
    151 | 156 | 157 |
    158 |
    159 |
    160 |
    161 | 162 |
    163 |
    164 | 168 |
    169 |
    170 |
    171 |
    172 |
    173 |
    174 | 175 | 176 | 177 | 178 | 179 | 264 | 627 | 628 | 629 | --------------------------------------------------------------------------------