├── 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 |
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 | [](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 | | UDID |
113 | |
114 |
115 |
116 | | ScreenSize |
117 | x |
118 |
119 |
120 | | SessionId |
121 |
122 |
123 |
124 | |
125 |
126 |
127 | | RefreshCount |
128 | |
129 |
130 |
131 |
132 |
133 |
134 |
137 |
138 |
147 |
148 |
149 |
159 |
160 |
161 |
162 |
163 |
164 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
264 |
627 |
628 |
629 |
--------------------------------------------------------------------------------