1
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
desc: Auto generated by fswatch [wdaproxy]
|
||||
triggers:
|
||||
- name: ""
|
||||
pattens:
|
||||
- '**/*.go'
|
||||
- '**/*.c'
|
||||
- '**/*.py'
|
||||
env:
|
||||
DEBUG: "1"
|
||||
cmd: go build && ./wdaproxy -p 8200
|
||||
shell: true
|
||||
delay: 100ms
|
||||
stop_timeout: 500ms
|
||||
signal: KILL
|
||||
kill_signal: ""
|
||||
watch_paths:
|
||||
- .
|
||||
watch_depth: 0
|
||||
@@ -0,0 +1,28 @@
|
||||
name: goreleaser
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
-
|
||||
name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.15
|
||||
-
|
||||
name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v2
|
||||
with:
|
||||
version: latest
|
||||
args: release --rm-dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Compiled Object files, Static and Dynamic libs (Shared Objects)
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# Folders
|
||||
_obj
|
||||
_test
|
||||
|
||||
# Architecture specific extensions/prefixes
|
||||
*.[568vq]
|
||||
[568vq].out
|
||||
|
||||
*.cgo1.go
|
||||
*.cgo2.c
|
||||
_cgo_defun.c
|
||||
_cgo_gotypes.go
|
||||
_cgo_export.*
|
||||
|
||||
_testmain.go
|
||||
|
||||
*.exe
|
||||
*.test
|
||||
*.prof
|
||||
|
||||
# Manual
|
||||
*.py
|
||||
*.zip
|
||||
wdaproxy
|
||||
assets_vfsdata.go
|
||||
|
||||
go.sum
|
||||
dist/
|
||||
@@ -0,0 +1,26 @@
|
||||
# This is an example goreleaser.yaml file with some sane defaults.
|
||||
# Make sure to check the documentation at http://goreleaser.com
|
||||
before:
|
||||
hooks:
|
||||
# you may remove this if you don't use vgo
|
||||
- go mod tidy
|
||||
# you may remove this if you don't need go generate
|
||||
- go generate ./web
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
main: .
|
||||
flags:
|
||||
- -tags=vfs
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
brews:
|
||||
-
|
||||
tap:
|
||||
owner: openatx
|
||||
name: homebrew-tap
|
||||
folder: Formula
|
||||
dependencies:
|
||||
- name: usbmuxd
|
||||
@@ -0,0 +1,9 @@
|
||||
language: go
|
||||
go:
|
||||
- 1.13.x
|
||||
install: go get -v -t
|
||||
script:
|
||||
- go generate ./web
|
||||
- go test -v
|
||||
after_success:
|
||||
test -n "$TRAVIS_TAG" && curl -sL https://git.io/goreleaser | bash
|
||||
@@ -0,0 +1,87 @@
|
||||
# Interface
|
||||
API which offer to FE
|
||||
|
||||
## File operation
|
||||
Get file list
|
||||
|
||||
```
|
||||
$ curl -X GET /api/v1/files
|
||||
{
|
||||
"success": true,
|
||||
"value": [{
|
||||
"name": "a/f.txt"
|
||||
}, {
|
||||
"name": "b/f.py"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Delete file
|
||||
|
||||
```
|
||||
$ curl -X DELETE /api/v1/files/{name}
|
||||
{
|
||||
"success": true,
|
||||
"description": "file deleted"
|
||||
}
|
||||
```
|
||||
|
||||
Add file
|
||||
|
||||
```
|
||||
$ curl -X POST /api/v1/files <<EOF
|
||||
{
|
||||
"name": "some.png",
|
||||
"data": "# coding: utf-8"
|
||||
}
|
||||
EOF
|
||||
{
|
||||
"success": true,
|
||||
"description": "file created"
|
||||
}
|
||||
```
|
||||
|
||||
## Devices
|
||||
```
|
||||
$ curl -X GET /api/v1/devices
|
||||
{
|
||||
"success": true,
|
||||
"value": [{
|
||||
"platform": "Android",
|
||||
"serial": "XAFEFF"
|
||||
}, {
|
||||
"platform": "iOS",
|
||||
"udid": "11231lk2j3lkjsdfasdfafaff"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Screenshot
|
||||
```
|
||||
$ curl -X GET /api/v1/devices/{serial}/screenshot
|
||||
{
|
||||
"success": true,
|
||||
"imageType": "png",
|
||||
"value": "LKJ#RF"
|
||||
}
|
||||
```
|
||||
|
||||
## Code debug
|
||||
WebSocket
|
||||
|
||||
```
|
||||
ws.connect ws://hostname.com/ipython
|
||||
|
||||
ws.send {
|
||||
"code": "# coding: utf-8 ..."
|
||||
}
|
||||
|
||||
ws.recv {
|
||||
"output": "hello\n"
|
||||
}
|
||||
|
||||
in the last recv: {
|
||||
"elapsedTime": 10002, // unit ms
|
||||
"exitCode": 0
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 shengxiang
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,124 @@
|
||||
# wdaproxy
|
||||
[](https://github.com/goreleaser)
|
||||
|
||||
Make [WebDriverAgent](https://github.com/facebook/WebDriverAgent) more powerful.
|
||||
|
||||
# Platform
|
||||
Limited in Mac
|
||||
|
||||
# Features
|
||||
- [x] Launch iproxy when start. Listen on `0.0.0.0` instead of `localhost`
|
||||
- [x] Create http proxy for WDA server
|
||||
- [x] add udid into `GET /status`
|
||||
- [x] forward all url starts with `/origin/<url>` to `/<url>`
|
||||
- [x] Add the missing Index page
|
||||
- [x] Support Package management API
|
||||
- [x] Support launch WDA
|
||||
- [x] iOS device remote control
|
||||
- [x] Support Appium Desktop (Beta)
|
||||
|
||||
# Installl
|
||||
```
|
||||
$ brew install openatx/tap/wdaproxy
|
||||
```
|
||||
|
||||
# Usage
|
||||
Simple run
|
||||
|
||||
```
|
||||
$ wdaproxy -p 8100 -u $UDID
|
||||
```
|
||||
|
||||
Run with WDA
|
||||
|
||||
```
|
||||
$ wdaproxy -W ../WebDriverAgent
|
||||
```
|
||||
|
||||
For more run `wdaproxy -h`
|
||||
|
||||
Strong recommended use [python facebook-wda](https://github.com/openatx/facebook-wda) to write tests.
|
||||
But if you have to use appium. Just keep reading.
|
||||
|
||||
## Simulate appium server
|
||||
Since WDA implements WebDriver protocol.
|
||||
Even through many API not implemented. But it still works. `wdaproxy` implemented a few api listed bellow.
|
||||
|
||||
- wdaproxy "/wd/hubs/sessions"
|
||||
- wdaproxy "/wd/hubs/session/$sessionId/window/current/size"
|
||||
|
||||
Launch wdaproxy with command `wdaproxy -p 8100 -u $UDID`
|
||||
|
||||
Here is sample `Python-Appium-Client` code.
|
||||
|
||||
```python
|
||||
from appium import webdriver
|
||||
import time
|
||||
|
||||
driver = webdriver.Remote("http://127.0.0.1:8100/wd/hub", {"bundleId": "com.apple.Preferences"})
|
||||
|
||||
def wait_element(xpath, timeout=10):
|
||||
print("Wait ELEMENT", xpath)
|
||||
deadline = time.time() + timeout
|
||||
while time.time() <= deadline:
|
||||
el = driver.find_element_by_xpath(xpath)
|
||||
if el:
|
||||
return el
|
||||
time.sleep(.2)
|
||||
raise RuntimeError("Element for " + xpath + " not found")
|
||||
|
||||
wait_element('//XCUIElementTypeCell[@name="蓝牙"]').click()
|
||||
```
|
||||
|
||||
Not working well code
|
||||
|
||||
```python
|
||||
driver.background_app(3)
|
||||
driver.implicitly_wait(30)
|
||||
driver.get_window_rect()
|
||||
# many a lot.
|
||||
```
|
||||
|
||||
# Extended API
|
||||
Package install
|
||||
|
||||
```
|
||||
$ curl -X POST -F [email protected] http://localhost:8100/api/v1/packages
|
||||
$ curl -X POST -F url="http://somehost.com/some.ipa" http://localhost:8100/api/v1/packages
|
||||
```
|
||||
|
||||
Package uninstall
|
||||
|
||||
```
|
||||
$ curl -X DELETE http://localhost:8100/api/v1/packages/${BUNDLE_ID}
|
||||
```
|
||||
|
||||
Package list (parse from `ideviceinstaller -l`)
|
||||
|
||||
```
|
||||
$ curl -X GET http://localhost:8100/api/v1/packages
|
||||
```
|
||||
|
||||
# For developer
|
||||
First checkout this repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openatx/wdaproxy $GOPATH/src/github.com/openatx/wdaproxy
|
||||
cd $GOPATH/src/github.com/openatx/wdaproxy
|
||||
```
|
||||
|
||||
Update golang vendor
|
||||
```bash
|
||||
brew install glide
|
||||
glide up
|
||||
```
|
||||
|
||||
Package web resources into binary
|
||||
|
||||
```bash
|
||||
go generate ./web
|
||||
go build -tags vfs
|
||||
```
|
||||
|
||||
# LICENSE
|
||||
Under [MIT](LICENSE)
|
||||
@@ -0,0 +1,169 @@
|
||||
package connector
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/codeskyblue/muuid"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pkg/errors"
|
||||
qlog "github.com/gobuild/log"
|
||||
)
|
||||
|
||||
var log = qlog.New(os.Stdout, "", qlog.Llevel|qlog.Lshortfile|qlog.LstdFlags)
|
||||
|
||||
const (
|
||||
ActionInit = "init"
|
||||
ActionDeviceAdd = "addDevice"
|
||||
ActionDeviceRemove = "removeDevice"
|
||||
ActionDeviceRelease = "releaseDevice"
|
||||
)
|
||||
|
||||
type Connector struct {
|
||||
ws *websocket.Conn
|
||||
host string
|
||||
listenPort int
|
||||
msgC chan interface{}
|
||||
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Group string `json:"group"`
|
||||
Address string `json:"address"`
|
||||
|
||||
RemoteIp string `json:"-"`
|
||||
devices map[string]interface{}
|
||||
}
|
||||
|
||||
func New(host string, group string, listenPort int) *Connector {
|
||||
c := &Connector{
|
||||
host: host,
|
||||
msgC: make(chan interface{}),
|
||||
Group: group,
|
||||
listenPort: listenPort,
|
||||
Id: muuid.UUID() + ":" + strconv.Itoa(listenPort),
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
devices: make(map[string]interface{}),
|
||||
}
|
||||
c.Name, _ = os.Hostname()
|
||||
return c
|
||||
}
|
||||
|
||||
func (w *Connector) KeepOnline() {
|
||||
if w.host == "" {
|
||||
return
|
||||
}
|
||||
for {
|
||||
w.keepOnline()
|
||||
log.Println("Retry connect to center after 3.0s")
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Connector) keepOnline() error {
|
||||
u := url.URL{
|
||||
Scheme: "ws",
|
||||
Host: w.host,
|
||||
Path: "/websocket",
|
||||
}
|
||||
log.Printf("connecting to %s", u.String())
|
||||
|
||||
ws, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "dial websocket")
|
||||
}
|
||||
w.ws = ws
|
||||
defer ws.Close()
|
||||
|
||||
// Step 1: get remote ip
|
||||
var t = struct {
|
||||
RemoteIp string `json:"remoteIp"`
|
||||
}{}
|
||||
if err := ws.ReadJSON(&t); err != nil {
|
||||
return errors.Wrap(err, "read remoteIp")
|
||||
}
|
||||
w.RemoteIp = t.RemoteIp
|
||||
w.Address = fmt.Sprintf("http://%s:%d", t.RemoteIp, w.listenPort)
|
||||
|
||||
// Step 2: send provider info
|
||||
err = ws.WriteJSON(map[string]interface{}{
|
||||
"type": ActionInit,
|
||||
"data": w,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "send init data")
|
||||
}
|
||||
|
||||
done := make(chan bool, 1)
|
||||
go w.keepPing(done)
|
||||
defer func() {
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// resend registed device data
|
||||
for _, device := range w.devices {
|
||||
w.Do(ActionDeviceAdd, device)
|
||||
}
|
||||
|
||||
for {
|
||||
_, message, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "read message")
|
||||
}
|
||||
log.Printf("recv: %s", message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Connector) keepPing(done chan bool) {
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := w.ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Warnf("send ping: %v", err)
|
||||
return
|
||||
}
|
||||
case msg := <-w.msgC:
|
||||
if err := w.ws.WriteJSON(msg); err != nil {
|
||||
log.Warnf("send data: %v", err)
|
||||
return
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Connector) WriteJSON(v interface{}) {
|
||||
w.msgC <- v
|
||||
}
|
||||
|
||||
func (w *Connector) Do(action string, data interface{}) {
|
||||
w.msgC <- map[string]interface{}{
|
||||
"type": action,
|
||||
"data": data,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Connector) AddDevice(id string, device interface{}) {
|
||||
w.Do(ActionDeviceAdd, device)
|
||||
w.devices[id] = device
|
||||
}
|
||||
|
||||
// func (w *Connector) ReleaseDevice(serial string, oneOffToken string) {
|
||||
// w.msgC <- map[string]interface{}{
|
||||
// "type": "releaseDevice",
|
||||
// "data": map[string]string{
|
||||
// "serial": serial,
|
||||
// "oneOffToken": oneOffToken,
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,22 @@
|
||||
module github.com/openatx/wdaproxy
|
||||
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/codeskyblue/muuid v0.0.0-20170401091614-44f8dfd4b3a9
|
||||
github.com/facebookgo/freeport v0.0.0-20150612182905-d4adf43b75b9
|
||||
github.com/gobuild/log v1.0.0
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/mash/go-accesslog v1.2.0
|
||||
github.com/ogier/pflag v0.0.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/satori/go.uuid v1.2.0 // indirect
|
||||
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect
|
||||
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546
|
||||
github.com/stretchr/testify v1.6.1 // indirect
|
||||
golang.org/x/tools v0.0.0-20201121010211-780cb80bd7fb // indirect
|
||||
howett.net/plist v0.0.0-20201026045517-117a925f2150
|
||||
)
|
||||
|
||||
replace github.com/qiniu/log => github.com/gobuild/log v0.1.0
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
accesslog "github.com/mash/go-accesslog"
|
||||
)
|
||||
|
||||
var logger = log.New(os.Stdout, "\033[0;32m[", log.Ltime)
|
||||
|
||||
type HTTPLogger struct {
|
||||
}
|
||||
|
||||
// Example
|
||||
// [I 170227 14:47:16 web:1946] 200 GET /api/v1/devices (10.240.185.65) 28.00ms
|
||||
func (l HTTPLogger) Log(record accesslog.LogRecord) {
|
||||
logger.Println(fmt.Sprintf("\b] \033[0;m%d %s %s (%s) %.2fms", record.Status, record.Method, record.Uri, record.Ip,
|
||||
float64(record.ElapsedTime.Nanoseconds()/1000)/1000.0))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os/exec"
|
||||
|
||||
plist "howett.net/plist"
|
||||
)
|
||||
|
||||
type Package struct {
|
||||
Name string `plist:"CFBundleDisplayName" json:"name"`
|
||||
BundleId string `plist:"CFBundleIdentifier" json:"bundleId"`
|
||||
Version string `plist:"CFBundleVersion" json:"version"`
|
||||
MinOSVersion string `plist:"MinimumOSVersion" json:"miniOSVersion"`
|
||||
SupportedDevices []string `plist:"UISupportedDevices" json:"UISupportedDevices"`
|
||||
}
|
||||
|
||||
func ListPackages(udid string) (pkgs []Package, err error) {
|
||||
pkgs = make([]Package, 0)
|
||||
c := exec.Command("ideviceinstaller", "--udid", udid, "-l", "-o", "xml")
|
||||
data, err := c.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = plist.NewDecoder(bytes.NewReader(data)).Decode(&pkgs)
|
||||
return
|
||||
}
|
||||
|
||||
func UninstallPackage(udid, bundleId string) (output string, err error) {
|
||||
c := exec.Command("ideviceinstaller", "--udid", udid, "--uninstall", bundleId)
|
||||
data, err := c.CombinedOutput()
|
||||
return string(data), err
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os/exec"
|
||||
|
||||
plist "howett.net/plist"
|
||||
)
|
||||
|
||||
type DeviceInfo struct {
|
||||
MacAddress string `plist:"EthernetAddress" json:"-"` // eg: a0:11:28:31:42:21
|
||||
ProductName string `plist:"ProductName" json:"-"` // eg: iPhone OS
|
||||
HardwareModel string `plist:"HardwareModel" json:"-"` // eg: N56AP
|
||||
ProductType string `plist:"ProductType" json:"model"` // eg: iPhone7,1
|
||||
Version string `plist:"ProductVersion" json:"version"` // eg: 10.2
|
||||
CPUArchitecture string `plist:"CPUArchitecture" json:"abi"` // eg: arm64
|
||||
Udid string `plist:"UniqueDeviceID" json:"serial"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
}
|
||||
|
||||
func GetDeviceInfo(udid string) (v DeviceInfo, err error) {
|
||||
c := exec.Command("ideviceinfo", "--udid", udid, "--xml")
|
||||
output, err := c.Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
v.Manufacturer = "Apple"
|
||||
err = plist.NewDecoder(bytes.NewReader(output)).Decode(&v)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/facebookgo/freeport"
|
||||
"github.com/gobuild/log"
|
||||
"github.com/gorilla/mux"
|
||||
accesslog "github.com/mash/go-accesslog"
|
||||
flag "github.com/ogier/pflag"
|
||||
"github.com/openatx/wdaproxy/web"
|
||||
_ "github.com/shurcooL/vfsgen"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetFlags(log.Lshortfile | log.LstdFlags)
|
||||
}
|
||||
|
||||
var (
|
||||
version = "develop"
|
||||
lisPort = 8100
|
||||
pWda string
|
||||
udid string
|
||||
yosemiteServer string
|
||||
yosemiteGroup string
|
||||
debug bool
|
||||
|
||||
rt = mux.NewRouter()
|
||||
udidNames = map[string]string{}
|
||||
)
|
||||
|
||||
type statusResp struct {
|
||||
Value map[string]interface{} `json:"value,omitempty"`
|
||||
SessionId string `json:"sessionId,omitempty"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
func getUdid() string {
|
||||
if udid != "" {
|
||||
return udid
|
||||
}
|
||||
output, err := exec.Command("idevice_id", "-l").Output()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
func assetsContent(name string) string {
|
||||
fd, err := web.Assets.Open(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
data, err := ioutil.ReadAll(fd)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
Udid string `json:"serial"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
}
|
||||
|
||||
// LocalIP returns the non loopback local IP of the host
|
||||
func LocalIP() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, address := range addrs {
|
||||
// check the address type and if it is not a loopback the display it
|
||||
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
if ipnet.IP.To4() != nil {
|
||||
return ipnet.IP.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
showVer := flag.BoolP("version", "v", false, "Print version")
|
||||
flag.IntVarP(&lisPort, "port", "p", 8100, "Proxy listen port")
|
||||
flag.StringVarP(&udid, "udid", "u", "", "device udid")
|
||||
flag.StringVarP(&pWda, "wda", "W", "", "WebDriverAgent project directory [optional]")
|
||||
flag.BoolVarP(&debug, "debug", "d", false, "Open debug mode")
|
||||
|
||||
// flag.StringVarP(&yosemiteServer, "yosemite-server", "S",
|
||||
// os.Getenv("YOSEMITE_SERVER"),
|
||||
// "server center(not open source yet")
|
||||
// flag.StringVarP(&yosemiteGroup, "yosemite-group", "G",
|
||||
// "everyone",
|
||||
// "server center group")
|
||||
flag.Parse()
|
||||
if udid == "" {
|
||||
udid = getUdid()
|
||||
}
|
||||
|
||||
if *showVer {
|
||||
println(version)
|
||||
return
|
||||
}
|
||||
|
||||
lis, err := net.Listen("tcp", ":"+strconv.Itoa(lisPort))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// if yosemiteServer != "" {
|
||||
// mockIOSProvider()
|
||||
// }
|
||||
|
||||
errC := make(chan error)
|
||||
freePort, err := freeport.Get()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("freeport %d", freePort)
|
||||
|
||||
go func() {
|
||||
log.Printf("launch tcp-proxy, listen on %d", lisPort)
|
||||
targetURL, _ := url.Parse("http://127.0.0.1:" + strconv.Itoa(freePort))
|
||||
rt.HandleFunc("/wd/hub/{path:.*}", NewAppiumProxyHandlerFunc(targetURL))
|
||||
rt.HandleFunc("/{path:.*}", NewReverseProxyHandlerFunc(targetURL))
|
||||
errC <- http.Serve(lis, accesslog.NewLoggingHandler(rt, HTTPLogger{}))
|
||||
}()
|
||||
go func() {
|
||||
log.Printf("launch iproxy (udid: %s)", strconv.Quote(udid))
|
||||
c := exec.Command("iproxy", strconv.Itoa(freePort), "8100")
|
||||
if udid != "" {
|
||||
c.Args = append(c.Args, udid)
|
||||
}
|
||||
errC <- c.Run()
|
||||
}()
|
||||
go func(udid string) {
|
||||
if pWda == "" {
|
||||
return
|
||||
}
|
||||
// device name
|
||||
nameBytes, _ := exec.Command("idevicename", "-u", udid).Output()
|
||||
deviceName := strings.TrimSpace(string(nameBytes))
|
||||
udidNames[udid] = deviceName
|
||||
log.Printf("device name: %s", deviceName)
|
||||
|
||||
log.Printf("launch WebDriverAgent(dir=%s)", pWda)
|
||||
c := exec.Command("xcodebuild",
|
||||
"-verbose",
|
||||
"-project", "WebDriverAgent.xcodeproj",
|
||||
"-scheme", "WebDriverAgentRunner",
|
||||
"-destination", "id="+udid, "test-without-building") // test-without-building
|
||||
c.Dir, _ = filepath.Abs(pWda)
|
||||
// Test Suite 'All tests' started at 2017-02-27 15:55:35.263
|
||||
// Test Suite 'WebDriverAgentRunner.xctest' started at 2017-02-27 15:55:35.266
|
||||
// Test Suite 'UITestingUITests' started at 2017-02-27 15:55:35.267
|
||||
// Test Case '-[UITestingUITests testRunner]' started.
|
||||
// t = 0.00s Start Test at 2017-02-27 15:55:35.270
|
||||
// t = 0.01s Set Up
|
||||
pipeReader, writer := io.Pipe()
|
||||
c.Stdout = writer
|
||||
c.Stderr = writer
|
||||
c.Stdin = os.Stdin
|
||||
|
||||
bufrd := bufio.NewReader(pipeReader)
|
||||
if err = c.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// close writers when xcodebuild exit
|
||||
go func() {
|
||||
c.Wait()
|
||||
writer.Close()
|
||||
}()
|
||||
|
||||
lineStr := ""
|
||||
for {
|
||||
line, isPrefix, err := bufrd.ReadLine()
|
||||
if isPrefix {
|
||||
lineStr = lineStr + string(line)
|
||||
continue
|
||||
} else {
|
||||
lineStr = string(line)
|
||||
}
|
||||
lineStr = strings.TrimSpace(string(line))
|
||||
|
||||
if debug {
|
||||
fmt.Printf("[WDA] %s\n", lineStr)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal("[WDA] exit", err)
|
||||
}
|
||||
if strings.Contains(lineStr, "Successfully wrote Manifest cache to") {
|
||||
log.Println("[WDA] test ipa successfully generated")
|
||||
}
|
||||
if strings.HasPrefix(lineStr, "Test Case '-[UITestingUITests testRunner]' started") {
|
||||
log.Println("[WDA] successfully started")
|
||||
}
|
||||
lineStr = "" // reset str
|
||||
}
|
||||
}(udid)
|
||||
|
||||
log.Printf("Open webbrower with http://%s:%d", LocalIP(), lisPort)
|
||||
log.Fatal(<-errC)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/openatx/wdaproxy/connector"
|
||||
"github.com/openatx/wdaproxy/web"
|
||||
"github.com/gobuild/log"
|
||||
)
|
||||
|
||||
var (
|
||||
upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
rt.HandleFunc("/devices/{udid}", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", 302)
|
||||
// io.WriteString(w, "Not finished yet")
|
||||
})
|
||||
|
||||
rt.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
t := template.Must(template.New("index").Parse(assetsContent("/index.html")))
|
||||
t.Execute(w, nil)
|
||||
})
|
||||
|
||||
rt.HandleFunc("/packages", func(w http.ResponseWriter, r *http.Request) {
|
||||
t := template.Must(template.New("pkgs").Delims("[[", "]]").Parse(assetsContent("/packages.html")))
|
||||
t.Execute(w, nil)
|
||||
})
|
||||
|
||||
rt.HandleFunc("/remote", func(w http.ResponseWriter, r *http.Request) {
|
||||
t := template.Must(template.New("pkgs").Delims("[[", "]]").Parse(assetsContent("/remote-control.html")))
|
||||
t.Execute(w, nil)
|
||||
})
|
||||
|
||||
rt.PathPrefix("/res/").Handler(http.StripPrefix("/res/", http.FileServer(web.Assets)))
|
||||
rt.PathPrefix("/recorddata/").Handler(http.StripPrefix("/recorddata/", http.FileServer(http.Dir("./recorddata"))))
|
||||
|
||||
rt.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
favicon, _ := web.Assets.Open("images/favicon.ico")
|
||||
http.ServeContent(w, r, "favicon.ico", time.Now(), favicon)
|
||||
})
|
||||
|
||||
v1Rounter(rt)
|
||||
}
|
||||
|
||||
func v1Rounter(rt *mux.Router) {
|
||||
rt.HandleFunc("/api/v1/packages", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
pkgs, err := ListPackages(udid)
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"description": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"value": pkgs,
|
||||
})
|
||||
}).Methods("GET")
|
||||
|
||||
rt.HandleFunc("/api/v1/packages/{bundleId}", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
bundleId := mux.Vars(r)["bundleId"]
|
||||
output, err := UninstallPackage(udid, bundleId)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": err == nil,
|
||||
"description": output,
|
||||
})
|
||||
}).Methods("DELETE")
|
||||
|
||||
rt.HandleFunc("/api/v1/packages", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseMultipartForm(0)
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
renderError := func(err error, description string) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"description": fmt.Sprintf("%s: %v", description, err),
|
||||
})
|
||||
}
|
||||
var reader io.Reader
|
||||
url := r.FormValue("url")
|
||||
if url != "" {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
renderError(err, "download from url")
|
||||
return
|
||||
}
|
||||
reader = resp.Body
|
||||
defer resp.Body.Close()
|
||||
} else {
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
renderError(err, "parse form 'file'")
|
||||
return
|
||||
}
|
||||
reader = file
|
||||
defer file.Close()
|
||||
}
|
||||
os.Mkdir("uploads", 0755)
|
||||
tmpfile, err := ioutil.TempFile("uploads", "tempipa-")
|
||||
if err != nil {
|
||||
renderError(err, "create tmpfile")
|
||||
return
|
||||
}
|
||||
defer os.Remove(tmpfile.Name())
|
||||
|
||||
log.Println("[pkg] create tmpfile", tmpfile.Name())
|
||||
_, err = io.Copy(tmpfile, reader)
|
||||
if err != nil {
|
||||
renderError(err, "read upload file")
|
||||
return
|
||||
}
|
||||
if err := tmpfile.Close(); err != nil {
|
||||
renderError(err, "finish write tmpfile")
|
||||
return
|
||||
}
|
||||
log.Println("[pkg] install ipa")
|
||||
cmd := exec.Command("ideviceinstaller", "--udid", udid, "-i", tmpfile.Name())
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
renderError(errors.New(string(output)), "install ipa")
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"description": "Successfully installed ipa",
|
||||
"value": string(output),
|
||||
})
|
||||
}).Methods("POST")
|
||||
|
||||
rt.HandleFunc("/api/v1/records", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if r.Method == "POST" {
|
||||
os.MkdirAll("./recorddata/20170603-test", 0755)
|
||||
err := launchXrecord("./recorddata/20170603-test/camera.mp4")
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"description": "Launch xrecord failed: " + err.Error(),
|
||||
})
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"description": "Record started",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if xrecordCmd != nil && xrecordCmd.Process != nil {
|
||||
xrecordCmd.Process.Signal(os.Interrupt)
|
||||
}
|
||||
select {
|
||||
case <-GoFunc(xrecordCmd.Wait):
|
||||
case <-time.After(5 * time.Second):
|
||||
xrecordCmd.Process.Kill()
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"description": "xrecord handle Ctrl-C longer than 5 second",
|
||||
})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"description": "Record stopped",
|
||||
})
|
||||
}
|
||||
}).Methods("POST", "DELETE")
|
||||
}
|
||||
|
||||
func mockIOSProvider() {
|
||||
c := connector.New(yosemiteServer, yosemiteGroup, lisPort)
|
||||
go c.KeepOnline()
|
||||
|
||||
device, err := GetDeviceInfo(udid)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
c.AddDevice(device.Udid, device)
|
||||
// c.WriteJSON(map[string]interface{}{
|
||||
// "type": "addDevice",
|
||||
// "data": device,
|
||||
// })
|
||||
rt.HandleFunc("/api/devices/{udid}/remoteConnect", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if r.Method == "POST" {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"description": "notice this is mock data",
|
||||
"remoteConnectUrl": fmt.Sprintf("http://%s:%d/", c.RemoteIp, lisPort),
|
||||
})
|
||||
}
|
||||
if r.Method == "DELETE" {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"description": "Device remote disconnected successfully",
|
||||
})
|
||||
}
|
||||
}).Methods("POST", "DELETE")
|
||||
}
|
||||
|
||||
var xrecordCmd *exec.Cmd
|
||||
|
||||
func launchXrecord(output string) error {
|
||||
rpath, err := exec.LookPath("xrecord")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
xrecordCmd = exec.Command(rpath, "-i", "0x14100000046d082d", "-o", output, "-f")
|
||||
xrecordCmd.Stdout = os.Stdout
|
||||
xrecordCmd.Stderr = os.Stderr
|
||||
return xrecordCmd.Start()
|
||||
}
|
||||
|
||||
func GoFunc(f func() error) chan error {
|
||||
errc := make(chan error)
|
||||
go func() {
|
||||
errc <- f()
|
||||
}()
|
||||
return errc
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type transport struct {
|
||||
http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *transport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
|
||||
// rewrite url
|
||||
if strings.HasPrefix(req.RequestURI, "/origin/") {
|
||||
req.URL.Path = req.RequestURI[len("/origin"):]
|
||||
return t.RoundTripper.RoundTrip(req)
|
||||
}
|
||||
|
||||
// request
|
||||
resp, err = t.RoundTripper.RoundTrip(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// rewrite body
|
||||
if req.URL.Path == "/status" {
|
||||
jsonResp := &statusResp{}
|
||||
err = json.NewDecoder(resp.Body).Decode(jsonResp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.Body.Close()
|
||||
jsonResp.Value["device"] = map[string]interface{}{
|
||||
"udid": udid,
|
||||
"name": udidNames[udid],
|
||||
}
|
||||
data, _ := json.Marshal(jsonResp)
|
||||
// update body and fix length
|
||||
resp.Body = ioutil.NopCloser(bytes.NewReader(data))
|
||||
resp.ContentLength = int64(len(data))
|
||||
resp.Header.Set("Content-Length", strconv.Itoa(len(data)))
|
||||
return resp, nil
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func NewReverseProxyHandlerFunc(targetURL *url.URL) http.HandlerFunc {
|
||||
httpProxy := httputil.NewSingleHostReverseProxy(targetURL)
|
||||
httpProxy.Transport = &transport{http.DefaultTransport}
|
||||
return func(rw http.ResponseWriter, r *http.Request) {
|
||||
httpProxy.ServeHTTP(rw, r)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeProxy struct {
|
||||
}
|
||||
|
||||
func (p *fakeProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
log.Println("FAKE", r.RequestURI)
|
||||
log.Println("P", r.URL.Path)
|
||||
io.WriteString(w, "Fake")
|
||||
}
|
||||
|
||||
func NewAppiumProxyHandlerFunc(targetURL *url.URL) http.HandlerFunc {
|
||||
httpProxy := httputil.NewSingleHostReverseProxy(targetURL)
|
||||
rt := mux.NewRouter()
|
||||
rt.HandleFunc("/wd/hub/sessions", func(w http.ResponseWriter, r *http.Request) {
|
||||
data, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"status": 0,
|
||||
"value": []string{},
|
||||
"sessionId": nil,
|
||||
}, "", " ")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.Write(data)
|
||||
})
|
||||
rt.HandleFunc("/wd/hub/session/{sessionId}/window/current/size", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.Path = strings.Replace(r.URL.Path, "/current/size", "/size", -1)
|
||||
r.URL.Path = r.URL.Path[len("/wd/hub"):]
|
||||
httpProxy.ServeHTTP(w, r)
|
||||
})
|
||||
rt.Handle("/wd/hub/{subpath:.*}", http.StripPrefix("/wd/hub", httpProxy))
|
||||
return rt.ServeHTTP
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,49 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>WDAProxy Index</title>
|
||||
<style>
|
||||
body {
|
||||
padding: 50px 80px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: "Courier New";
|
||||
padding: 10px;
|
||||
border: 1px solid gray;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>WDA Proxy</h1>
|
||||
<div>
|
||||
<ul>
|
||||
<li><a href="/inspector">Inspector</a></li>
|
||||
<li><a href="/status">Status</a></li>
|
||||
<li><a href="/packages">Packages</a></li>
|
||||
<li><a href="/remote">Remote</a></li>
|
||||
</ul>
|
||||
<pre id="status"></pre>
|
||||
</div>
|
||||
</body>
|
||||
<script src="//cdn.jsdelivr.net/jquery/3.1.1/jquery.min.js"></script>
|
||||
<script>
|
||||
var pre = document.getElementById("status");
|
||||
$.ajax({
|
||||
url: "/status",
|
||||
dataType: "json",
|
||||
success: function(ret) {
|
||||
pre.innerHTML = JSON.stringify(ret, null, " ");
|
||||
},
|
||||
error: function(xhr, status, err) {
|
||||
pre.innerHTML = xhr.status + " " + err;
|
||||
pre.style.color = "red";
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>App Manager</title>
|
||||
<style>
|
||||
body {
|
||||
padding: 50px 80px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: "Courier New";
|
||||
padding: 10px;
|
||||
border: 1px solid gray;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Packages</h1>
|
||||
<div id="app">
|
||||
<li v-for="v in pkgs">
|
||||
{{v.name}} <input v-model="v.bundleId">
|
||||
<button :disabled="v.busy" v-on:click="uninstall(v)">
|
||||
Uninstall
|
||||
<span v-if="v.busy">ing...</span>
|
||||
</button>
|
||||
</li>
|
||||
</div>
|
||||
</body>
|
||||
<script src="//cdn.jsdelivr.net/jquery/3.1.1/jquery.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/vue/2.2.1/vue.min.js"></script>
|
||||
<script>
|
||||
var pre = document.getElementById("status");
|
||||
|
||||
new Vue({
|
||||
el: "#app",
|
||||
data: {
|
||||
pkgs: [],
|
||||
},
|
||||
methods: {
|
||||
uninstall: function(pkg) {
|
||||
var self = this;
|
||||
pkg.busy = true;
|
||||
$.ajax({
|
||||
url: "/api/v1/packages/" + pkg.bundleId,
|
||||
dataType: "json",
|
||||
method: "DELETE",
|
||||
success: function(ret) {
|
||||
if (ret.success) {
|
||||
self.pkgs = self.pkgs.filter(function(v) {
|
||||
return v.bundleId != pkg.bundleId;
|
||||
})
|
||||
}
|
||||
alert(ret.description);
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
created: function() {
|
||||
var self = this;
|
||||
$.ajax({
|
||||
url: "/api/v1/packages",
|
||||
dataType: "json",
|
||||
success: function(ret) {
|
||||
if (!ret.success) {
|
||||
alert(ret.description);
|
||||
return;
|
||||
}
|
||||
self.pkgs = ret.value.map(function(v) {
|
||||
v.busy = false;
|
||||
return v;
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,629 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Remote Control</title>
|
||||
<!-- 最新版本的 Bootstrap 核心 CSS 文件 -->
|
||||
<link rel="stylesheet" href="https://cdn.bootcss.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
|
||||
<!-- 可选的 Bootstrap 主题文件(一般不用引入) -->
|
||||
<link rel="stylesheet" href="https://cdn.bootcss.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
|
||||
</head>
|
||||
<style>
|
||||
* {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.color-red {
|
||||
color: red;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"
|
||||
}
|
||||
|
||||
.height100p {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: "Courier New";
|
||||
padding: 10px;
|
||||
border: 1px solid gray;
|
||||
}
|
||||
|
||||
.finger {
|
||||
position: absolute;
|
||||
border-style: solid;
|
||||
border-radius: 50%;
|
||||
border-color: white;
|
||||
border-width: 1mm;
|
||||
width: 6mm;
|
||||
height: 6mm;
|
||||
top: -3mm;
|
||||
left: -3mm;
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
background: red;
|
||||
/*#464646;*/
|
||||
/*background: red;*/
|
||||
display: none;
|
||||
}
|
||||
|
||||
.finger-1 {
|
||||
background-color: blue;
|
||||
}
|
||||
|
||||
.finger.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.finger.longClick {
|
||||
border: 1mm solid rebeccapurple;
|
||||
}
|
||||
|
||||
.screen-img {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
border: 1px solid gray;
|
||||
}
|
||||
|
||||
.screen-wraper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
video::-webkit-media-controls-panel {
|
||||
/* always show video controls */
|
||||
display: flex !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
<div id="app" class="container height100p">
|
||||
<div class="row height100p">
|
||||
<div class="col-sm-6 height100p">
|
||||
<div class="screen-wraper">
|
||||
<span class="finger finger-0" style="transform: translate3d(0, 0, 0)"></span>
|
||||
<span class="finger finger-1"></span>
|
||||
<img id="screen" class="screen-img" ondragstart="return false" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<h3>{{device.name}}</h3>
|
||||
<hr>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">设备详情</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>UDID</td>
|
||||
<td v-text="device.udid"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ScreenSize</td>
|
||||
<td><span v-text="display.width"></span>x<span v-text="display.height"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>SessionId</td>
|
||||
<td>
|
||||
<span v-text="sessionId"></span>
|
||||
<span @click="refreshSessionId" class="glyphicon glyphicon-refresh cursor-pointer"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>RefreshCount</td>
|
||||
<td><span v-text="refreshCount"></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-default" @click="home()">
|
||||
<span class="glyphicon glyphicon-home"></span> Home
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" v-model="inputText" placeholder="Input text ..." @keyup.enter="sendText(inputText+'\n')">
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-default" type="button" @click="sendText(inputText)">SendText</button>
|
||||
<button class="btn btn-default" type="button" @click="clearText(30)">Clear</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /input-group -->
|
||||
<div style="display: none">
|
||||
<form class="form-inline">
|
||||
<div class="form-group">
|
||||
<button type="button" class="btn btn-sm" @click="toggleRecord">
|
||||
<span v-if="!record.running" class="color-red glyphicon glyphicon-record"></span>
|
||||
<span v-if="record.running" class="color-red glyphicon glyphicon-stop"></span>
|
||||
录制 <span v-text="record.prompt"></span> <span v-if="record.running">{{record.duration|formatDuration}}</span>
|
||||
</button>
|
||||
<input type="text" class="form-control input-sm" placeholder="Notes 备注">
|
||||
</div>
|
||||
</form>
|
||||
<hr>
|
||||
<div class="list-group">
|
||||
<button v-for="r in records" type="button" @click="play(r)" style="height: 34px; padding: 7px 15px;" class="list-group-item">{{r.name}} camera.mp4 <span class="badge">2:12</span></button>
|
||||
</div>
|
||||
<div>
|
||||
<video width="320" height="240" controls>
|
||||
<source src="" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div id="chart_div"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--<script src="//cdn.jsdelivr.net/jquery/3.1.1/jquery.min.js"></script>-->
|
||||
<script src="//cdn.jsdelivr.net/g/[email protected],[email protected]"></script>
|
||||
<script src="//cdn.bootcss.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
|
||||
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
|
||||
<script>
|
||||
google.charts.load('current', {
|
||||
packages: ['corechart', 'line']
|
||||
});
|
||||
google.charts.setOnLoadCallback(drawCrosshairs);
|
||||
|
||||
function drawCrosshairs() {
|
||||
var data = new google.visualization.DataTable();
|
||||
data.addColumn('number', 'X');
|
||||
data.addColumn('number', 'Dogs');
|
||||
data.addColumn('number', 'Cats');
|
||||
|
||||
data.addRows([
|
||||
[0, 0, 0],
|
||||
[1, 10, 5],
|
||||
[2, 23, 15],
|
||||
[3, 17, 9],
|
||||
[4, 18, 10],
|
||||
[5, 9, 5],
|
||||
[6, 11, 3],
|
||||
[7, 27, 19],
|
||||
[8, 33, 25],
|
||||
[9, 40, 32],
|
||||
[11, 35, 27],
|
||||
[12, 30, 22],
|
||||
[13, 40, 32],
|
||||
[14, 42, 34],
|
||||
[15, 47, 39],
|
||||
[16, 44, 36],
|
||||
[17, 48, 40],
|
||||
[18, 52, 44],
|
||||
[19, 54, 46],
|
||||
[20, 42, 34],
|
||||
[21, 55, 47],
|
||||
[22, 56, 48],
|
||||
[23, 57, 49],
|
||||
[24, 60, 52],
|
||||
[25, 50, 42],
|
||||
[26, 52, 44],
|
||||
[27, 51, 43],
|
||||
[28, 49, 41],
|
||||
[29, 53, 45],
|
||||
[30, 55, 47],
|
||||
[31, 60, 52],
|
||||
[32, 61, 53],
|
||||
[33, 59, 51],
|
||||
[34, 62, 54],
|
||||
[35, 65, 57],
|
||||
[36, 62, 54],
|
||||
[37, 58, 50],
|
||||
[38, 55, 47],
|
||||
[39, 61, 53],
|
||||
[40, 64, 56],
|
||||
[41, 65, 57],
|
||||
[42, 63, 55],
|
||||
[43, 66, 58],
|
||||
]);
|
||||
|
||||
var options = {
|
||||
hAxis: {
|
||||
title: 'Time'
|
||||
},
|
||||
vAxis: {
|
||||
title: 'Popularity'
|
||||
},
|
||||
colors: ['#a52714', '#097138'],
|
||||
crosshair: {
|
||||
color: '#000',
|
||||
trigger: 'selection'
|
||||
}
|
||||
};
|
||||
|
||||
var chart = new google.visualization.LineChart(document.getElementById('chart_div'));
|
||||
|
||||
chart.draw(data, options);
|
||||
chart.setSelection([{
|
||||
row: 38,
|
||||
column: 1
|
||||
}]);
|
||||
window.chart = chart;
|
||||
|
||||
chart.clearChart()
|
||||
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
var app = new Vue({
|
||||
el: "#app",
|
||||
// delimiters: ["[[", "]]"],
|
||||
data: {
|
||||
device: {
|
||||
name: '',
|
||||
},
|
||||
display: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
naturalWidth: 0,
|
||||
naturalHeight: 0,
|
||||
},
|
||||
refreshCount: 0,
|
||||
sessionId: null,
|
||||
pageHidden: false,
|
||||
inputText: '',
|
||||
wsAdmin: null,
|
||||
record: {
|
||||
running: false,
|
||||
startTime: 0,
|
||||
ticker: null,
|
||||
duration: 0,
|
||||
prompt: '',
|
||||
},
|
||||
records: [{
|
||||
name: "20170603-test",
|
||||
}]
|
||||
},
|
||||
filters: {
|
||||
formatDuration: function(value) {
|
||||
function pad2(number) {
|
||||
return (number < 10 ? '0' : '') + number
|
||||
}
|
||||
var totalSeconds = Math.floor(value / 1000); //"00:00:00"
|
||||
var mms = value % 1000;
|
||||
var ms = totalSeconds % 60;
|
||||
var mm = (totalSeconds - ms) / 60
|
||||
return "" + pad2(mm) + " : " + pad2(ms);
|
||||
}
|
||||
},
|
||||
mounted: function() {
|
||||
var self = this;
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
function() {
|
||||
self.pageHidden = document.hidden;
|
||||
}, false
|
||||
)
|
||||
this.refreshScreen();
|
||||
this.initTouchListener();
|
||||
this.initScreenSize();
|
||||
var self = this;
|
||||
$("#screen")[0].onload = function(e) {
|
||||
var nw = e.target.naturalWidth;
|
||||
var nh = e.target.naturalHeight;
|
||||
if (nw && nh && self.display.naturalHeight != nh) {
|
||||
self.display.naturalWidth = nw;
|
||||
self.display.naturalHeight = nh;
|
||||
self.initScreenSize();
|
||||
console.log("Detect screen rotate");
|
||||
}
|
||||
}.bind(this);
|
||||
},
|
||||
methods: {
|
||||
refreshSessionId: function() {
|
||||
$.ajax({
|
||||
url: "/status",
|
||||
})
|
||||
.then(function(ret) {
|
||||
this.sessionId = ret.sessionId;
|
||||
}.bind(this))
|
||||
},
|
||||
toggleRecord: function() {
|
||||
var self = this;
|
||||
if (!this.record.running) {
|
||||
self.record.prompt = "启动中"
|
||||
$.ajax({
|
||||
url: "/api/v1/records",
|
||||
method: "POST",
|
||||
})
|
||||
.done(function(ret) {
|
||||
self.record.running = true;
|
||||
self.record.prompt = "";
|
||||
self.record.startTime = new Date().getTime();
|
||||
self.record.ticker = setInterval(function() {
|
||||
self.record.duration = new Date().getTime() - self.record.startTime;
|
||||
}.bind(self), 50);
|
||||
})
|
||||
.fail(function(ret) {
|
||||
self.record.prompt = "录制启动失败,原因需要查看后台日志";
|
||||
})
|
||||
} else {
|
||||
self.record.prompt = "结束中"
|
||||
clearInterval(this.record.ticker)
|
||||
$.ajax({
|
||||
url: "/api/v1/records",
|
||||
method: "DELETE",
|
||||
})
|
||||
.done(function(ret) {
|
||||
self.record.running = false;
|
||||
console.log(ret);
|
||||
self.record.prompt = "";
|
||||
var video = document.getElementsByTagName("video")[0];
|
||||
video.src = "/recorddata/20170603-test/camera.mp4";
|
||||
video.load();
|
||||
video.play();
|
||||
})
|
||||
.fail(function(ret) {
|
||||
self.record.prompt = "结束异常";
|
||||
})
|
||||
}
|
||||
},
|
||||
play: function(r) {
|
||||
console.log(r.name);
|
||||
var video = document.getElementsByTagName("video")[0];
|
||||
video.src = "/recorddata/" + r.name + "/camera.mp4";
|
||||
video.load();
|
||||
video.play();
|
||||
},
|
||||
tap: function(x, y) {
|
||||
var self = this;
|
||||
return $.ajax({
|
||||
url: "/session/" + self.sessionId + "/wda/tap/0",
|
||||
method: "POST",
|
||||
data: JSON.stringify({
|
||||
x: x,
|
||||
y: y
|
||||
}),
|
||||
}).then(function(ret) {
|
||||
if (ret.status !== 0) {
|
||||
console.log(ret.value);
|
||||
} else {
|
||||
return "Success";
|
||||
}
|
||||
})
|
||||
},
|
||||
tapHold: function(x, y, duration) {
|
||||
var self = this;
|
||||
return $.ajax({
|
||||
url: "/session/" + self.sessionId + "/wda/touchAndHold",
|
||||
method: "POST",
|
||||
data: JSON.stringify({
|
||||
x: x,
|
||||
y: y,
|
||||
duration: duration / 1000, // unit second
|
||||
}),
|
||||
}).then(function(ret) {
|
||||
if (ret.status !== 0) {
|
||||
console.log(ret.value);
|
||||
} else {
|
||||
return "Success";
|
||||
}
|
||||
})
|
||||
},
|
||||
swipe: function(fromX, fromY, toX, toY) {
|
||||
var self = this;
|
||||
return $.ajax({
|
||||
url: "/session/" + self.sessionId + "/wda/dragfromtoforduration",
|
||||
method: "POST",
|
||||
data: JSON.stringify({
|
||||
fromX: fromX,
|
||||
fromY: fromY,
|
||||
toX: toX,
|
||||
toY: toY,
|
||||
duration: 0,
|
||||
})
|
||||
})
|
||||
},
|
||||
home: function() {
|
||||
return $.ajax({
|
||||
url: "/wda/homescreen",
|
||||
method: "POST",
|
||||
})
|
||||
.then(function(ret) {
|
||||
console.log(ret);
|
||||
})
|
||||
},
|
||||
clearText: function(n) {
|
||||
this.sendText("\b".repeat(n || 30))
|
||||
},
|
||||
sendText: function(text) {
|
||||
return $.ajax({
|
||||
url: "/session/" + this.sessionId + "/wda/keys",
|
||||
method: "POST",
|
||||
data: JSON.stringify({
|
||||
value: text.split('')
|
||||
})
|
||||
})
|
||||
.then(function(ret) {
|
||||
console.log(ret);
|
||||
})
|
||||
},
|
||||
refreshScreen: function() {
|
||||
if (this.pageHidden) {
|
||||
console.log("Hidden");
|
||||
setTimeout(this.refreshScreen, 200);
|
||||
return;
|
||||
}
|
||||
return $.getJSON("/screenshot")
|
||||
.done(function(ret) {
|
||||
var imgSrc = "data:image/png;base64," + ret.value;
|
||||
$("#screen").attr("src", imgSrc);
|
||||
this.refreshCount += 1;
|
||||
setTimeout(this.refreshScreen, 200);
|
||||
}.bind(this))
|
||||
.fail(function(ret) {
|
||||
console.log("load screen failed", ret)
|
||||
// alert("Load screen failed");
|
||||
})
|
||||
},
|
||||
initScreenSize: function() {
|
||||
$.ajax({
|
||||
url: "/status",
|
||||
})
|
||||
.then(function(ret) {
|
||||
this.sessionId = ret.sessionId;
|
||||
Object.assign(this.device, ret.value.device);
|
||||
return $.ajax({
|
||||
url: "/session/" + ret.sessionId + "/window/size",
|
||||
})
|
||||
}.bind(this))
|
||||
.then(function(ret) {
|
||||
this.display.width = ret.value.width;
|
||||
this.display.height = ret.value.height;
|
||||
}.bind(this))
|
||||
},
|
||||
initTouchListener: function() {
|
||||
var self = this;
|
||||
var element = $("#screen")[0];
|
||||
var screen = {
|
||||
bounds: {},
|
||||
firstPosition: {},
|
||||
secondPosition: {},
|
||||
beginTime: null,
|
||||
}
|
||||
var _longClickTimeout = 800;
|
||||
|
||||
function calculateBounds() {
|
||||
var el = element;
|
||||
screen.bounds.w = el.offsetWidth
|
||||
screen.bounds.h = el.offsetHeight
|
||||
screen.bounds.x = 0
|
||||
screen.bounds.y = 0
|
||||
|
||||
while (el.offsetParent) {
|
||||
screen.bounds.x += el.offsetLeft
|
||||
screen.bounds.y += el.offsetTop
|
||||
el = el.offsetParent
|
||||
}
|
||||
}
|
||||
|
||||
function deactiveFinger(index) {
|
||||
$(".finger-" + index).removeClass("active").removeClass("longClick")
|
||||
}
|
||||
|
||||
function convertPosition(event) {
|
||||
var e = event;
|
||||
if (e.originalEvent) {
|
||||
e = e.originalEvent
|
||||
}
|
||||
// Ignore mouse right click
|
||||
if (e.which === 3) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault()
|
||||
calculateBounds()
|
||||
var x = e.pageX - screen.bounds.x
|
||||
var y = e.pageY - screen.bounds.y
|
||||
return {
|
||||
e: e,
|
||||
x: Math.floor(x / element.clientHeight * self.display.height),
|
||||
y: Math.floor(y / element.clientHeight * self.display.height),
|
||||
pageX: x,
|
||||
pageY: y,
|
||||
}
|
||||
}
|
||||
|
||||
function stopMousing() {
|
||||
element.removeEventListener('mousemove', mouseMoveListener);
|
||||
document.removeEventListener('mouseup', mouseUpListener);
|
||||
}
|
||||
|
||||
function mouseUpListener(event) {
|
||||
var p = convertPosition(event);
|
||||
stopMousing()
|
||||
|
||||
var first = screen.firstPosition,
|
||||
second = screen.secondPosition;
|
||||
|
||||
screen.secondPosition = {};
|
||||
element.style.cursor = 'not-allowed'
|
||||
clearTimeout(screen.longClickKey);
|
||||
var defer;
|
||||
if (second && (second.x || second.y)) {
|
||||
console.log("Swipe", first, second);
|
||||
defer = self.swipe(first.x, first.y, second.x, second.y);
|
||||
} else {
|
||||
var holdTime = new Date().getTime() - screen.beginTime;
|
||||
if (holdTime > _longClickTimeout) {
|
||||
console.log("LongTap", first);
|
||||
defer = self.tapHold(first.x, first.y, holdTime)
|
||||
} else {
|
||||
console.log("Tap", first);
|
||||
defer = self.tap(first.x, first.y)
|
||||
}
|
||||
}
|
||||
defer.then(function() {
|
||||
console.log("Release operation")
|
||||
deactiveFinger(0);
|
||||
deactiveFinger(1);
|
||||
element.style.cursor = 'auto'
|
||||
})
|
||||
}
|
||||
|
||||
function mouseMoveListener(event) {
|
||||
var p = convertPosition(event);
|
||||
var first = screen.firstPosition;
|
||||
var distance = Math.sqrt((first.x - p.x) * (first.x - p.x) + (first.y - p.y) * (first.y - p.y));
|
||||
if (distance > 10) {
|
||||
screen.secondPosition = {
|
||||
x: p.x,
|
||||
y: p.y
|
||||
};
|
||||
console.log(p);
|
||||
$(".finger-1")
|
||||
.addClass("active")
|
||||
.css("transform", 'translate3d(' + p.pageX + 'px,' + p.pageY + 'px,0)')
|
||||
} else {
|
||||
deactiveFinger("1");
|
||||
screen.secondPosition = {};
|
||||
}
|
||||
}
|
||||
|
||||
function mouseDownListener(event) {
|
||||
if (element.style.cursor == 'not-allowed') {
|
||||
return;
|
||||
}
|
||||
var p = convertPosition(event);
|
||||
var index = 0;
|
||||
$(".finger-" + index)
|
||||
.addClass("active")
|
||||
.css("transform", 'translate3d(' + p.pageX + 'px,' + p.pageY + 'px,0)')
|
||||
|
||||
screen.longClickKey = setTimeout(function() {
|
||||
$(".finger-0").addClass("longClick");
|
||||
}, _longClickTimeout)
|
||||
|
||||
screen.beginTime = new Date().getTime();
|
||||
screen.firstPosition = {
|
||||
x: p.x,
|
||||
y: p.y
|
||||
}
|
||||
|
||||
element.addEventListener('mousemove', mouseMoveListener);
|
||||
document.addEventListener('mouseup', mouseUpListener);
|
||||
}
|
||||
element.addEventListener("mousedown", mouseDownListener);
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
// +build !vfs
|
||||
//go:generate go run assets_generate.go
|
||||
|
||||
package web
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Assets contains project assets.
|
||||
var Assets http.FileSystem = http.Dir("web/assets")
|
||||
@@ -0,0 +1,23 @@
|
||||
// +build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/shurcooL/vfsgen"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var fs http.FileSystem = http.Dir("assets")
|
||||
|
||||
err := vfsgen.Generate(fs, vfsgen.Options{
|
||||
PackageName: "web",
|
||||
BuildTags: "vfs",
|
||||
VariableName: "Assets",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user