This commit is contained in:
PenguinRobert1
2024-01-05 14:54:41 +00:00
parent 550e57fb5b
commit 38d3dcc02c
22 changed files with 1962 additions and 0 deletions
@@ -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
[![Powered By: GoReleaser](https://img.shields.io/badge/powered%20by-goreleaser-green.svg?style=flat-square)](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)
}
}