Full Code of openatx/atx-server for AI

master e76ec88e1984 cached
44 files
184.2 KB
54.9k tokens
114 symbols
1 requests
Download .txt
Repository: openatx/atx-server
Branch: master
Commit: e76ec88e1984
Files: 44
Total size: 184.2 KB

Directory structure:
gitextract_my193frc/

├── .fsw.yml
├── .github/
│   └── stale.yml
├── .gitignore
├── .goreleaser.yml
├── .travis.yml
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── assets/
│   ├── bootstrap-tabs.css
│   ├── common.js
│   ├── libs/
│   │   ├── jquery-tiny-pubsub.js
│   │   └── notify.js
│   ├── logcat.css
│   ├── remote.css
│   ├── remote.js
│   ├── style.css
│   └── vue-components.js
├── database.go
├── database_test.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── heartbeat/
│   └── heartbeat.go
├── hostsmanager.go
├── httplog.go
├── httpserver.go
├── main.go
├── proto/
│   └── message.go
├── rethinkdb-test/
│   └── main.go
├── scripts/
│   ├── img2video/
│   │   ├── main.py
│   │   ├── requirements.txt
│   │   ├── static/
│   │   │   ├── .gitkeep
│   │   │   └── videos/
│   │   │       └── .gitkeep
│   │   └── templates/
│   │       ├── index.html
│   │       └── videos.html
│   ├── levenshtein.py
│   └── update_coverage_umeng.py
├── templates/
│   ├── edit.html
│   ├── index.html
│   ├── property.html
│   ├── providers.html
│   └── remote.html
└── utils.go

================================================
FILE CONTENTS
================================================

================================================
FILE: .fsw.yml
================================================
desc: Auto generated by fswatch [atx-server]
triggers:
- name: ""
  pattens:
  - '**/*.go'
  - '**/*.c'
  - '**/*.py'
  env:
    DEBUG: "1"
  cmd: go build && ./atx-server
  shell: true
  delay: 100ms
  stop_timeout: 500ms
  signal: KILL
  kill_signal: ""
watch_paths:
- .
watch_depth: 0


================================================
FILE: .github/stale.yml
================================================
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 30
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 2
# Issues with these labels will never be considered stale
exemptLabels:
  - pinned
  - security
  - feature-request
# Label to use when marking an issue as stale
staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
  This issue has been automatically marked as stale because it has not had
  recent activity. It will be closed if no further activity occurs. Thank you
  for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

================================================
FILE: .gitignore
================================================
# Binaries for programs and plugins
*.exe
*.dll
*.so
*.dylib
*.mp4

# Test binary, build with `go test -c`
*.test

# Output of the go coverage tool, specifically when used with LiteIDE
*.out

# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
.glide/
*.exe
__pycache__

# vim
*.un~

# vscode
settings.json

*.mp4.json
/data

atx-server


================================================
FILE: .goreleaser.yml
================================================
builds:
  -
    goos:
      - linux
      - windows
      - darwin
    goarch:
      - amd64
      - 386
    flags: -tags vfs
    hooks:
      pre: go generate


================================================
FILE: .travis.yml
================================================
---
language: go
sudo: false
services:
  - docker
go:
  - "1.11"
env:
  - GO111MODULE=on

install: true

script:
  - go test -v
  - docker build .

after_success:
  - test -n "$TRAVIS_TAG" && curl -sL https://git.io/goreleaser | bash


================================================
FILE: Dockerfile
================================================
FROM golang:1.11
RUN mkdir /app
ADD . /app/
WORKDIR /app
RUN go build

FROM debian:stretch
WORKDIR /root/
COPY --from=0 /app/atx-server .
COPY --from=0 /app/templates ./templates
ENTRYPOINT ./atx-server --port 8000


================================================
FILE: LICENSE
================================================
The MIT License (MIT)
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.



================================================
FILE: Makefile
================================================
dc_cmd=docker-compose -p atx
atx_server_addr=192.168.147.230:8000
# --serial $SERIAL

up:
	$(dc_cmd) up -d --build

down:
	$(dc_cmd) down

shell:
	docker exec -it atxserver bash

ps:
	$(dc_cmd) ps

log:
	$(dc_cmd) logs -f atxserver

init:
	python -m uiautomator2 init --server $(atx_server_addr)

================================================
FILE: README.md
================================================
# Deprecated
Please use <https://github.com/openatx/atxserver2> instead.

-----------------

# ATX-SERVER
[![GitHub stars](https://img.shields.io/badge/govendor-vendor-blue.svg)](https://github.com/kardianos/govendor)
[![Build Status](https://travis-ci.org/openatx/atx-server.svg?branch=master)](https://travis-ci.org/openatx/atx-server)

Manage batch of atx-agents

# Testerhome上相关文章
- [安卓设备集群管理 atx-server](https://testerhome.com/topics/11546) By [codeskyblue](https://testerhome.com/codeskyblue)
- [atx 安卓集群管理 安装运行及自动化的实践](https://testerhome.com/topics/11588) By [cynic]: (https://testerhome.com/cynic)

# Install
重要:需要有go语言的基础,知道该如何编译一个go的程序

1. Install and start [rethinkdb](https://rethinkdb.com)
2. Install [go](https://golang.org)

Compile with go

```bash
$ go get -v github.com/openatx/atx-server
$ cd $GOPATH/src/github.com/openatx/atx-server
$ go build
```

# Usage
launch `rethinkdb`

```bash
$ rethinkdb
Running rethinkdb 2.3.6 (CLANG 8.1.0 (clang-802.0.42))...
Running on Darwin 16.6.0 x86_64
...
```

launch `atx-server`

```
./atx-server --port 8000
```

Install `atx-agent` using [uiautomator2](https://github.com/openatx/uiautomator2) into android phone. your android phone and server running `atx-server` should in the same intranet.

Suppose server running `atx-server` got the ip `10.0.1.1`, listen port `8000`. Do the following command

```bash
$ pip install -U --pre uiautomator2
$ python -m uiautomator2 init 10.0.1.1:8000
```

open browser <http://localhost:8000>, you should see the device listed on the web.

## Advanced usage
### Set up <https://www.dingtalk.com> notification.
1. Usage command flag

    ```
    ./atx-server --ding-token 13gb4db7c276d22e84f788fa693b729d53218b8e07d6ede43de79360c962 --port 8080
    ```

2. Set up env var

    ```
    export DING_TOKEN="13gb4db7c276d22e84f788fa693b729d53218b8e07d6ede43de79360c962"
    ./atx-server --port 8080
    ```

# APIs
## /list 接口

其中udid是通过hwaddr, model, serial组合生成的

```bash
$ curl $SERVER_URL/list
[
    {
        "udid": "741AEDR42P6YM-2c:57:31:4b:40:74-M2_E",
        "ip": "10.240.218.20",
        "present": true,
        "ready": true,
        "using": true,
        "provider": null,
        "serial": "741AEDR42P6YM",
        "brand": "Meizu",
        "model": "M2 E",
        "hwaddr": "2c:57:31:4b:40:74",
        "agentVersion": "0.1.1",
        "battery": {},
        "display": {
            "width": 1080,
            "height": 1920
        }
    }
]
```

There are some fields you need pay attention.

- `present` means device is online
- `ready` is the thumb :thumbsup: you can see and edit in the web
- `using` means if device is using by someone

`provider` is a special field, if device is plugged into some machine which running [u2init](https://github.com/openatx/u2init), the bellow info can be found in device info.

```json
"provider": {
    "id": "33576428",
    "ip": "10.0.0.1",
    "port": 10000,
    "present": true  # provider online of not
}
```

if `provider` is `null` it means device is not plugged-in.

## /devices/{query}/info
```bash
$ curl $SERVER_URL/devices/ip:10.0.0.1/info
# or
$ curl $SERVER_URL/devices/$UDID/info
```

返回值同/list的的单个结果,这里就不写了。

## /version
`atx-agent`通过检测该接口确定是否升级

```bash
$ curl /version
{
    "server": "dev",
    "atx-agent": "0.0.7"
}
```

## 执行shell命令
```bash
$ curl -X POST -F command="pwd" $SERVER_URL/devices/{query}/shell
{
    "output": "/"
}
```

## 设备管理
占用、释放

状态码 成功200,失败403

### 占用设备
```bash
$ curl -X POST $SERVER_URL/devices/{query}/reserved
Success
```

### 释放设备
状态码 成功200,失败403

```bash
$ curl -X DELETE $SERVER_URL/devices/{query}/reserved
Release success
```

随机占用一台设备

```bash
$ curl -X POST $SERVER_URL/devices/:random/reserved
Success
```

## Communication between provider(u2init) and server(atx-server)
Provider send POST to Server
**heartbeat info** to let server known provider is online. It is also need to send the same data to Server in 15s or the Provider will be marked offline.

```bash
$ curl -X POST -F id=$PROVIDER_ID -F port=11000 $SERVER_URL/provider/heartbeat
```

You may need to add ip field if provider and server is not in the same network

```bash
$ PROVIDER_IP=10.0.0.1 # change to your provider ip
$ PROVIDER_ID=ccdd11ff # change to your provider id
$ curl -X POST \
    -F ip=$PROVIDER_IP \
    -F id=$PROVIDER_ID \
    -F port=11000 \
    $SERVER_URL/provider/heartbeat
```

Server response status 200 indicate success, or 400 and else means failure

Send using bellow command when there is device plugged-in

```bash
$ DEVICE_UDID="3578298f-b4:0b:44:e6:1f:90-OD103" # change to your device udid
$ DATA="{\"status\": \"online\", \"udid\": \"$DEVICE_UDID\"}"
$ curl -X POST \
    -F id=$PROVIDER_ID \
    -F port=11000 \
    -F data="$DATA"  $SERVER_URL/provider/heartbeat
```

## Comminication between atx-agent and atx-server
It is complicated. Hard to write.

# Docker
`atx-server` is dockerized (based on `golang` image) and depends on the official `rethinkdb` container. To build and run all services, use:
```bash
docker-compuse up --build
```
`atx-server` can be accessed from `localhost:8000` and `rethinkdb` web console is available at `localhost:8001`, both specified in the compose file.
`rethinkdb` data is stored at `$PWD/data` (host volume). 

## References and some good resources
- Golang library for rethinkdb [gorethink](https://github.com/GoRethink/gorethink)
- [美团点评云真机平台实践](https://tech.meituan.com/cloud_phone.html)
- [腾讯TMQ-远程移动测试平台对比分析](https://blog.csdn.net/TMQ1225/article/details/52369171)
- [藏经阁-iOS多机远程控制技术](http://www.sohu.com/a/240584209_744135)

# LICENSE
[MIT](LICENSE)


================================================
FILE: assets/bootstrap-tabs.css
================================================
/**
  Make better bootstrap tabs
  Thanks to https://bootsnipp.com/snippets/featured/material-design-tab-style
*/

.nav-tabs {
  border-bottom: 2px solid #DDD;
}

.nav-tabs>li.active>a,
.nav-tabs>li.active>a:focus,
.nav-tabs>li.active>a:hover {
  border-width: 0;
}

.nav-tabs>li>a {
  border: none;
  color: #666;
}

.nav-tabs>li.active>a,
.nav-tabs>li>a:hover {
  border: none;
  color: #4285F4 !important;
  background: transparent;
}

.nav-tabs>li>a::after {
  content: "";
  background: #4285F4;
  height: 2px;
  position: absolute;
  width: 100%;
  left: 0px;
  bottom: -1px;
  transition: all 250ms ease 0s;
  transform: scale(0);
}

.nav-tabs>li.active>a::after,
.nav-tabs>li:hover>a::after {
  transform: scale(1);
}

.tab-nav>li>a::after {
  background: #21527d none repeat scroll 0% 0%;
  color: #fff;
}

.tab-pane {
  padding: 15px 0;
}

.tab-content {
  padding: 20px;
  padding-bottom: 0px;
  overflow: auto;
}

.card {
  background: #FFF none repeat scroll 0% 0%;
  box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.3);
  /* margin-bottom: 30px; */
  /* add */
  display: flex;
  flex: 1;
  flex-direction: column;
}

================================================
FILE: assets/common.js
================================================
// Copies a string to the clipboard. Must be called from within an 
// event handler such as click. May return false if it failed, but
// this is not always possible. Browser support for Chrome 43+, 
// Firefox 42+, Safari 10+, Edge and IE 10+.
// IE: The clipboard feature may be disabled by an administrator. By
// default a prompt is shown the first time the clipboard is 
// used (per session).
function copyToClipboard(text) {
  if (window.clipboardData && window.clipboardData.setData) {
    // IE specific code path to prevent textarea being shown while dialog is visible.
    return clipboardData.setData("Text", text);

  } else if (document.queryCommandSupported && document.queryCommandSupported("copy")) {
    var textarea = document.createElement("textarea");
    textarea.textContent = text;
    textarea.style.position = "fixed"; // Prevent scrolling to bottom of page in MS Edge.
    document.body.appendChild(textarea);
    textarea.select();
    try {
      return document.execCommand("copy"); // Security exception may be thrown by some browsers.
    } catch (ex) {
      console.warn("Copy to clipboard failed.", ex);
      return false;
    } finally {
      document.body.removeChild(textarea);
    }
  }
}

/* Image Pool */
function ImagePool(size) {
  this.size = size
  this.images = []
  this.counter = 0
}

ImagePool.prototype.next = function() {
  if (this.images.length < this.size) {
    var image = new Image()
    this.images.push(image)
    return image
  } else {
    if (this.counter >= this.size) {
      // Reset for unlikely but theoretically possible overflow.
      this.counter = 0
    }
  }

  return this.images[this.counter++ % this.size]
}

// convert to blob data
function b64toBlob(b64Data, contentType, sliceSize) {
  contentType = contentType || '';
  sliceSize = sliceSize || 512;

  var byteCharacters = atob(b64Data);
  var byteArrays = [];

  for (var offset = 0; offset < byteCharacters.length; offset += sliceSize) {
    var slice = byteCharacters.slice(offset, offset + sliceSize);

    var byteNumbers = new Array(slice.length);
    for (var i = 0; i < slice.length; i++) {
      byteNumbers[i] = slice.charCodeAt(i);
    }

    var byteArray = new Uint8Array(byteNumbers);
    byteArrays.push(byteArray);
  }

  return new Blob(byteArrays, {
    type: contentType
  });
}

var MiniTouch = {
  createNew: function(ws) {
    // ws: Websocket connection communication with minitouch
    var control = {}

    function sendJSON(obj) {
      ws.send(JSON.stringify(obj))
    }

    // control.coords = function(w, h, x, y, rotation) {
    //   console.log(w, h, x, y, rotation)
    //   return {
    //     xP: x / w,
    //     yP: y / h,
    //   }
    // };

    control.touchDown = function(index, xP, yP, pressure) {
      sendJSON({
        operation: 'd',
        index: index,
        pressure: pressure,
        xP: xP,
        yP: yP,
      })
    };

    control.touchWait = function(mseconds) {
      sendJSON({ operation: 'w', milliseconds: mseconds })
    }

    control.touchCommit = function() {
      sendJSON({ operation: 'c' })
    };

    control.touchMove = function(index, xP, yP, pressure) {
      sendJSON({
        operation: 'm',
        index: index,
        pressure: pressure,
        xP: xP,
        yP: yP,
      })
    };

    control.touchUp = function(index) {
      sendJSON({ operation: 'u', index: index })
    };

    return control;
  }
}

/**
 * Rotation affects the screen as follows:
 *
 *                   0deg
 *                 |------|
 *                 | MENU |
 *                 |------|
 *            -->  |      |  --|
 *            |    |      |    v
 *                 |      |
 *                 |      |
 *                 |------|
 *        |----|-|          |-|----|
 *        |    |M|          | |    |
 *        |    |E|          | |    |
 *  90deg |    |N|          |U|    | 270deg
 *        |    |U|          |N|    |
 *        |    | |          |E|    |
 *        |    | |          |M|    |
 *        |----|-|          |-|----|
 *                 |------|
 *            ^    |      |    |
 *            |--  |      |  <--
 *                 |      |
 *                 |      |
 *                 |------|
 *                 | UNEM |
 *                 |------|
 *                  180deg
 *
 * Which leads to the following mapping:
 *
 * |--------------|------|---------|---------|---------|
 * |              | 0deg |  90deg  |  180deg |  270deg |
 * |--------------|------|---------|---------|---------|
 * | CSS rotate() | 0deg | -90deg  | -180deg |  90deg  |
 * | bounding w   |  w   |    h    |    w    |    h    |
 * | bounding h   |  h   |    w    |    h    |    w    |
 * | pos x        |  x   |   h-y   |   w-x   |    y    |
 * | pos y        |  y   |    x    |   h-y   |   h-x   |
 * |--------------|------|---------|---------|---------|
 */
function coords(boundingW, boundingH, relX, relY, rotation) {
  var w, h, x, y;

  switch (rotation) {
    case 0:
      w = boundingW
      h = boundingH
      x = relX
      y = relY
      break
    case 90:
      w = boundingH
      h = boundingW
      x = boundingH - relY
      y = relX
      break
    case 180:
      w = boundingW
      h = boundingH
      x = boundingW - relX
      y = boundingH - relY
      break
    case 270:
      w = boundingH
      h = boundingW
      x = relY
      y = boundingW - relX
      break
  }

  return {
    xP: x / w,
    yP: y / h,
  }
}

/* accepts parameters
 * h  Object = {h:x, s:y, v:z}
 * OR 
 * h, s, v
 * This code expects 0 <= h, s, v <= 1
 * The returned 0 <= r, g, b <= 255 are rounded to the nearest Integer
 */
function HSVtoRGB(h, s, v) {
  var r, g, b, i, f, p, q, t;
  if (arguments.length === 1) {
    s = h.s, v = h.v, h = h.h;
  }
  i = Math.floor(h * 6);
  f = h * 6 - i;
  p = v * (1 - s);
  q = v * (1 - f * s);
  t = v * (1 - (1 - f) * s);
  switch (i % 6) {
    case 0:
      r = v, g = t, b = p;
      break;
    case 1:
      r = q, g = v, b = p;
      break;
    case 2:
      r = p, g = v, b = t;
      break;
    case 3:
      r = p, g = q, b = v;
      break;
    case 4:
      r = t, g = p, b = v;
      break;
    case 5:
      r = v, g = p, b = q;
      break;
  }
  return [
    Math.round(r * 255),
    Math.round(g * 255),
    Math.round(b * 255)
  ]
}

function getRandomRgb(brightness) {
  var rgb = HSVtoRGB(Math.random(), Math.random(), 0.8);
  return 'rgb(' + rgb.join(",") + ")";
}

================================================
FILE: assets/libs/jquery-tiny-pubsub.js
================================================
/*! Tiny Pub/Sub - v0.7.0 - 2013-01-29
* https://github.com/cowboy/jquery-tiny-pubsub
* Copyright (c) 2013 "Cowboy" Ben Alman; Licensed MIT 
*/
(function ($) {
    var o = $({});

    $.subscribe = function () {
        o.on.apply(o, arguments);
    };

    $.unsubscribe = function () {
        o.off.apply(o, arguments);
    };

    $.publish = function () {
        o.trigger.apply(o, arguments);
    };
}(jQuery));

================================================
FILE: assets/libs/notify.js
================================================
/* Notify.js - http://notifyjs.com/ Copyright (c) 2015 MIT */
(function (factory) {
	// UMD start
	// https://github.com/umdjs/umd/blob/master/jqueryPluginCommonjs.js
	if (typeof define === 'function' && define.amd) {
		// AMD. Register as an anonymous module.
		define(['jquery'], factory);
	} else if (typeof module === 'object' && module.exports) {
		// Node/CommonJS
		module.exports = function( root, jQuery ) {
			if ( jQuery === undefined ) {
				// require('jQuery') returns a factory that requires window to
				// build a jQuery instance, we normalize how we use modules
				// that require this pattern but the window provided is a noop
				// if it's defined (how jquery works)
				if ( typeof window !== 'undefined' ) {
					jQuery = require('jquery');
				}
				else {
					jQuery = require('jquery')(root);
				}
			}
			factory(jQuery);
			return jQuery;
		};
	} else {
		// Browser globals
		factory(jQuery);
	}
}(function ($) {
	//IE8 indexOf polyfill
	var indexOf = [].indexOf || function(item) {
		for (var i = 0, l = this.length; i < l; i++) {
			if (i in this && this[i] === item) {
				return i;
			}
		}
		return -1;
	};

	var pluginName = "notify";
	var pluginClassName = pluginName + "js";
	var blankFieldName = pluginName + "!blank";

	var positions = {
		t: "top",
		m: "middle",
		b: "bottom",
		l: "left",
		c: "center",
		r: "right"
	};
	var hAligns = ["l", "c", "r"];
	var vAligns = ["t", "m", "b"];
	var mainPositions = ["t", "b", "l", "r"];
	var opposites = {
		t: "b",
		m: null,
		b: "t",
		l: "r",
		c: null,
		r: "l"
	};

	var parsePosition = function(str) {
		var pos;
		pos = [];
		$.each(str.split(/\W+/), function(i, word) {
			var w;
			w = word.toLowerCase().charAt(0);
			if (positions[w]) {
				return pos.push(w);
			}
		});
		return pos;
	};

	var styles = {};

	var coreStyle = {
		name: "core",
		html: "<div class=\"" + pluginClassName + "-wrapper\">\n	<div class=\"" + pluginClassName + "-arrow\"></div>\n	<div class=\"" + pluginClassName + "-container\"></div>\n</div>",
		css: "." + pluginClassName + "-corner {\n	position: fixed;\n	margin: 5px;\n	z-index: 1050;\n}\n\n." + pluginClassName + "-corner ." + pluginClassName + "-wrapper,\n." + pluginClassName + "-corner ." + pluginClassName + "-container {\n	position: relative;\n	display: block;\n	height: inherit;\n	width: inherit;\n	margin: 3px;\n}\n\n." + pluginClassName + "-wrapper {\n	z-index: 1;\n	position: absolute;\n	display: inline-block;\n	height: 0;\n	width: 0;\n}\n\n." + pluginClassName + "-container {\n	display: none;\n	z-index: 1;\n	position: absolute;\n}\n\n." + pluginClassName + "-hidable {\n	cursor: pointer;\n}\n\n[data-notify-text],[data-notify-html] {\n	position: relative;\n}\n\n." + pluginClassName + "-arrow {\n	position: absolute;\n	z-index: 2;\n	width: 0;\n	height: 0;\n}"
	};

	var stylePrefixes = {
		"border-radius": ["-webkit-", "-moz-"]
	};

	var getStyle = function(name) {
		return styles[name];
	};

	var removeStyle = function(name) {
		if (!name) {
			throw "Missing Style name";
		}
		if (styles[name]) {
			delete styles[name];
		}
	};

	var addStyle = function(name, def) {
		if (!name) {
			throw "Missing Style name";
		}
		if (!def) {
			throw "Missing Style definition";
		}
		if (!def.html) {
			throw "Missing Style HTML";
		}
		//remove existing style
		var existing = styles[name];
		if (existing && existing.cssElem) {
			if (window.console) {
				console.warn(pluginName + ": overwriting style '" + name + "'");
			}
			styles[name].cssElem.remove();
		}
		def.name = name;
		styles[name] = def;
		var cssText = "";
		if (def.classes) {
			$.each(def.classes, function(className, props) {
				cssText += "." + pluginClassName + "-" + def.name + "-" + className + " {\n";
				$.each(props, function(name, val) {
					if (stylePrefixes[name]) {
						$.each(stylePrefixes[name], function(i, prefix) {
							return cssText += "	" + prefix + name + ": " + val + ";\n";
						});
					}
					return cssText += "	" + name + ": " + val + ";\n";
				});
				return cssText += "}\n";
			});
		}
		if (def.css) {
			cssText += "/* styles for " + def.name + " */\n" + def.css;
		}
		if (cssText) {
			def.cssElem = insertCSS(cssText);
			def.cssElem.attr("id", "notify-" + def.name);
		}
		var fields = {};
		var elem = $(def.html);
		findFields("html", elem, fields);
		findFields("text", elem, fields);
		def.fields = fields;
	};

	var insertCSS = function(cssText) {
		var e, elem, error;
		elem = createElem("style");
		elem.attr("type", 'text/css');
		$("head").append(elem);
		try {
			elem.html(cssText);
		} catch (_) {
			elem[0].styleSheet.cssText = cssText;
		}
		return elem;
	};

	var findFields = function(type, elem, fields) {
		var attr;
		if (type !== "html") {
			type = "text";
		}
		attr = "data-notify-" + type;
		return find(elem, "[" + attr + "]").each(function() {
			var name;
			name = $(this).attr(attr);
			if (!name) {
				name = blankFieldName;
			}
			fields[name] = type;
		});
	};

	var find = function(elem, selector) {
		if (elem.is(selector)) {
			return elem;
		} else {
			return elem.find(selector);
		}
	};

	var pluginOptions = {
		clickToHide: true,
		autoHide: true,
		autoHideDelay: 5000,
		arrowShow: true,
		arrowSize: 5,
		breakNewLines: true,
		elementPosition: "bottom",
		globalPosition: "top right",
		style: "bootstrap",
		className: "error",
		showAnimation: "slideDown",
		showDuration: 400,
		hideAnimation: "slideUp",
		hideDuration: 200,
		gap: 5
	};

	var inherit = function(a, b) {
		var F;
		F = function() {};
		F.prototype = a;
		return $.extend(true, new F(), b);
	};

	var defaults = function(opts) {
		return $.extend(pluginOptions, opts);
	};

	var createElem = function(tag) {
		return $("<" + tag + "></" + tag + ">");
	};

	var globalAnchors = {};

	var getAnchorElement = function(element) {
		var radios;
		if (element.is('[type=radio]')) {
			radios = element.parents('form:first').find('[type=radio]').filter(function(i, e) {
				return $(e).attr("name") === element.attr("name");
			});
			element = radios.first();
		}
		return element;
	};

	var incr = function(obj, pos, val) {
		var opp, temp;
		if (typeof val === "string") {
			val = parseInt(val, 10);
		} else if (typeof val !== "number") {
			return;
		}
		if (isNaN(val)) {
			return;
		}
		opp = positions[opposites[pos.charAt(0)]];
		temp = pos;
		if (obj[opp] !== undefined) {
			pos = positions[opp.charAt(0)];
			val = -val;
		}
		if (obj[pos] === undefined) {
			obj[pos] = val;
		} else {
			obj[pos] += val;
		}
		return null;
	};

	var realign = function(alignment, inner, outer) {
		if (alignment === "l" || alignment === "t") {
			return 0;
		} else if (alignment === "c" || alignment === "m") {
			return outer / 2 - inner / 2;
		} else if (alignment === "r" || alignment === "b") {
			return outer - inner;
		}
		throw "Invalid alignment";
	};

	var encode = function(text) {
		encode.e = encode.e || createElem("div");
		return encode.e.text(text).html();
	};

	function Notification(elem, data, options) {
		if (typeof options === "string") {
			options = {
				className: options
			};
		}
		this.options = inherit(pluginOptions, $.isPlainObject(options) ? options : {});
		this.loadHTML();
		this.wrapper = $(coreStyle.html);
		if (this.options.clickToHide) {
			this.wrapper.addClass(pluginClassName + "-hidable");
		}
		this.wrapper.data(pluginClassName, this);
		this.arrow = this.wrapper.find("." + pluginClassName + "-arrow");
		this.container = this.wrapper.find("." + pluginClassName + "-container");
		this.container.append(this.userContainer);
		if (elem && elem.length) {
			this.elementType = elem.attr("type");
			this.originalElement = elem;
			this.elem = getAnchorElement(elem);
			this.elem.data(pluginClassName, this);
			this.elem.before(this.wrapper);
		}
		this.container.hide();
		this.run(data);
	}

	Notification.prototype.loadHTML = function() {
		var style;
		style = this.getStyle();
		this.userContainer = $(style.html);
		this.userFields = style.fields;
	};

	Notification.prototype.show = function(show, userCallback) {
		var args, callback, elems, fn, hidden;
		callback = (function(_this) {
			return function() {
				if (!show && !_this.elem) {
					_this.destroy();
				}
				if (userCallback) {
					return userCallback();
				}
			};
		})(this);
		hidden = this.container.parent().parents(':hidden').length > 0;
		elems = this.container.add(this.arrow);
		args = [];
		if (hidden && show) {
			fn = "show";
		} else if (hidden && !show) {
			fn = "hide";
		} else if (!hidden && show) {
			fn = this.options.showAnimation;
			args.push(this.options.showDuration);
		} else if (!hidden && !show) {
			fn = this.options.hideAnimation;
			args.push(this.options.hideDuration);
		} else {
			return callback();
		}
		args.push(callback);
		return elems[fn].apply(elems, args);
	};

	Notification.prototype.setGlobalPosition = function() {
		var p = this.getPosition();
		var pMain = p[0];
		var pAlign = p[1];
		var main = positions[pMain];
		var align = positions[pAlign];
		var key = pMain + "|" + pAlign;
		var anchor = globalAnchors[key];
		if (!anchor || !document.body.contains(anchor[0])) {
			anchor = globalAnchors[key] = createElem("div");
			var css = {};
			css[main] = 0;
			if (align === "middle") {
				css.top = '45%';
			} else if (align === "center") {
				css.left = '45%';
			} else {
				css[align] = 0;
			}
			anchor.css(css).addClass(pluginClassName + "-corner");
			$("body").append(anchor);
		}
		return anchor.prepend(this.wrapper);
	};

	Notification.prototype.setElementPosition = function() {
		var arrowColor, arrowCss, arrowSize, color, contH, contW, css, elemH, elemIH, elemIW, elemPos, elemW, gap, j, k, len, len1, mainFull, margin, opp, oppFull, pAlign, pArrow, pMain, pos, posFull, position, ref, wrapPos;
		position = this.getPosition();
		pMain = position[0];
		pAlign = position[1];
		pArrow = position[2];
		elemPos = this.elem.position();
		elemH = this.elem.outerHeight();
		elemW = this.elem.outerWidth();
		elemIH = this.elem.innerHeight();
		elemIW = this.elem.innerWidth();
		wrapPos = this.wrapper.position();
		contH = this.container.height();
		contW = this.container.width();
		mainFull = positions[pMain];
		opp = opposites[pMain];
		oppFull = positions[opp];
		css = {};
		css[oppFull] = pMain === "b" ? elemH : pMain === "r" ? elemW : 0;
		incr(css, "top", elemPos.top - wrapPos.top);
		incr(css, "left", elemPos.left - wrapPos.left);
		ref = ["top", "left"];
		for (j = 0, len = ref.length; j < len; j++) {
			pos = ref[j];
			margin = parseInt(this.elem.css("margin-" + pos), 10);
			if (margin) {
				incr(css, pos, margin);
			}
		}
		gap = Math.max(0, this.options.gap - (this.options.arrowShow ? arrowSize : 0));
		incr(css, oppFull, gap);
		if (!this.options.arrowShow) {
			this.arrow.hide();
		} else {
			arrowSize = this.options.arrowSize;
			arrowCss = $.extend({}, css);
			arrowColor = this.userContainer.css("border-color") || this.userContainer.css("border-top-color") || this.userContainer.css("background-color") || "white";
			for (k = 0, len1 = mainPositions.length; k < len1; k++) {
				pos = mainPositions[k];
				posFull = positions[pos];
				if (pos === opp) {
					continue;
				}
				color = posFull === mainFull ? arrowColor : "transparent";
				arrowCss["border-" + posFull] = arrowSize + "px solid " + color;
			}
			incr(css, positions[opp], arrowSize);
			if (indexOf.call(mainPositions, pAlign) >= 0) {
				incr(arrowCss, positions[pAlign], arrowSize * 2);
			}
		}
		if (indexOf.call(vAligns, pMain) >= 0) {
			incr(css, "left", realign(pAlign, contW, elemW));
			if (arrowCss) {
				incr(arrowCss, "left", realign(pAlign, arrowSize, elemIW));
			}
		} else if (indexOf.call(hAligns, pMain) >= 0) {
			incr(css, "top", realign(pAlign, contH, elemH));
			if (arrowCss) {
				incr(arrowCss, "top", realign(pAlign, arrowSize, elemIH));
			}
		}
		if (this.container.is(":visible")) {
			css.display = "block";
		}
		this.container.removeAttr("style").css(css);
		if (arrowCss) {
			return this.arrow.removeAttr("style").css(arrowCss);
		}
	};

	Notification.prototype.getPosition = function() {
		var pos, ref, ref1, ref2, ref3, ref4, ref5, text;
		text = this.options.position || (this.elem ? this.options.elementPosition : this.options.globalPosition);
		pos = parsePosition(text);
		if (pos.length === 0) {
			pos[0] = "b";
		}
		if (ref = pos[0], indexOf.call(mainPositions, ref) < 0) {
			throw "Must be one of [" + mainPositions + "]";
		}
		if (pos.length === 1 || ((ref1 = pos[0], indexOf.call(vAligns, ref1) >= 0) && (ref2 = pos[1], indexOf.call(hAligns, ref2) < 0)) || ((ref3 = pos[0], indexOf.call(hAligns, ref3) >= 0) && (ref4 = pos[1], indexOf.call(vAligns, ref4) < 0))) {
			pos[1] = (ref5 = pos[0], indexOf.call(hAligns, ref5) >= 0) ? "m" : "l";
		}
		if (pos.length === 2) {
			pos[2] = pos[1];
		}
		return pos;
	};

	Notification.prototype.getStyle = function(name) {
		var style;
		if (!name) {
			name = this.options.style;
		}
		if (!name) {
			name = "default";
		}
		style = styles[name];
		if (!style) {
			throw "Missing style: " + name;
		}
		return style;
	};

	Notification.prototype.updateClasses = function() {
		var classes, style;
		classes = ["base"];
		if ($.isArray(this.options.className)) {
			classes = classes.concat(this.options.className);
		} else if (this.options.className) {
			classes.push(this.options.className);
		}
		style = this.getStyle();
		classes = $.map(classes, function(n) {
			return pluginClassName + "-" + style.name + "-" + n;
		}).join(" ");
		return this.userContainer.attr("class", classes);
	};

	Notification.prototype.run = function(data, options) {
		var d, datas, name, type, value;
		if ($.isPlainObject(options)) {
			$.extend(this.options, options);
		} else if ($.type(options) === "string") {
			this.options.className = options;
		}
		if (this.container && !data) {
			this.show(false);
			return;
		} else if (!this.container && !data) {
			return;
		}
		datas = {};
		if ($.isPlainObject(data)) {
			datas = data;
		} else {
			datas[blankFieldName] = data;
		}
		for (name in datas) {
			d = datas[name];
			type = this.userFields[name];
			if (!type) {
				continue;
			}
			if (type === "text") {
				d = encode(d);
				if (this.options.breakNewLines) {
					d = d.replace(/\n/g, '<br/>');
				}
			}
			value = name === blankFieldName ? '' : '=' + name;
			find(this.userContainer, "[data-notify-" + type + value + "]").html(d);
		}
		this.updateClasses();
		if (this.elem) {
			this.setElementPosition();
		} else {
			this.setGlobalPosition();
		}
		this.show(true);
		if (this.options.autoHide) {
			clearTimeout(this.autohideTimer);
			this.autohideTimer = setTimeout(this.show.bind(this, false), this.options.autoHideDelay);
		}
	};

	Notification.prototype.destroy = function() {
		this.wrapper.data(pluginClassName, null);
		this.wrapper.remove();
	};

	$[pluginName] = function(elem, data, options) {
		if ((elem && elem.nodeName) || elem.jquery) {
			$(elem)[pluginName](data, options);
		} else {
			options = data;
			data = elem;
			new Notification(null, data, options);
		}
		return elem;
	};

	$.fn[pluginName] = function(data, options) {
		$(this).each(function() {
			var prev = getAnchorElement($(this)).data(pluginClassName);
			if (prev) {
				prev.destroy();
			}
			var curr = new Notification($(this), data, options);
		});
		return this;
	};

	$.extend($[pluginName], {
		defaults: defaults,
		addStyle: addStyle,
		removeStyle: removeStyle,
		pluginOptions: pluginOptions,
		getStyle: getStyle,
		insertCSS: insertCSS
	});

	//always include the default bootstrap style
	addStyle("bootstrap", {
		html: "<div>\n<span data-notify-text></span>\n</div>",
		classes: {
			base: {
				"font-weight": "bold",
				"padding": "8px 15px 8px 14px",
				"text-shadow": "0 1px 0 rgba(255, 255, 255, 0.5)",
				"background-color": "#fcf8e3",
				"border": "1px solid #fbeed5",
				"border-radius": "4px",
				"white-space": "nowrap",
				"padding-left": "25px",
				"background-repeat": "no-repeat",
				"background-position": "3px 7px"
			},
			error: {
				"color": "#B94A48",
				"background-color": "#F2DEDE",
				"border-color": "#EED3D7",
				"background-image": "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAtRJREFUeNqkVc1u00AQHq+dOD+0poIQfkIjalW0SEGqRMuRnHos3DjwAH0ArlyQeANOOSMeAA5VjyBxKBQhgSpVUKKQNGloFdw4cWw2jtfMOna6JOUArDTazXi/b3dm55socPqQhFka++aHBsI8GsopRJERNFlY88FCEk9Yiwf8RhgRyaHFQpPHCDmZG5oX2ui2yilkcTT1AcDsbYC1NMAyOi7zTX2Agx7A9luAl88BauiiQ/cJaZQfIpAlngDcvZZMrl8vFPK5+XktrWlx3/ehZ5r9+t6e+WVnp1pxnNIjgBe4/6dAysQc8dsmHwPcW9C0h3fW1hans1ltwJhy0GxK7XZbUlMp5Ww2eyan6+ft/f2FAqXGK4CvQk5HueFz7D6GOZtIrK+srupdx1GRBBqNBtzc2AiMr7nPplRdKhb1q6q6zjFhrklEFOUutoQ50xcX86ZlqaZpQrfbBdu2R6/G19zX6XSgh6RX5ubyHCM8nqSID6ICrGiZjGYYxojEsiw4PDwMSL5VKsC8Yf4VRYFzMzMaxwjlJSlCyAQ9l0CW44PBADzXhe7xMdi9HtTrdYjFYkDQL0cn4Xdq2/EAE+InCnvADTf2eah4Sx9vExQjkqXT6aAERICMewd/UAp/IeYANM2joxt+q5VI+ieq2i0Wg3l6DNzHwTERPgo1ko7XBXj3vdlsT2F+UuhIhYkp7u7CarkcrFOCtR3H5JiwbAIeImjT/YQKKBtGjRFCU5IUgFRe7fF4cCNVIPMYo3VKqxwjyNAXNepuopyqnld602qVsfRpEkkz+GFL1wPj6ySXBpJtWVa5xlhpcyhBNwpZHmtX8AGgfIExo0ZpzkWVTBGiXCSEaHh62/PoR0p/vHaczxXGnj4bSo+G78lELU80h1uogBwWLf5YlsPmgDEd4M236xjm+8nm4IuE/9u+/PH2JXZfbwz4zw1WbO+SQPpXfwG/BBgAhCNZiSb/pOQAAAAASUVORK5CYII=)"
			},
			success: {
				"color": "#468847",
				"background-color": "#DFF0D8",
				"border-color": "#D6E9C6",
				"background-image": "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAutJREFUeNq0lctPE0Ecx38zu/RFS1EryqtgJFA08YCiMZIAQQ4eRG8eDGdPJiYeTIwHTfwPiAcvXIwXLwoXPaDxkWgQ6islKlJLSQWLUraPLTv7Gme32zoF9KSTfLO7v53vZ3d/M7/fIth+IO6INt2jjoA7bjHCJoAlzCRw59YwHYjBnfMPqAKWQYKjGkfCJqAF0xwZjipQtA3MxeSG87VhOOYegVrUCy7UZM9S6TLIdAamySTclZdYhFhRHloGYg7mgZv1Zzztvgud7V1tbQ2twYA34LJmF4p5dXF1KTufnE+SxeJtuCZNsLDCQU0+RyKTF27Unw101l8e6hns3u0PBalORVVVkcaEKBJDgV3+cGM4tKKmI+ohlIGnygKX00rSBfszz/n2uXv81wd6+rt1orsZCHRdr1Imk2F2Kob3hutSxW8thsd8AXNaln9D7CTfA6O+0UgkMuwVvEFFUbbAcrkcTA8+AtOk8E6KiQiDmMFSDqZItAzEVQviRkdDdaFgPp8HSZKAEAL5Qh7Sq2lIJBJwv2scUqkUnKoZgNhcDKhKg5aH+1IkcouCAdFGAQsuWZYhOjwFHQ96oagWgRoUov1T9kRBEODAwxM2QtEUl+Wp+Ln9VRo6BcMw4ErHRYjH4/B26AlQoQQTRdHWwcd9AH57+UAXddvDD37DmrBBV34WfqiXPl61g+vr6xA9zsGeM9gOdsNXkgpEtTwVvwOklXLKm6+/p5ezwk4B+j6droBs2CsGa/gNs6RIxazl4Tc25mpTgw/apPR1LYlNRFAzgsOxkyXYLIM1V8NMwyAkJSctD1eGVKiq5wWjSPdjmeTkiKvVW4f2YPHWl3GAVq6ymcyCTgovM3FzyRiDe2TaKcEKsLpJvNHjZgPNqEtyi6mZIm4SRFyLMUsONSSdkPeFtY1n0mczoY3BHTLhwPRy9/lzcziCw9ACI+yql0VLzcGAZbYSM5CCSZg1/9oc/nn7+i8N9p/8An4JMADxhH+xHfuiKwAAAABJRU5ErkJggg==)"
			},
			info: {
				"color": "#3A87AD",
				"background-color": "#D9EDF7",
				"border-color": "#BCE8F1",
				"background-image": "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3QYFAhkSsdes/QAAA8dJREFUOMvVlGtMW2UYx//POaWHXg6lLaW0ypAtw1UCgbniNOLcVOLmAjHZolOYlxmTGXVZdAnRfXQm+7SoU4mXaOaiZsEpC9FkiQs6Z6bdCnNYruM6KNBw6YWewzl9z+sHImEWv+vz7XmT95f/+3/+7wP814v+efDOV3/SoX3lHAA+6ODeUFfMfjOWMADgdk+eEKz0pF7aQdMAcOKLLjrcVMVX3xdWN29/GhYP7SvnP0cWfS8caSkfHZsPE9Fgnt02JNutQ0QYHB2dDz9/pKX8QjjuO9xUxd/66HdxTeCHZ3rojQObGQBcuNjfplkD3b19Y/6MrimSaKgSMmpGU5WevmE/swa6Oy73tQHA0Rdr2Mmv/6A1n9w9suQ7097Z9lM4FlTgTDrzZTu4StXVfpiI48rVcUDM5cmEksrFnHxfpTtU/3BFQzCQF/2bYVoNbH7zmItbSoMj40JSzmMyX5qDvriA7QdrIIpA+3cdsMpu0nXI8cV0MtKXCPZev+gCEM1S2NHPvWfP/hL+7FSr3+0p5RBEyhEN5JCKYr8XnASMT0xBNyzQGQeI8fjsGD39RMPk7se2bd5ZtTyoFYXftF6y37gx7NeUtJJOTFlAHDZLDuILU3j3+H5oOrD3yWbIztugaAzgnBKJuBLpGfQrS8wO4FZgV+c1IxaLgWVU0tMLEETCos4xMzEIv9cJXQcyagIwigDGwJgOAtHAwAhisQUjy0ORGERiELgG4iakkzo4MYAxcM5hAMi1WWG1yYCJIcMUaBkVRLdGeSU2995TLWzcUAzONJ7J6FBVBYIggMzmFbvdBV44Corg8vjhzC+EJEl8U1kJtgYrhCzgc/vvTwXKSib1paRFVRVORDAJAsw5FuTaJEhWM2SHB3mOAlhkNxwuLzeJsGwqWzf5TFNdKgtY5qHp6ZFf67Y/sAVadCaVY5YACDDb3Oi4NIjLnWMw2QthCBIsVhsUTU9tvXsjeq9+X1d75/KEs4LNOfcdf/+HthMnvwxOD0wmHaXr7ZItn2wuH2SnBzbZAbPJwpPx+VQuzcm7dgRCB57a1uBzUDRL4bfnI0RE0eaXd9W89mpjqHZnUI5Hh2l2dkZZUhOqpi2qSmpOmZ64Tuu9qlz/SEXo6MEHa3wOip46F1n7633eekV8ds8Wxjn37Wl63VVa+ej5oeEZ/82ZBETJjpJ1Rbij2D3Z/1trXUvLsblCK0XfOx0SX2kMsn9dX+d+7Kf6h8o4AIykuffjT8L20LU+w4AZd5VvEPY+XpWqLV327HR7DzXuDnD8r+ovkBehJ8i+y8YAAAAASUVORK5CYII=)"
			},
			warn: {
				"color": "#C09853",
				"background-color": "#FCF8E3",
				"border-color": "#FBEED5",
				"background-image": "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAMAAAC6V+0/AAABJlBMVEXr6eb/2oD/wi7/xjr/0mP/ykf/tQD/vBj/3o7/uQ//vyL/twebhgD/4pzX1K3z8e349vK6tHCilCWbiQymn0jGworr6dXQza3HxcKkn1vWvV/5uRfk4dXZ1bD18+/52YebiAmyr5S9mhCzrWq5t6ufjRH54aLs0oS+qD751XqPhAybhwXsujG3sm+Zk0PTwG6Shg+PhhObhwOPgQL4zV2nlyrf27uLfgCPhRHu7OmLgAafkyiWkD3l49ibiAfTs0C+lgCniwD4sgDJxqOilzDWowWFfAH08uebig6qpFHBvH/aw26FfQTQzsvy8OyEfz20r3jAvaKbhgG9q0nc2LbZxXanoUu/u5WSggCtp1anpJKdmFz/zlX/1nGJiYmuq5Dx7+sAAADoPUZSAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfdBgUBGhh4aah5AAAAlklEQVQY02NgoBIIE8EUcwn1FkIXM1Tj5dDUQhPU502Mi7XXQxGz5uVIjGOJUUUW81HnYEyMi2HVcUOICQZzMMYmxrEyMylJwgUt5BljWRLjmJm4pI1hYp5SQLGYxDgmLnZOVxuooClIDKgXKMbN5ggV1ACLJcaBxNgcoiGCBiZwdWxOETBDrTyEFey0jYJ4eHjMGWgEAIpRFRCUt08qAAAAAElFTkSuQmCC)"
			}
		}
	});

	$(function() {
		insertCSS(coreStyle.css).attr("id", "core-notify");
		$(document).on("click", "." + pluginClassName + "-hidable", function(e) {
			$(this).trigger("notify-hide");
		});
		$(document).on("notify-hide", "." + pluginClassName + "-wrapper", function(e) {
			var elem = $(this).data(pluginClassName);
			if(elem) {
				elem.show(false);
			}
		});
	});

}));


================================================
FILE: assets/logcat.css
================================================
.logcat {
  font-family: "Courier New", Courier, monospace;
  border-collapse: separate;
  border-spacing: 4px 0px;
  background-color: black;
  color: white;
  width: 100%;
  border-radius: 5px;
  font-size: 10px;
}

.logcat-lineno {
  vertical-align: text-top;
}

.logcat-tag {
  color: red;
  text-align: right;
  vertical-align: text-top;
  width: 10em;
  font-weight: 500;
  font-family: Consolas;
}

.logcat-level {
  text-align: center;
  vertical-align: text-top;
  color: white;
  background-color: green;
  width: 1.5em;
}

.logcat-content {
  word-break: normal;
  white-space: pre-line;
}

.nav-tabs>li.follow-log {
  display: block;
  border-color: green;
  border-width: 1.5mm;
  border-style: solid;
  border-radius: 50%;
  width: 6mm;
  height: 6mm;
  opacity: 0.7;
  background: white;
  position: relative;
  float: right;
  top: 60px;
  right: 50px;
  cursor: pointer;
  /* pointer-events: none; */
  /* left: 100mm; */
}

.follow-log .hover-content {
  display: none;
  background: gray;
  color: white;
  width: 12em;
  margin-left: -10.5em;
  margin-top: -0.5em;
  padding: 2px;
  border-radius: 2px;
  text-align: center;
}

.follow-log:hover .hover-content {
  display: block;
}

================================================
FILE: assets/remote.css
================================================
* {
  margin: 0px;
  padding: 0px;
}

.color-red {
  color: red;
}

.color-green {
  color: green;
}

.color-blue {
  color: blue;
}

.description {
  margin-top: 10px;
  margin-left: 5px;
  font-size: 0.8em;
  color: gray;
}

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"
  /*display: flex;*/
}

.finger {
  position: absolute;
  border-style: solid;
  border-radius: 50%;
  border-color: white;
  border-width: 0mm;
  width: 6mm;
  height: 6mm;
  top: -3mm;
  left: -3mm;
  opacity: 0.7;
  pointer-events: none;
  background: red;
  /*#464646;*/
  /*background: red;*/
  display: none;
}

.finger.active {
  display: block;
  border-color: #464646;
  border-width: 1mm;
}

#app {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}

#left {
  /*height: 100%;*/
  display: flex;
  flex-direction: column;
  width: 700px;
}

.editor-container {
  position: relative;
  min-height: 100px;
  padding: 0px;
  margin: 0px;
  flex: 1;
}

#right {
  display: flex;
  flex-direction: column;
  flex: 1;
  overflow-y: auto;
  overflow-x: hidden;
  /*border: 1px solid red;*/
  /*position: relative;*/
}

section {
  /*border: 1px solid red;*/
}

#screen {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  flex: 1;
  background-color: gray;
  border-top: 1px solid darkgray;
  border-bottom: 1px solid darkgray;
}

#footer {
  height: 50px;
  display: flex;
  justify-content: space-around;
}

#footer>button {
  flex: 1;
}

.box {
  position: relative;
  display: flex;
  flex: 1;
}

#editor {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
}

#console {
  height: 150px;
  /*border: 1px solid green;*/
  background-color: #eee;
  word-break: break-all;
  padding: 0px;
  position: relative;
  background-color: #f5f5f5;
  overflow: auto;
}

#console>pre {
  font-family: "Courier New";
  font-size: 13px;
  padding: 2px 5px;
  /*height: 100%;*/
  border: 0px;
}

#console>img {
  position: absolute;
  left: 0px;
  top: 0px;
}

#upper {
  width: 100%;
  display: flex;
  flex: 1;
  border-top: 1px solid black;
}

#toolbar {
  border: 1px solid green;
}

.vertical-gap {
  width: 5px;
  background-color: #444444;
}

.vertical-gap:hover {
  cursor: col-resize;
  background-color: black;
}

.horizon-gap {
  height: 5px;
  background-color: #444444;
}

.horizon-gap:hover {
  cursor: row-resize;
  background-color: black;
}

.canvas-fg {
  z-index: 1;
  position: absolute;
}

.canvas-bg {
  z-index: 0;
  position: absolute;
}

.table-weditor {
  word-wrap: break-word;
  table-layout: fixed;
}

i.inactive {
  opacity: 0.5;
}

.cursor-pointer {
  cursor: pointer;
}

================================================
FILE: assets/remote.js
================================================
/* Javascript */
$(function () {
  $('.btn-copy')
    .mouseleave(function () {
      var $element = $(this);
      $element.tooltip('hide').tooltip('disable');
    })

  var clipboard = new Clipboard('.btn-copy');
  clipboard.on('success', function (e) {
    $(e.trigger)
      .attr('title', 'Copied')
      .tooltip('fixTitle')
      .tooltip('enable')
      .tooltip('show');
  })

  $('[data-toggle=tooltip]').tooltip({
    trigger: 'hover',
  });
})


window.app = new Vue({
  el: '#app',
  data: {
    deviceUdid: deviceUdid,
    device: {
      ip: deviceIp,
      port: 7912,
    },
    deviceInfo: {},
    fixConsole: '', // log for fix minicap and rotation
    navtabs: {
      active: location.hash.slice(1) || 'home',
      tabs: [],
    },
    error: '',
    control: null,
    loading: false,
    canvas: {
      bg: null,
      fg: null,
    },
    canvasStyle: {
      opacity: 1,
      width: 'inherit',
      height: 'inherit'
    },
    lastScreenSize: {
      screen: {},
      canvas: {
        width: 1,
        height: 1
      }
    },
    screenWS: null,
    browserURL: "",
    logcat: {
      follow: true,
      tagColors: {},
      lineNumber: 0,
      maxKeep: 1500,
      cachedScrollTop: 0,
      logs: [{
        lineno: 1,
        tag: "EsService2",
        level: "W",
        content: "loaded /system/lib/egl/libEGL_adreno200.so",
      }]
    },
    imageBlobBuffer: [],
    videoUrl: '',
    videoReceiver: null, // sub function to receive image
    inputText: '',
    inputWS: null,
  },
  watch: {},
  computed: {
    deviceUrl: function () {
      return "http://" + this.device.ip + ":" + this.device.port;
    }
  },
  mounted: function () {
    var URL = window.URL || window.webkitURL;
    var currentSize = null;
    var self = this;
    $.notify.defaults({ className: "success" });

    this.canvas.bg = document.getElementById('bgCanvas')
    this.canvas.fg = document.getElementById('fgCanvas')
    // this.canvas = c;
    window.c = this.canvas.bg;
    var ctx = c.getContext('2d')

    $(window).resize(function () {
      self.resizeScreen();
    })

    this.initDragDealer();

    // get device info
    $.ajax({
      url: this.deviceUrl + "/info", // "/devices/" + this.deviceUdid + "/info",
      dateType: "json"
    }).then(function (ret) {
      this.deviceInfo = ret;
      document.title = ret.model;
    }.bind(this))

    this.reserveDevice()
      .then(function () {
        this.enableTouch();
        this.openScreenStream();
      }.bind(this))

    // wakeup device on connect
    setTimeout(function () {
      this.keyevent("WAKEUP");
    }.bind(this), 1)

    window.k = setTimeout(function () {
      var lineno = (this.logcat.lineNumber += 1);
      this.logcat.logs.push({
        lineno: lineno,
        tag: "EsService2",
        level: "W",
        content: "loaded /system/lib/egl/libEGL_adreno200.so",
      });
      if (this.logcat.follow) {
        // only keep maxKeep lines
        var maxKeep = Math.max(20, this.logcat.maxKeep);
        var size = this.logcat.logs.length;
        this.logcat.logs = this.logcat.logs.slice(size - maxKeep, size);

        // scroll to end
        var el = this.$refs.tab_content;
        var logcat = this.logcat;
        if (el.scrollTop < logcat.cachedScrollTop) {
          this.logcat.follow = false;
        } else {
          setTimeout(function () {
            logcat.cachedScrollTop = el.scrollTop = el.scrollHeight - el.clientHeight;
          }, 2);
        }
      }
    }.bind(this), 200)

    this.inputWS = new WebSocket("ws://" + this.device.ip + ":" + this.device.port + "/whatsinput");
    this.inputWS.onmessage = function (message) {
      // console.log(message)
      var data = JSON.parse(message.data)
      if (data.type == "InputStart") {
        this.inputText = data.text;
      } else {
        console.log(data)
      }
    }.bind(this);

  },
  watch: {
    inputText: function (newText) {
      console.log(newText);
      this.inputWS.send(JSON.stringify({ type: "InputEdit", text: newText }))
    }
  },
  methods: {
    reserveDevice: function () {
      var dtd = $.Deferred();
      var ws = new WebSocket("ws://" + location.host + "/devices/" + this.deviceUdid + "/reserved")
      ws.onmessage = function (message) {
        console.log("WebSocket receive", message)
      }
      var key = setInterval(function () {
        ws.send("ping")
      }, 5000);
      ws.onopen = function () {
        dtd.resolve();
      }
      ws.onerror = function (err) {
        console.log("WebSocket Error " + err)
      }
      ws.onclose = function () {
        dtd.reject();
        clearInterval(key);
        console.log("websocket reserved closed");
      }
      return dtd.promise();
    },
    connectImage2VideoWebSocket: function (fps) {
      var protocol = location.protocol == "http:" ? "ws:" : "wss:";
      var wsURL = protocol + location.host + "/video/convert"
      var wsQueries = encodeURI("fps=" + fps) + "&" + encodeURI("udid=" + this.deviceUdid) + "&" + encodeURI("name=" + this.deviceInfo.model)
      var ws = new WebSocket(wsURL + "?" + wsQueries)
      var def = $.Deferred()
      ws.onopen = function () {
        def.resolve(this)
      }
      ws.onclose = function (ev) {
        def.reject("Somehow ws disconnected")
      }
      return def.promise();
    },
    startLowQualityScreenRecord: function (event) {
      $(event.target).notify("初始化中 ...");
      this.connectImage2VideoWebSocket(2)
        .done(function (ws) {
          $(event.target).notify("视频录制中, 再次点击停止");
          var key = setInterval(function () {
            $.ajax({
              url: this.deviceUrl + "/screenshot/0?thumbnail=800x800",
              method: "get",
              processData: false,
              cache: false,
              xhr: function () {
                var xhr = new XMLHttpRequest();
                xhr.responseType = "blob"
                return xhr;
              },
              success: function (data) {
                ws.send(data)
                console.log("screenshot")
              }
            })
          }.bind(this), 1000)
          this.videoReceiver = {
            ws: ws,
            key: key,
          }
        }.bind(this))
        .fail(function (err) {
          $(event.target).notify("录制启动失败, 请点击【关于我们】,联系网站管理员", "error");
        })
    },
    startVideoRecord: function (event) {
      $(event.target).notify("初始化中 ...");
      this.connectImage2VideoWebSocket(10)
        .done(function (ws) {
          $(event.target).notify("视频录制中, 再次点击停止");
          var cache = {}
          function receiver(_, data) {
            cache.last = data;
          }
          var key = setInterval(function () {
            var lastData = cache.last;
            cache.last = null;
            if (lastData) {
              ws.send(lastData)
            }
          }, 1000 / 6) // fps: 6
          receiver.ws = ws;
          receiver.key = key;

          $.subscribe('imagedata', receiver)
          this.videoReceiver = receiver;
        }.bind(this))
        .fail(function (err) {
          $(event.target).notify("录制启动失败, 请点击【关于我们】,联系网站管理员", "error");
        })
    },
    stopVideoRecord: function () {
      if (this.videoReceiver) {
        $.unsubscribe("imagedata", this.videoReceiver);
        this.videoReceiver.ws.close()
        clearInterval(this.videoReceiver.key);
        this.videoReceiver = null;
        $(event.target).notify("视频录制成功");
      }
    },
    toggleScreen: function () {
      if (this.screenWS) {
        this.screenWS.close();
        this.canvasStyle.opacity = 0;
        this.screenWS = null;
      } else {
        this.openScreenStream();
        this.canvasStyle.opacity = 1;
      }
    },
    saveShortVideo: function (event) {
      var fd = new FormData();
      this.imageBlobBuffer.forEach(function (blob) {
        fd.append('file', blob);
      });
      $(event.target).notify("视频后台合成中,请稍候 ...");
      console.log("upload")
      $.ajax({
        type: "post",
        url: "http://10.246.46.160:7000/img2video", // TODO: 临时地址,需要后期更换
        processData: false,
        contentType: false,
        data: fd,
        dateType: 'json',
      }).done(function (data) {
        console.log(data.url);
        this.videoUrl = data.url;
        $(event.target).notify("合成完毕");
      }.bind(this))
    },
    saveScreenshot: function () {
      $.ajax({
        url: this.deviceUrl + "/screenshot",
        cache: false,
        xhrFields: {
          responseType: 'blob'
        },
      }).then(function (blob) {
        saveAs(blob, "screenshot.jpg") // saveAs require FileSaver.js
      })
    },
    openBrowser: function (url) {
      if (!/^https?:\/\//.test(url)) {
        url = "http://" + url;
      }
      return this.shell("am start -a android.intent.action.VIEW -d " + url);
    },
    uploadFile: function (event) {
      var formData = new FormData(event.target);
      $(event.target).notify("Uploading ...");
      $.ajax({
        method: "post",
        url: this.deviceUrl + "/upload/sdcard/tmp/",
        data: formData,
        processData: false,
        contentType: false,
        enctype: 'multipart/form-data',
      })
        .then(function (ret) {
          $(event.target).notify("Upload success");
        }, function (err) {
          $(event.target).notify("Upload failed:" + err.responseText, "error")
          console.error(err)
        })
    },
    addTabItem: function (item) {
      this.navtabs.tabs.push(item);
    },
    changeTab: function (tabId) {
      location.hash = tabId;
    },
    fixRotation: function () {
      $.ajax({
        url: this.deviceUrl + "/info/rotation",
        method: "post",
      }).then(function (ret) {
        console.log("rotation fixed")
      })
    },
    fixMinicap: function () {
      this.fixConsole = "remove old minicap";
      $.ajax({
        method: "post",
        url: this.deviceUrl + "/shell",
        data: {
          command: "rm -f /data/local/tmp/minicap /data/local/tmp/minicap.so"
        }
      })
        .then(function () {
          this.fixConsole = "download mincap to device ..."
          return $.ajax({
            url: this.deviceUrl + "/minicap",
            method: "put",
          })
        }.bind(this))
        .then(function () {
          this.fixConsole = "minicap fixed"
        }.bind(this), function () {
          this.fixConsole = "minicap can not be fixed, open Browser Console for more detail"
        }.bind(this))
    },
    tabScroll: function (ev) {
      // var el = ev.target;
      // var el = this.$refs.tab_content;
      // var bottom = (el.scrollTop == (el.scrollHeight - el.clientHeight));
      // console.log("Bottom", bottom, el.scrollTop, el.scrollHeight, el.clientHeight, el.scrollHeight - el.clientHeight)
      // console.log(ev.target.scrollTop, ev.target.scrollHeight, ev.target.clientHeight);
      this.logcat.follow = false;
    },
    followLog: function () {
      this.logcat.follow = !this.logcat.follow;
      if (this.logcat.follow) {
        var el = this.$refs.tab_content;
        el.scrollTop = el.scrollHeight - el.clientHeight;
      }
    },
    logcatTag2Color: function (tag) {
      var color = this.logcat.tagColors[tag];
      if (!color) {
        color = this.logcat.tagColors[tag] = getRandomRgb(5);
      }
      return color;
    },
    logcatLevel2Color: function (level) {
      switch (level) {
        case "W":
          return "goldenrod";
        case "I":
          return "darkgreen";
        case "D":
          return "gray";
        default:
          return "gray";
      }
    },
    hold: function (msecs) {
      this.control.touchDown(0, 0.5, 0.5, 5, 0.5)
      this.control.touchCommit();
      this.control.touchWait(msecs);
      this.control.touchUp(0)
      this.control.touchCommit();
    },
    keyevent: function (meta) {
      console.log("keyevent", meta)
      return this.shell("input keyevent " + meta.toUpperCase());
    },
    shell: function (command) {
      return $.ajax({
        url: this.deviceUrl + "/shell",
        method: "post",
        data: {
          command: command,
        },
        success: function (ret) {
          console.log(ret);
        },
        error: function (ret) {
          console.log(ret)
        }
      })
    },
    showError: function (error) {
      this.loading = false;
      this.error = error;
      $('.modal').modal('show');
    },
    showAjaxError: function (ret) {
      if (ret.responseJSON && ret.responseJSON.description) {
        this.showError(ret.responseJSON.description);
      } else {
        this.showError("<p>Local server not started, start with</p><pre>$ python -m weditor</pre>");
      }
    },
    initDragDealer: function () {
      var self = this;
      var updateFunc = null;

      function dragMoveListener(evt) {
        evt.preventDefault();
        updateFunc(evt);
        self.resizeScreen();
      }

      function dragStopListener(evt) {
        document.removeEventListener('mousemove', dragMoveListener);
        document.removeEventListener('mouseup', dragStopListener);
        document.removeEventListener('mouseleave', dragStopListener);
      }

      $('#vertical-gap1').mousedown(function (e) {
        e.preventDefault();
        updateFunc = function (evt) {
          $("#left").width(evt.clientX);
        }
        document.addEventListener('mousemove', dragMoveListener);
        document.addEventListener('mouseup', dragStopListener);
        document.addEventListener('mouseleave', dragStopListener)
      });
    },
    resizeScreen: function (img) {
      // check if need update
      if (img) {
        if (this.lastScreenSize.canvas.width == img.width &&
          this.lastScreenSize.canvas.height == img.height) {
          return;
        }
      } else {
        img = this.lastScreenSize.canvas;
        if (!img) {
          return;
        }
      }
      var screenDiv = document.getElementById('screen');
      this.lastScreenSize = {
        canvas: {
          width: img.width,
          height: img.height
        },
        screen: {
          width: screenDiv.clientWidth,
          height: screenDiv.clientHeight,
        }
      }

      var canvasAspect = img.width / img.height;
      var screenAspect = screenDiv.clientWidth / screenDiv.clientHeight;
      if (canvasAspect > screenAspect) {
        Object.assign(this.canvasStyle, {
          width: Math.floor(screenDiv.clientWidth) + 'px', //'100%',
          height: Math.floor(screenDiv.clientWidth / canvasAspect) + 'px', // 'inherit',
        })
      } else if (canvasAspect < screenAspect) {
        Object.assign(this.canvasStyle, {
          width: Math.floor(screenDiv.clientHeight * canvasAspect) + 'px', //'inherit',
          height: Math.floor(screenDiv.clientHeight) + 'px', //'100%',
        })
      }
    },
    delayReload: function (msec) {
      setTimeout(this.screenDumpUI, msec || 1000);
    },
    drawBlobImageToScreen: function (blob) {
      // Support jQuery Promise
      var dtd = $.Deferred();
      var bgcanvas = this.canvas.bg,
        fgcanvas = this.canvas.fg,
        ctx = bgcanvas.getContext('2d'),
        self = this,
        URL = window.URL || window.webkitURL,
        BLANK_IMG = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
        img = this.imagePool.next();

      img.onload = function () {
        console.log("image")
        fgcanvas.width = bgcanvas.width = img.width
        fgcanvas.height = bgcanvas.height = img.height


        ctx.drawImage(img, 0, 0, img.width, img.height);
        self.resizeScreen(img);

        // Try to forcefully clean everything to get rid of memory
        // leaks. Note self despite this effort, Chrome will still
        // leak huge amounts of memory when the developer tools are
        // open, probably to save the resources for inspection. When
        // the developer tools are closed no memory is leaked.
        img.onload = img.onerror = null
        img.src = BLANK_IMG
        img = null
        blob = null

        URL.revokeObjectURL(url)
        url = null
        dtd.resolve();
      }

      img.onerror = function () {
        // Happily ignore. I suppose this shouldn't happen, but
        // sometimes it does, presumably when we're loading images
        // too quickly.

        // Do the same cleanup here as in onload.
        img.onload = img.onerror = null
        img.src = BLANK_IMG
        img = null
        blob = null

        URL.revokeObjectURL(url)
        url = null
        dtd.reject();
      }
      var url = URL.createObjectURL(blob)
      img.src = url;
      return dtd;
    },
    openScreenStream: function () {
      var self = this;
      var BLANK_IMG =
        'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
      var protocol = location.protocol == "http:" ? "ws://" : "wss://"
      var ws = new WebSocket(this.deviceUrl.replace("http:", "ws:") + '/minicap');
      var canvas = document.getElementById('bgCanvas')
      var ctx = canvas.getContext('2d');
      var lastScreenSize = {
        screen: {},
        canvas: {}
      };

      this.screenWS = ws;
      var imagePool = new ImagePool(100);

      ws.onopen = function (ev) {
        console.log('screen websocket connected')
      };

      // FIXME(ssx): use pubsub is better
      var imageBlobBuffer = self.imageBlobBuffer;
      var imageBlobMaxLength = 300;

      ws.onmessage = function (message) {
        if (message.data instanceof Blob) {
          console.log("image received");
          $.publish("imagedata", message.data);

          var blob = new Blob([message.data], {
            type: 'image/jpeg'
          })

          imageBlobBuffer.push(blob);

          if (imageBlobBuffer.length > imageBlobMaxLength) {
            imageBlobBuffer.shift();
          }

          var img = imagePool.next();
          img.onload = function () {
            canvas.width = img.width
            canvas.height = img.height
            ctx.drawImage(img, 0, 0, img.width, img.height);
            self.resizeScreen(img);


            // Try to forcefully clean everything to get rid of memory
            // leaks. Note self despite this effort, Chrome will still
            // leak huge amounts of memory when the developer tools are
            // open, probably to save the resources for inspection. When
            // the developer tools are closed no memory is leaked.
            img.onload = img.onerror = null
            img.src = BLANK_IMG
            img = null
            blob = null

            URL.revokeObjectURL(url)
            url = null
          }

          img.onerror = function () {
            // Happily ignore. I suppose this shouldn't happen, but
            // sometimes it does, presumably when we're loading images
            // too quickly.

            // Do the same cleanup here as in onload.
            img.onload = img.onerror = null
            img.src = BLANK_IMG
            img = null
            blob = null

            URL.revokeObjectURL(url)
            url = null
          }

          var url = URL.createObjectURL(blob)
          img.src = url;
        } else if (/^data size:/.test(message.data)) {
          // console.log("receive message:", message.data)
        } else if (/^rotation/.test(message.data)) {
          self.rotation = parseInt(message.data.substr('rotation '.length), 10);
          console.log(self.rotation)
        } else {
          console.log("receive message:", message.data)
        }
      }

      ws.onclose = function (ev) {
        console.log("screen websocket closed", ev.code)
      }.bind(this)

      ws.onerror = function (ev) {
        console.log("screen websocket error")
      }
    },
    enableTouch: function () {
      /**
       * TOUCH HANDLING
       */
      var self = this;
      var element = this.canvas.fg;

      var screen = {
        bounds: {}
      }

      var ws = new WebSocket(this.deviceUrl.replace("http:", "ws:") + "/minitouch")
      ws.onerror = function (ev) {
        console.log("minitouch websocket error:", ev)
      }
      ws.onmessage = function (ev) {
        console.log("minitouch websocket receive message:", ev.data);
      }
      ws.onclose = function () {
        console.log("minitouch websocket closed");
      }
      var control = this.control = MiniTouch.createNew(ws);

      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 activeFinger(index, x, y, pressure) {
        var scale = 0.5 + pressure
        $(".finger-" + index)
          .addClass("active")
          .css("transform", 'translate3d(' + x + 'px,' + y + 'px,0)')
      }

      function deactiveFinger(index) {
        $(".finger-" + index).removeClass("active")
      }

      function mouseDownListener(event) {
        var e = event;
        if (e.originalEvent) {
          e = e.originalEvent
        }
        // Skip secondary click
        if (e.which === 3) {
          return
        }
        e.preventDefault()

        fakePinch = e.altKey
        calculateBounds()

        var x = e.pageX - screen.bounds.x
        var y = e.pageY - screen.bounds.y
        var pressure = 0.5
        activeFinger(0, e.pageX, e.pageY, pressure);

        var scaled = coords(screen.bounds.w, screen.bounds.h, x, y, self.rotation);
        control.touchDown(0, scaled.xP, scaled.yP, pressure);
        control.touchCommit();

        element.removeEventListener('mousemove', mouseHoverListener);
        element.addEventListener('mousemove', mouseMoveListener);
        document.addEventListener('mouseup', mouseUpListener);
      }

      function mouseMoveListener(event) {
        var e = event
        if (e.originalEvent) {
          e = e.originalEvent
        }
        // Skip secondary click
        if (e.which === 3) {
          return
        }
        e.preventDefault()

        var pressure = 0.5
        activeFinger(0, e.pageX, e.pageY, pressure);
        var x = e.pageX - screen.bounds.x
        var y = e.pageY - screen.bounds.y
        var scaled = coords(screen.bounds.w, screen.bounds.h, x, y, self.rotation);
        control.touchMove(0, scaled.xP, scaled.yP, pressure);
        control.touchCommit();
      }

      function mouseUpListener(event) {
        var e = event
        if (e.originalEvent) {
          e = e.originalEvent
        }
        // Skip secondary click
        if (e.which === 3) {
          return
        }
        e.preventDefault()

        control.touchUp(0)
        control.touchCommit();
        stopMousing()
      }

      function stopMousing() {
        element.removeEventListener('mousemove', mouseMoveListener);
        // element.addEventListener('mousemove', mouseHoverListener);
        document.removeEventListener('mouseup', mouseUpListener);
        deactiveFinger(0);
      }

      function mouseHoverListener(event) {
        var e = event;
        if (e.originalEvent) {
          e = e.originalEvent
        }
        // Skip secondary click
        if (e.which === 3) {
          return
        }
        e.preventDefault()

        var x = e.pageX - screen.bounds.x
        var y = e.pageY - screen.bounds.y
      }

      function markPosition(pos) {
        var ctx = self.canvas.fg.getContext("2d");
        ctx.fillStyle = '#ff0000'; // red
        ctx.beginPath()
        ctx.arc(pos.x, pos.y, 12, 0, 2 * Math.PI)
        ctx.closePath()
        ctx.fill()

        ctx.fillStyle = "#fff"; // white
        ctx.beginPath()
        ctx.arc(pos.x, pos.y, 8, 0, 2 * Math.PI)
        ctx.closePath()
        ctx.fill();
      }

      var wheelTimer, fromYP;

      function mouseWheelDelayTouchUp() {
        clearTimeout(wheelTimer);
        wheelTimer = setTimeout(function () {
          fromYP = null;
          control.touchUp(1)
          control.touchCommit();
          // deactiveFinger(0);
          // deactiveFinger(1);
        }, 100)
      }

      function mouseWheelListener(event) {
        var e = event;
        if (e.originalEvent) {
          e = e.originalEvent
        }
        e.preventDefault()
        calculateBounds()

        var x = e.pageX - screen.bounds.x
        var y = e.pageY - screen.bounds.y
        var pressure = 0.5;
        var scaled;

        if (!fromYP) {
          fromYP = y / screen.bounds.h; // display Y percent
          // touch down when first detect mousewheel
          scaled = coords(screen.bounds.w, screen.bounds.h, x, y, self.rotation);
          control.touchDown(1, scaled.xP, scaled.yP, pressure);
          control.touchCommit();
          // activeFinger(0, e.pageX, e.pageY, pressure);
        }
        // caculate position after scroll
        var toYP = fromYP + (event.wheelDeltaY < 0 ? -0.05 : 0.05);
        toYP = Math.max(0, Math.min(1, toYP));

        var step = Math.max((toYP - fromYP) / 5, 0.01) * (event.wheelDeltaY < 0 ? -1 : 1);
        for (var yP = fromYP; yP < 1 && yP > 0 && Math.abs(yP - toYP) > 0.0001; yP += step) {
          y = screen.bounds.h * yP;
          var pageY = y + screen.bounds.y;
          scaled = coords(screen.bounds.w, screen.bounds.h, x, y, self.rotation);
          // activeFinger(1, e.pageX, pageY, pressure);
          control.touchMove(1, scaled.xP, scaled.yP, pressure);
          control.touchWait(10);
          control.touchCommit();
        }
        fromYP = toYP;
        mouseWheelDelayTouchUp()
      }

      /* bind listeners */
      element.addEventListener('mousedown', mouseDownListener);
      // element.addEventListener('mousemove', mouseHoverListener);
      element.addEventListener('mousewheel', mouseWheelListener);
    }
  }
})

================================================
FILE: assets/style.css
================================================
body {
  font-family: "Segoe UI", Arial, "Microsoft Yahei", sans-serif;
}

.color-inverse {
  color: white;
  background-color: black;
}

.color-green {
  color: green;
}

.color-red {
  color: red;
}

.color-rest {
  color: rgb(74, 201, 89);
}

.color-busy {
  color: tomato;
}

.color-yellow {
  color: yellowgreen;
}

.fa-fix-height {
  line-height: 24px;
}

.fa-battery-4 {
  color: green;
}

.fa-battery-3 {
  color: yellowgreen;
}

.fa-battery-2 {
  color: yellow;
}

.fa-battery-1 {
  color: red;
}

.fa-battery-0 {
  color: red;
}

tr.offline {
  /* background-color: red; */
  color: gray;
}

================================================
FILE: assets/vue-components.js
================================================
/* require fontawesome
*/

/*
Example:
<editable-span :content="content" @change="content=$event"/>
*/
Vue.component('editable-span', {
    template: `<div>
    <template v-if="!editMode">
        <span @dblclick="editContent" v-text="content"></span>
        <i class="fa fa-edit" @click="editContent"></i>
    </template>
    <div v-show="editMode">
        <input ref="ipt" v-model="newContent" @keyup.enter="saveContent"/>
        <i @click="saveContent" class="fa fa-save"></i>
        <i class="fa fa-undo" @click="editMode=false"></i>
    </div>
    </div>
    `,
    props: ['content'],
    data: function () {
        return {
            editMode: false,
            newContent: "",
        }
    },
    methods: {
        editContent: function () {
            this.newContent = this.content;
            this.editMode = true;
            this.$nextTick(function () {
                this.$refs.ipt.focus()
            }.bind(this))
        },
        saveContent: function () {
            this.editMode = false;
            this.$emit("change", this.newContent);
        }
    }
})

================================================
FILE: database.go
================================================
package main

import (
	"context"
	"encoding/json"
	"strings"
	"time"

	"github.com/openatx/atx-server/proto"
	"github.com/qiniu/log"
	r "gopkg.in/gorethink/gorethink.v4"
)

var (
	db *RdbUtils
)

func initDB(address, dbName string) {
	r.SetTags("gorethink", "json")
	r.SetVerbose(true)
	session, err := r.Connect(r.ConnectOpts{
		Address:    address,
		Database:   dbName,
		InitialCap: 1,
		MaxOpen:    10,
	})

	if err != nil {
		log.Fatal(err)
	}
	db = &RdbUtils{session}

	// initial state
	if err := db.DBCreateAnyway(dbName); err != nil {
		panic(err)
	}
	log.Println("create tables")
	db.TableMustCreate("devices", r.TableCreateOpts{
		PrimaryKey: "udid",
	})
	db.TableMustCreate("products")
	db.TableMustCreate("providers")

	r.Table("devices").Update(map[string]interface{}{
		"present":     false,
		"using":       false,
		"provider_id": 0,
	}).Exec(session)

	if err := r.Table("devices").IndexCreate("provider_id", r.IndexCreateOpts{}).Exec(session); err != nil {
		log.Println("create index", err)
	}

	r.Table("providers").Update(proto.Provider{
		Present: newBool(false),
	}).Exec(session)
}

type RdbUtils struct {
	session *r.Session
}

func (db *RdbUtils) DBCreateAnyway(name string) error {
	res, err := r.DBList().Run(db.session)
	if err != nil {
		return err
	}
	defer res.Close()
	var dbNames []string
	if err := res.All(&dbNames); err != nil {
		return err
	}
	for _, dbName := range dbNames {
		log.Println("found db:", dbName)
		if dbName == name {
			log.Println("db exists atxserver")
			return nil
		}
	}
	err = r.DBCreate(name).Exec(db.session)
	return err
}

func (db *RdbUtils) TableMustCreate(name string, optArgs ...r.TableCreateOpts) {
	if err := db.TableCreateAnyway(name, optArgs...); err != nil {
		panic(err)
	}
}

func (db *RdbUtils) TableCreateAnyway(name string, optArgs ...r.TableCreateOpts) error {
	err := r.TableCreate(name, optArgs...).Exec(db.session)
	if err != nil && strings.Contains(err.Error(), "already exists") {
		return nil
	}
	return err
}

// DeviceUpdateOrInsert called when device plugin
func (db *RdbUtils) DeviceUpdateOrInsert(dev proto.DeviceInfo) error {
	dev.Present = newBool(true)
	dev.PresenceChangedAt = time.Now()
	// only update when create
	dev.Ready = newBool(false)
	dev.Using = newBool(false)
	dev.CreatedAt = time.Now()
	_, err := r.Table("devices").Insert(dev, r.InsertOpts{
		Conflict: func(id, oldDoc, newDoc r.Term) interface{} {
			return oldDoc.Merge(newDoc.Without("createdAt", "ready", "using")).Merge(map[string]interface{}{
				"createdAt": oldDoc.Field("createdAt").Default(time.Now()),
				"ready":     oldDoc.Field("ready").Default(false),
				"using":     oldDoc.Field("using").Default(false),
			})
		},
	}).RunWrite(db.session)
	return err
}

func (db *RdbUtils) DeviceUpdate(udid string, arg interface{}) error {
	_, err := r.Table("devices").Get(udid).Update(arg).RunWrite(db.session)
	return err
}

func (db *RdbUtils) DeviceList() (devices []proto.DeviceInfo, err error) {
	res, err := r.Table("devices").
		OrderBy(r.Desc("present"), r.Desc("ready"), r.Desc("using"), r.Desc("presenceChangedAt")).
		Merge(func(p r.Term) interface{} {
			return map[string]interface{}{
				"product_id":  r.Table("products").Get(p.Field("product_id").Default(0)),
				"provider_id": r.Table("providers").Get(p.Field("provider_id").Default(0)),
			}
		}).Run(db.session)
	if err != nil {
		log.Error(err)
		return
	}
	defer res.Close()
	err = res.All(&devices)
	return
}

func (db *RdbUtils) DeviceGet(udid string) (info proto.DeviceInfo, err error) {
	res, err := r.Table("devices").Get(udid).
		Merge(func(p r.Term) interface{} {
			return map[string]interface{}{
				"product_id":  r.Table("products").Get(p.Field("product_id").Default(0)),
				"provider_id": r.Table("providers").Get(p.Field("provider_id").Default(0)),
			}
		}).Run(db.session)
	if err != nil {
		return
	}
	defer res.Close()
	err = res.One(&info)
	return
}

func (db *RdbUtils) DeviceFindAll(info proto.DeviceInfo) (infos []proto.DeviceInfo) {
	infojson, _ := json.Marshal(info)
	log.Debugf("query %s", string(infojson))
	res, err := r.Table("devices").Filter(info).
		Merge(func(p r.Term) interface{} {
			return map[string]interface{}{
				"product_id":  r.Table("products").Get(p.Field("product_id").Default(0)),
				"provider_id": r.Table("providers").Get(p.Field("provider_id").Default(0)),
			}
		}).Run(db.session)
	if err != nil {
		log.Error(err)
		return nil
	}
	defer res.Close()
	if err := res.All(&infos); err != nil {
		log.Error(err)
	}
	return
}

// ProviderFindAll get all providers
func (db *RdbUtils) ProvidersAll() (providers []proto.Provider, err error) {
	res, err := r.Table("providers").OrderBy(r.Desc("present"), "id").
		Merge(func(p r.Term) interface{} {
			return map[string]interface{}{
				"devices": r.Table("devices").
					GetAllByIndex("provider_id", p.Field("id")).
					Without("product_id", "provider_id", "battery").CoerceTo("array"),
			}
		}).Run(db.session)
	if err != nil {
		return nil, err
	}
	defer res.Close()
	err = res.All(&providers)
	return
}

// SetDevicePresent change present status
func (db *RdbUtils) SetDeviceAbsent(udid string) error {
	log.Debugf("device absent: %s", udid)
	return db.DeviceUpdate(udid, proto.DeviceInfo{
		Present:           newBool(false),
		PresenceChangedAt: time.Now(),
	})
}

func (db *RdbUtils) WatchDeviceChanges() (feeds chan r.ChangeResponse, cancel func(), err error) {
	ctx, cancel := context.WithCancel(context.Background())
	res, err := r.Table("devices").Changes().Run(db.session, r.RunOpts{
		Context: ctx,
	})
	if err != nil {
		return
	}
	feeds = make(chan r.ChangeResponse)
	var change r.ChangeResponse
	go func() {
		for res.Next(&change) {
			feeds <- change
		}
		close(feeds)
	}()
	return
}

func (db *RdbUtils) ProductsFindAll(brand, model string) (products []proto.Product, err error) {
	res, err := r.Table("products").Filter(proto.Product{Brand: brand, Model: model}).Run(db.session)
	if err != nil {
		return
	}
	if err = res.All(&products); err != nil {
		return
	}
	if len(products) > 0 {
		return
	}
	resp, err := r.Table("products").Insert(proto.Product{Brand: brand, Model: model}).RunWrite(db.session)
	if err != nil {
		return
	}
	if len(resp.GeneratedKeys) != 1 {
		panic("generatedKeys must be one")
	}
	return db.ProductsFindAll(brand, model)
}

func (db *RdbUtils) ProductUpdate(id string, product proto.Product) error {
	product.Id = ""
	_, err := r.Table("products").Get(id).Update(product).RunWrite(db.session)
	return err
}

// ProviderUpdateOrInsert will create a record if not exists
func (db *RdbUtils) ProviderUpdateOrInsert(machineId string, ip string, port int) error {
	p := proto.Provider{
		Id:                machineId,
		IP:                ip,
		Port:              port,
		Present:           newBool(true),
		CreatedAt:         time.Now(),
		PresenceChangedAt: time.Now(),
	}
	_, err := r.Table("providers").Insert(p, r.InsertOpts{
		Conflict: func(id, oldDoc, newDoc r.Term) interface{} {
			return oldDoc.Merge(newDoc.Without("createdAt")).Merge(map[string]interface{}{
				"createdAt": oldDoc.Field("createdAt").Default(time.Now()),
			})
		},
	}).RunWrite(db.session)
	return err
}

func (db *RdbUtils) ProviderUpdate(id string, provider proto.Provider) error {
	provider.Id = id
	_, err := r.Table("providers").Get(id).Update(provider).RunWrite(db.session)
	return err
}

func (db *RdbUtils) ProviderOffline(id string) error {
	_, err := r.Table("providers").Get(id).Update(proto.Provider{
		Present: newBool(false),
	}).RunWrite(db.session)
	if err != nil {
		return err
	}
	_, err = r.Table("devices").Filter(r.Row.Field("provider_id").Eq(id)).Update(map[string]interface{}{
		"provider_id": 0,
	}).RunWrite(db.session)
	return err
}

func (db *RdbUtils) ProviderGet(id string) (provider proto.Provider, err error) {
	res, err := r.Table("providers").Get(id).Run(db.session)
	if err != nil {
		return
	}
	defer res.Close()
	err = res.One(&provider)
	return
}


================================================
FILE: database_test.go
================================================
package main

import (
	"testing"

	r "gopkg.in/gorethink/gorethink.v4"
)

func TestInsertOrUpdateDevice(t *testing.T) {
	mock := r.NewMock()
	// mock.On(r.Table("devices")).
	_ = mock
}

// func TestTableProduct(t *testing.T) {

// 	device, err := db.DeviceGet("6EB0217607005249-c4:86:e9:53:c2:e4-DUK-AL20")
// 	if err != nil {
// 		t.Fatal(err)
// 	}
// 	t.Logf("%#v", device)
// }


================================================
FILE: docker-compose.yml
================================================
version: '3'
services:
  atxserver:
    build: .
    container_name: atxserver
    ports:
      - "8000:8000"
    depends_on:
      - rethinkdb
    entrypoint: ./atx-server --rdbaddr rethinkdb:28015 --port 8000
  rethinkdb:
    image: rethinkdb:2.3.6
    container_name: rethinkdb
    ports:
      - "8001:8080" # expose rethinkdb web console
    volumes:
      - "$PWD/data:/data"


================================================
FILE: go.mod
================================================
module github.com/openatx/atx-server

require (
	github.com/alecthomas/kingpin v2.2.6+incompatible
	github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc
	github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf
	github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 // indirect
	github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
	github.com/codeskyblue/dingrobot v0.0.0-20171214074958-18a295f1ddd8
	github.com/codeskyblue/heartbeat v0.0.0-20180510083815-41c9a36c9169
	github.com/codeskyblue/realip v0.0.0-20180509031353-57e9cd075d0e
	github.com/golang/protobuf v1.2.0
	github.com/gorilla/context v1.1.1
	github.com/gorilla/mux v1.6.2
	github.com/gorilla/websocket v1.4.0
	github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed
	github.com/koding/websocketproxy v0.0.0-20180716164433-0fa3f994f6e7
	github.com/mash/go-accesslog v0.0.0-20180522074327-610c2be04217
	github.com/mattn/go-isatty v0.0.4
	github.com/openatx/androidutils v1.0.0
	github.com/opentracing/opentracing-go v1.0.2
	github.com/pkg/errors v0.8.0 // indirect
	github.com/qiniu/log v0.0.0-20140728010919-a304a74568d6
	github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
	golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869
	golang.org/x/net v0.0.0-20181114220301-adae6a3d119a
	golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect
	golang.org/x/sys v0.0.0-20181121002834-0cf1ed9e522b
	gopkg.in/gorethink/gorethink.v4 v4.1.0
)


================================================
FILE: go.sum
================================================
github.com/alecthomas/kingpin v2.2.6+incompatible h1:5svnBTFgJjZvGKyYBtMB0+m5wvrbUHiqye8wRJMlnYI=
github.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/cenkalti/backoff v2.0.0+incompatible h1:5IIPUHhlnUZbcHQsQou5k1Tn58nJkeJL9U+ig5CHJbY=
github.com/cenkalti/backoff v2.0.0+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/codeskyblue/dingrobot v0.0.0-20171214074958-18a295f1ddd8 h1:+Jo1zBcaPTAj7hYZh4eJhuqzOpnsuX8dgtR2YC2k2Ws=
github.com/codeskyblue/dingrobot v0.0.0-20171214074958-18a295f1ddd8/go.mod h1:vQjYKkK66U6tqIBdXHZotqxqYXLfYLJsQFNSVuwLoNM=
github.com/codeskyblue/heartbeat v0.0.0-20180510083815-41c9a36c9169 h1:P+SqriwlWNS+MKgPC6JTs7Lipgk+yScuxzqFAvtVXNY=
github.com/codeskyblue/heartbeat v0.0.0-20180510083815-41c9a36c9169/go.mod h1:L/Vc8wjkglTA5/s1l9/4YOa8e7sOzt4WerxU1AEC0LM=
github.com/codeskyblue/realip v0.0.0-20180509031353-57e9cd075d0e h1:ImKTOlij69pQ3ftNIpBCgTpz52NLJmn3NyHITlgOwVM=
github.com/codeskyblue/realip v0.0.0-20180509031353-57e9cd075d0e/go.mod h1:S09jNhPIOrprdcNbJtRvu8SxrMPp61FQ2GEOm1So8u0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/koding/websocketproxy v0.0.0-20180716164433-0fa3f994f6e7 h1:UPc4az2SLy5Usu+JKfOV4KtfzuRQXXUxY6QOWf9QBJU=
github.com/koding/websocketproxy v0.0.0-20180716164433-0fa3f994f6e7/go.mod h1:Nn5wlyECw3iJrzi0AhIWg+AJUb4PlRQVW4/3XHH1LZA=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mash/go-accesslog v0.0.0-20180522074327-610c2be04217 h1:oWyemD7bnPAGRGGPE22W1Z+kspkC7Uclz5rdzgxxiwk=
github.com/mash/go-accesslog v0.0.0-20180522074327-610c2be04217/go.mod h1:5JLTyA+23fYz/BfD5Hn736mGEZopzWtEx1pdNfnTp8k=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/openatx/androidutils v1.0.0 h1:gYKFX/LqOf4LxyO7dZrNfGtPNaCaSNrniUHL06MPATQ=
github.com/openatx/androidutils v1.0.0/go.mod h1:Pbja6rsE71OHQMhrK/tZm86fqB9Go8sXToi9CylrXEU=
github.com/opentracing/opentracing-go v1.0.2 h1:3jA2P6O1F9UOrWVpwrIo17pu01KWvNWg4X946/Y5Zwg=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qiniu/log v0.0.0-20140728010919-a304a74568d6 h1:pMot4yzjv8CtGQiGhRvVhwhQl3g4D0Hwkmyd0CVCiyk=
github.com/qiniu/log v0.0.0-20140728010919-a304a74568d6/go.mod h1:WSWulkCEBvfLKNNmweUmJjQGNaYzdHqpTRITXdUNQiQ=
github.com/sirupsen/logrus v1.0.6 h1:hcP1GmhGigz/O7h1WVUM5KklBp1JoNS9FggWKdj/j3s=
github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
golang.org/x/crypto v0.0.0-20180820150726-614d502a4dac/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869 h1:kkXA53yGe04D0adEYJwEVQjeBppL01Exg+fnMjfUraU=
golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a h1:gOpx8G595UYyvj8UK4+OFyY4rx037g3fmfhe5SasG3U=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180828065106-d99a578cf41b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181121002834-0cf1ed9e522b h1:fpg9kqwtLzitbbnpLJATV5Ty8sDv8sJ2ii9+e6fG89A=
golang.org/x/sys v0.0.0-20181121002834-0cf1ed9e522b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fatih/pool.v2 v2.0.0 h1:xIFeWtxifuQJGk/IEPKsTduEKcKvPmhoiVDGpC40nKg=
gopkg.in/fatih/pool.v2 v2.0.0/go.mod h1:8xVGeu1/2jr2wm5V9SPuMht2H5AEmf5aFMGSQixtjTY=
gopkg.in/gorethink/gorethink.v4 v4.1.0 h1:xoE9qJ9Ae9KdKEsiQGCF44u2JdnjyohrMBRDtts3Gjw=
gopkg.in/gorethink/gorethink.v4 v4.1.0/go.mod h1:M7JgwrUAmshJ3iUbEK0Pt049MPyPK+CYDGGaEjdZb/c=


================================================
FILE: heartbeat/heartbeat.go
================================================
/*
FormValue id and port is required

Client send request example

$ curl -X POST -F id=cfa124af -F port=8000
*/
package heartbeat

import (
	"io"
	"net/http"
	"strconv"
	"sync"
	"time"

	"github.com/tomasen/realip"
)

type Server struct {
	sessions map[string]*Session
	mu       sync.RWMutex
	receiver Receiver
}

// NewServer return http.Handler
func NewServer(receiver Receiver) *Server {
	return &Server{
		sessions: make(map[string]*Session),
		receiver: receiver,
	}
}

func (h *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	id := r.FormValue("id")
	if id == "" {
		http.Error(w, "param id is required", 400)
		return
	}
	port, _ := strconv.Atoi(r.FormValue("port"))
	if port == 0 {
		http.Error(w, "param port is required", 400)
		return
	}
	ip := r.FormValue("ip")
	if ip == "" {
		ip = realip.FromRequest(r)
	}

	h.mu.Lock()
	defer h.mu.Unlock()
	ctx := Context{
		IP:      ip,
		ID:      id,
		Request: r,
	}
	sess, exists := h.sessions[id]
	if !exists {
		if err := h.receiver.OnConnect(ctx); err != nil {
			http.Error(w, err.Error(), 400)
			return
		}
		h.sessions[id] = &Session{
			Timeout:    time.Second * 15,
			sigC:       make(chan bool),
			remoteIP:   ip,
			remotePort: port,
		}
		go func() {
			h.sessions[id].drain()
			h.receiver.OnDisconnect(id)
			delete(h.sessions, id)
		}()
	} else {
		if ip != sess.remoteIP || port != sess.remotePort {
			sess.remoteIP = ip
			sess.remotePort = port
			if err := h.receiver.OnConnect(ctx); err != nil {
				http.Error(w, err.Error(), 400)
				return
			}
		}
		sess.Update()
	}
	if r.FormValue("data") == "" || r.FormValue("data") == "null" {
		io.WriteString(w, "success ping\n")
		return
	}
	if err := h.receiver.OnRequest(ctx); err != nil {
		http.Error(w, err.Error(), 400)
	} else {
		io.WriteString(w, "success request\n")
	}
}

// Receiver defines on request
type Receiver interface {
	OnConnect(ctx Context) error
	OnDisconnect(id string)
	OnRequest(ctx Context) error
}

type Session struct {
	id         string
	remoteIP   string
	remotePort int
	Timeout    time.Duration
	sigC       chan bool
}

func (hs *Session) Update() {
	select {
	case hs.sigC <- true:
	case <-time.After(100 * time.Millisecond):
	}
}

func (hs *Session) drain() {
	for {
		select {
		case <-time.After(hs.Timeout):
			return
		case <-hs.sigC:
		}
	}
}

type Context struct {
	Request *http.Request
	IP      string
	ID      string
}


================================================
FILE: hostsmanager.go
================================================
package main

import (
	"errors"
	"strings"

	"github.com/openatx/atx-server/proto"
)

func deviceQueryToUdid(query string) (udid string, err error) {
	if strings.HasPrefix(query, "ip:") {
		infos := db.DeviceFindAll(proto.DeviceInfo{IP: query[3:], Present: newBool(true)})
		return extractUdidFromInfos(infos)
	}
	return query, nil
}

func extractUdidFromInfos(infos []proto.DeviceInfo) (udid string, err error) {
	if len(infos) == 0 {
		return "", errors.New("not found")
	}
	if len(infos) > 1 {
		return "", errors.New("too many matches")
	}
	return infos[0].Udid, nil
}

// // TODO: need to delete bellow
// type HostsManager struct {
// 	maps map[string]*proto.DeviceInfo
// 	mu   sync.RWMutex
// }

// func NewHostsManager() *HostsManager {
// 	return &HostsManager{
// 		maps: make(map[string]*proto.DeviceInfo),
// 	}
// }

// func (t *HostsManager) Lookup(query string) *proto.DeviceInfo {
// 	if strings.HasPrefix(query, "ip:") {
// 		return t.FromIP(query[3:])
// 	}
// 	return t.FromUdid(query)
// }

// // A return value of nil indicates not found
// func (t *HostsManager) FromIP(ip string) *proto.DeviceInfo {
// 	t.mu.Lock()
// 	defer t.mu.Unlock()
// 	for _, info := range t.maps {
// 		if info.IP == ip {
// 			return info
// 		}
// 	}
// 	return nil
// }

// // A return value of nil indicates not found
// func (t *HostsManager) FromUdid(udid string) *proto.DeviceInfo {
// 	t.mu.Lock()
// 	defer t.mu.Unlock()
// 	return t.maps[udid]
// }

// func (t *HostsManager) AddFromDeviceInfo(devInfo *proto.DeviceInfo) {
// 	t.mu.Lock()
// 	defer t.mu.Unlock()
// 	udid := devInfo.Udid
// 	if info, ok := t.maps[udid]; ok {
// 		info.IP = devInfo.IP
// 		info.ConnectionCount++
// 	} else {
// 		devInfo.ConnectionCount = 1
// 		t.maps[udid] = devInfo
// 	}
// }

// func (t *HostsManager) Remove(udid string) {
// 	t.mu.Lock()
// 	defer t.mu.Unlock()
// 	if info, ok := t.maps[udid]; ok {
// 		info.ConnectionCount--
// 		if info.ConnectionCount <= 0 {
// 			delete(t.maps, udid)
// 		}
// 	}
// }

// func (t *HostsManager) Acquire(query string) error {
// 	info := t.Lookup(query)
// 	if info == nil {
// 		return errors.New("device not found")
// 	}
// 	if info.Reserved != "" {
// 		return errors.New("device already reserved")
// 	}
// 	info.Reserved = "hzsunshx"
// 	return nil
// }

// func (t *HostsManager) Release(query string) error {
// 	info := t.Lookup(query)
// 	if info == nil {
// 		return errors.New("device not found")
// 	}
// 	info.Reserved = ""
// 	return nil
// }

// func (t *HostsManager) Random() (devInfo *proto.DeviceInfo, err error) {
// 	t.mu.Lock()
// 	defer t.mu.Unlock()
// 	for _, info := range t.maps {
// 		if info.Ready != nil && *info.Ready == true && info.Reserved == "" {
// 			info.Reserved = "random"
// 			return info, nil
// 		}
// 	}
// 	return nil, errors.New("no devices avaliable")
// }


================================================
FILE: httplog.go
================================================
package main

import (
	"fmt"
	"log"
	"os"
	"regexp"
	"runtime"
	"strings"

	accesslog "github.com/mash/go-accesslog"
	isatty "github.com/mattn/go-isatty"
)

var logger *log.Logger
var isaTTY = isatty.IsTerminal(os.Stdout.Fd())

func init() {
	if runtime.GOOS == "windows" {
		logger = log.New(os.Stdout, "", log.Ltime)
	} else {
		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) {
	// update info too many just ignore
	if record.Method == "POST" && regexp.MustCompile(`/devices/[^/]+/info`).MatchString(record.Uri) {
		return
	}
	if strings.HasSuffix(record.Uri, "/heartbeat") {
		return
	}

	if isaTTY {
		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))
	} else {
		logger.Println(fmt.Sprintf("%d %s %s (%s) %.2fms", record.Status, record.Method, record.Uri, record.Ip,
			float64(record.ElapsedTime.Nanoseconds()/1000)/1000.0))
	}
}


================================================
FILE: httpserver.go
================================================
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"io"
	"io/ioutil"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/codeskyblue/heartbeat"
	"github.com/codeskyblue/realip"
	"github.com/gorilla/mux"
	"github.com/gorilla/websocket"
	"github.com/koding/websocketproxy"
	hb2 "github.com/openatx/atx-server/heartbeat"
	"github.com/openatx/atx-server/proto"
	"github.com/qiniu/log"
)

var (
	upgrader = websocket.Upgrader{
		CheckOrigin: func(r *http.Request) bool {
			return true
		},
	}

	// Time allowed to write message to the client
	wsWriteWait = 10 * time.Second

	// Send pings to client with this period. Must be less than pongWait.
	wsPingPeriod = 10 * time.Second

	// Time allowed to read the next pong message from client
	wsPongWait = wsPingPeriod * 3

	funcMap template.FuncMap
)

func init() {
	funcMap = template.FuncMap{
		"title": strings.Title,
		"urlhash": func(s string) string {
			path := strings.TrimPrefix(s, "/")
			info, err := os.Stat(path)
			if err != nil {
				return s + "#no-such-file"
			}
			return fmt.Sprintf("%s?t=%d", s, info.ModTime().Unix())
		},
	}
}

func renderHTML(w http.ResponseWriter, filename string, value interface{}) {
	tmpl := template.Must(template.New("").Funcs(funcMap).Delims("[[", "]]").ParseGlob("templates/*.html"))
	tmpl.ExecuteTemplate(w, filename, value)
	// content, _ := ioutil.ReadFile("templates/" + filename)
	// template.Must(template.New(filename).Parse(string(content))).Execute(w, nil)
}

func renderJSON(w http.ResponseWriter, data interface{}) {
	js, err := json.Marshal(data)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json; charset=UTF-8")
	w.Header().Set("Content-Length", fmt.Sprintf("%d", len(js)))
	w.Write(js)
}

func newHandler() http.Handler {
	r := mux.NewRouter()
	r.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
		renderJSON(w, map[string]string{
			"server":    version,
			"atx-agent": atxAgentVersion,
		})
	})

	// 设备远程控制
	r.HandleFunc("/devices/ip:{ip}/remote", func(w http.ResponseWriter, r *http.Request) {
		ip := mux.Vars(r)["ip"]
		renderHTML(w, "remote.html", ip)
	}).Methods("GET")

	r.HandleFunc("/devices/{udid}/remote", func(w http.ResponseWriter, r *http.Request) {
		udid := mux.Vars(r)["udid"]
		info, err := db.DeviceGet(udid)
		if err != nil {
			http.Error(w, err.Error(), 404)
			return
		}
		renderHTML(w, "remote.html", map[string]interface{}{
			"IP":   info.IP,
			"Port": info.Port,
			"Udid": udid})
	}).Methods("GET")

	// 设备信息修改
	r.HandleFunc("/devices/{udid}/edit", func(w http.ResponseWriter, r *http.Request) {
		udid := mux.Vars(r)["udid"]
		renderHTML(w, "edit.html", udid)
	}).Methods("GET")

	// Video-backend starts
	videoProxyURL, _ := url.Parse(*videoBackend)
	wsProxyURL, _ := url.Parse(*videoBackend)
	wsProxyURL.Scheme = "ws"

	videoProxy := httputil.NewSingleHostReverseProxy(videoProxyURL)
	wsVideoProxy := websocketproxy.NewProxy(wsProxyURL)

	r.PathPrefix("/videos").Handler(videoProxy).Methods("GET", "DELETE")
	r.Handle("/video/images2video", videoProxy) // not working with POST proxy
	r.PathPrefix("/static/videos/").Handler(videoProxy)
	r.Handle("/video/convert", wsVideoProxy)
	// End of video-backend

	r.HandleFunc("/products/{brand}/{model}", func(w http.ResponseWriter, r *http.Request) {
		vars := mux.Vars(r)
		brand, model := vars["brand"], vars["model"]
		products, err := db.ProductsFindAll(brand, model)
		if err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		renderJSON(w, products)
	})

	r.HandleFunc("/devices/{udid}/product", func(w http.ResponseWriter, r *http.Request) {
		var product proto.Product
		err := json.NewDecoder(r.Body).Decode(&product)
		if err != nil {
			http.Error(w, err.Error(), http.StatusForbidden)
			return
		}
		if product.Id == "" {
			http.Error(w, "product id is required", http.StatusForbidden)
			return
		}
		if err := db.ProductUpdate(product.Id, product); err != nil {
			http.Error(w, err.Error(), http.StatusForbidden)
			return
		}
		err = db.DeviceUpdate(mux.Vars(r)["udid"], proto.DeviceInfo{
			Product: &proto.Product{
				Id: product.Id,
			},
		})
		if err != nil {
			http.Error(w, err.Error(), http.StatusForbidden)
			return
		}
		renderJSON(w, map[string]interface{}{
			"success": true,
		})
	}).Methods("PUT")

	r.HandleFunc("/echo", echo)

	r.HandleFunc("/feeds", func(w http.ResponseWriter, r *http.Request) {
		ws, err := upgrader.Upgrade(w, r, nil)
		if err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		defer ws.Close()
		feeds, cancel, err := db.WatchDeviceChanges()
		if err != nil {
			ws.WriteJSON(map[string]string{
				"error": err.Error(),
			})
			return
		}
		go func() {
			defer cancel()
			for {
				_, _, err := ws.ReadMessage()
				if err != nil {
					break
				}
			}
			log.Debug("ws read closed")
		}()
		for change := range feeds {
			buf := bytes.NewBuffer(nil)
			json.NewEncoder(buf).Encode(map[string]interface{}{
				"new": change.NewValue,
				"old": change.OldValue,
			})
			err := ws.WriteMessage(websocket.TextMessage, buf.Bytes()) // []byte(`{"new": "haha", "old": "wowo"}`))
			if err != nil {
				break
			}
		}
		log.Debug("ws write closed")
	})

	r.HandleFunc("/providers", func(w http.ResponseWriter, r *http.Request) {
		values := r.URL.Query()
		if _, ok := values["json"]; ok {
			providers, err := db.ProvidersAll()
			if err != nil {
				renderJSON(w, map[string]interface{}{
					"success":     false,
					"description": err.Error(),
				})
				return
			}
			renderJSON(w, providers)
			return
		}
		renderHTML(w, "providers.html", nil)
	})

	r.HandleFunc("/providers/{id}", func(w http.ResponseWriter, r *http.Request) {
		var p proto.Provider
		data, _ := ioutil.ReadAll(r.Body)
		if err := json.Unmarshal(data, &p); err != nil {
			renderJSON(w, map[string]interface{}{
				"success":     false,
				"description": err.Error(),
			})
			return
		}
		id := mux.Vars(r)["id"]
		db.ProviderUpdate(id, p)
		renderJSON(w, map[string]interface{}{
			"success": true,
		})
	}).Methods("PUT")

	r.HandleFunc("/api/v1/batch/unlock", func(w http.ResponseWriter, r *http.Request) {
		batchRunCommand("am start -W --user 0 -a com.github.uiautomator.ACTION_IDENTIFY; input keyevent HOME")
		io.WriteString(w, "Success")
	})

	r.HandleFunc("/api/v1/batch/lock", func(w http.ResponseWriter, r *http.Request) {
		batchRunCommand("input keyevent POWER")
		io.WriteString(w, "Success")
	})

	r.HandleFunc("/api/v1/batch/shell", func(w http.ResponseWriter, r *http.Request) {
		command := r.FormValue("command")
		batchRunCommand(command)
		io.WriteString(w, "Success")
	})

	// r.HandleFunc("/api/v1/phones/identify")
	r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		renderHTML(w, "index.html", nil)
	})

	r.PathPrefix("/assets").Handler(http.StripPrefix("/assets/", http.FileServer(http.Dir("./assets"))))
	r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
		http.ServeFile(w, r, "assets/favicon.ico")
	})

	r.HandleFunc("/list", func(w http.ResponseWriter, r *http.Request) {
		devices, err := db.DeviceList()
		if err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		renderJSON(w, devices)
	})

	r.HandleFunc("/devices/{query}/info", func(w http.ResponseWriter, r *http.Request) {
		query := mux.Vars(r)["query"]
		udid, err := deviceQueryToUdid(query)
		if err != nil {
			io.WriteString(w, "Failure, device "+query+" not found")
			return
		}
		if r.Method == "GET" {
			info, _ := db.DeviceGet(udid)
			renderJSON(w, info)
			return
		}
		// POST
		var info proto.DeviceInfo
		if err := json.NewDecoder(r.Body).Decode(&info); err != nil {
			io.WriteString(w, err.Error())
			return
		}
		db.DeviceUpdate(udid, info)
		io.WriteString(w, "Success")
	}).Methods("GET", "POST")

	r.HandleFunc("/property", func(w http.ResponseWriter, r *http.Request) {
		clientIp := realip.FromRequest(r)
		udid, err := deviceQueryToUdid("ip:" + clientIp)
		if err != nil {
			io.WriteString(w, "init with uiautomator2")
			return
		}
		info, err := db.DeviceGet(udid)
		if err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		if r.Method == "POST" {
			var id string = r.FormValue("id")
			if id == "" && r.FormValue("id_number") != "" {
				id = "HIH-PHO-" + r.FormValue("id_number")
			}
			db.DeviceUpdate(info.Udid, proto.DeviceInfo{
				PropertyId: id,
			})
			info.PropertyId = id
			io.WriteString(w, "<h1>Updated to "+id+"</h1>")
			return
		}
		renderHTML(w, "property.html", info.PropertyId)
	}).Methods("GET", "POST")

	r.HandleFunc("/devices/{query}/reserved", func(w http.ResponseWriter, r *http.Request) {
		query := mux.Vars(r)["query"]
		udid, err := deviceQueryToUdid(query)
		if err != nil {
			http.Error(w, "Device not found "+err.Error(), http.StatusGone)
			return
		}
		info, err := db.DeviceGet(udid)
		if err != nil {
			http.Error(w, "Device get error "+err.Error(), http.StatusGone)
			return
		}
		// create websocket connection
		ws, err := upgrader.Upgrade(w, r, nil)
		if err != nil {
			log.Println(err)
			return
		}
		defer ws.Close()
		if toBool(info.Using) {
			log.Printf("Device %s is using", udid)
			return
		}
		db.DeviceUpdate(info.Udid, proto.DeviceInfo{
			Using:        newBool(true),
			UsingBeganAt: time.Now(),
			Owner: &proto.OwnerInfo{
				IP: realip.FromRequest(r),
			},
		})
		defer func() {
			db.DeviceUpdate(udid, proto.DeviceInfo{
				Using: newBool(false),
			})
			go func() {
				port := info.Port
				if port == 0 {
					port = 7912
				}
				reqURL := "http://" + info.IP + ":" + strconv.Itoa(port) + "/shell"
				req, _ := http.NewRequest("GET", reqURL, nil)
				q := req.URL.Query()
				q.Add("command", "am start -n com.github.uiautomator/.IdentifyActivity")
				req.URL.RawQuery = q.Encode()

				resp, err := http.DefaultClient.Do(req)
				if err == nil {
					resp.Body.Close()
				}
			}()
		}()
		// wait until ws disconnected
		for {
			if _, _, err := ws.ReadMessage(); err != nil {
				return
			}
		}
	}).Methods("GET")

	r.HandleFunc("/devices/{query}/reserved", func(w http.ResponseWriter, r *http.Request) {
		query := mux.Vars(r)["query"]
		udid, err := deviceQueryToUdid(query)
		// info := hostsManager.Lookup(query)
		if err != nil {
			http.Error(w, "Device not found "+err.Error(), http.StatusGone)
			return
		}
		if r.Method == "POST" {
			info, err := db.DeviceGet(udid)
			if err != nil {
				http.Error(w, "Device get error "+err.Error(), http.StatusGone)
				return
			}
			if !toBool(info.Present) {
				http.Error(w, "Device offline", http.StatusGone)
				return
			}
			if toBool(info.Using) {
				http.Error(w, "Device is using", http.StatusForbidden)
				return
			}
			db.DeviceUpdate(info.Udid, proto.DeviceInfo{
				Using:        newBool(true),
				UsingBeganAt: time.Now(),
				Owner: &proto.OwnerInfo{
					IP: realip.FromRequest(r),
				},
			})
			io.WriteString(w, "Success")
			return
		}
		// DELETE
		db.DeviceUpdate(udid, proto.DeviceInfo{
			Using: newBool(false),
		})
		io.WriteString(w, "Release success")
	}).Methods("POST", "DELETE")

	r.HandleFunc("/devices/{query}/shell", func(w http.ResponseWriter, r *http.Request) {
		query := mux.Vars(r)["query"]
		udid, err := deviceQueryToUdid(query)
		if err != nil {
			http.Error(w, "Device not found", 410)
			return
		}
		info, err := db.DeviceGet(udid)
		if err != nil {
			http.Error(w, "Device get error "+err.Error(), 500)
			return
		}

		command := r.FormValue("command")
		output, err := runAndroidShell(info.IP, command)
		if err != nil {
			renderJSON(w, map[string]string{
				"error": err.Error(),
			})
		} else {
			w.Header().Set("Content-Type", "application/json; charset=UTF-8")
			io.WriteString(w, output) // the output is already json
		}
	}).Methods("POST")

	// heartbeat for reverse proxies (adb forward device 7912 port)
	hbs := heartbeat.NewServer("hello kitty", 15*time.Second)
	hbs.OnConnect = func(identifier string, r *http.Request) {
		host := realip.FromRequest(r)
		db.DeviceUpdateOrInsert(proto.DeviceInfo{
			Udid: identifier,
			IP:   host,
		})
		log.Println(identifier, "is online")
	}

	// Called when client ip changes
	hbs.OnReconnect = func(identifier string, r *http.Request) {
		host := realip.FromRequest(r)
		db.DeviceUpdateOrInsert(proto.DeviceInfo{
			Udid: identifier,
			IP:   host,
		})
		log.Println(identifier, "is reconnected")
	}

	hbs.OnDisconnect = func(identifier string) {
		db.SetDeviceAbsent(identifier)
		log.Println(identifier, "is offline")
	}
	r.Handle("/heartbeat", hbs)

	providerhbs := hb2.NewServer(&ProviderReceiver{})
	r.Handle("/provider/heartbeat", providerhbs)

	return r
}

type ProviderReceiver struct{}

func (p *ProviderReceiver) OnConnect(ctx hb2.Context) error {
	port, _ := strconv.Atoi(ctx.Request.FormValue("port"))
	if port == 0 {
		return errors.New("Request port is required")
	}
	log.Printf("Provider id:%s ip:%s port:%d is connected", ctx.ID, ctx.IP, port)
	return db.ProviderUpdateOrInsert(ctx.ID, ctx.IP, port)
}

func (p *ProviderReceiver) OnDisconnect(id string) {
	log.Printf("Provider %s disconnected", id)
	db.ProviderOffline(id)
}

/*
POST udid, status=<online|offline>
*/
func (p *ProviderReceiver) OnRequest(ctx hb2.Context) error {
	id, req := ctx.ID, ctx.Request
	data := req.FormValue("data")
	log.Println("Receive data:", data)
	var v struct {
		Status                string `json:"status"`
		Udid                  string `json:"udid"`
		ProviderForwardedPort int    `json:"providerForwardedPort"`
	}
	if err := json.Unmarshal([]byte(data), &v); err != nil {
		return err
	}
	status, udid := v.Status, v.Udid
	if status == "" || udid == "" {
		return errors.New("status or udid is empty")
	}
	provider, err := db.ProviderGet(id)
	if err != nil {
		log.Println("Unexpect err:", err)
		return err
	}
	var providerId = &provider.Id
	if status == "online" {
		log.Printf("Device: %s is plugged-in", udid)
	} else if status == "offline" {
		log.Printf("Device: %s is plugged-off", udid)
		providerId = nil
	} else {
		log.Printf("Invalid status: %s, only <offline|online> is allowed.", status)
		return errors.New("status is required")
	}

	return db.DeviceUpdate(udid, map[string]interface{}{
		"provider_id":           providerId,
		"providerForwardedPort": v.ProviderForwardedPort,
	})
}


================================================
FILE: main.go
================================================
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"math"
	"net"
	"net/http"
	"net/url"
	"sync"
	"time"

	"github.com/alecthomas/kingpin"
	"github.com/codeskyblue/dingrobot"
	"github.com/gorilla/websocket"
	"github.com/openatx/atx-server/proto"
	"github.com/qiniu/log"
)

const (
	version                = "dev"
	defaultATXAgentVersion = "0.4.3"
)

var (
	port            = kingpin.Flag("port", "http server listen port").Short('p').Default("8000").Int()
	addr            = kingpin.Flag("addr", "http server listen address").Default(":8000").String()
	rdbAddr         = kingpin.Flag("rdbaddr", "rethinkdb address").Default("localhost:28015").String()
	rdbName         = kingpin.Flag("rdbname", "rethinkdb database name").Default("atxserver").String()
	videoBackend    = kingpin.Flag("video-backend", "backend service for encoding images to video").Default("http://localhost:7000").String()
	atxAgentVersion string
	dingtalkToken   string
)

func handleWebsocketMessage(host string, message []byte) {
	return
}

func echo(w http.ResponseWriter, r *http.Request) {
	host, _, _ := net.SplitHostPort(r.RemoteAddr)
	ws, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Print("upgrade:", err)
		return
	}
	log.Debugf("new connection: %s", host)

	defer func() {
		log.Debugf("connection lost: %s", host)
		ws.Close()
	}()

	ws.SetReadDeadline(time.Now().Add(wsPongWait))
	ws.SetPongHandler(func(string) error {
		ws.SetReadDeadline(time.Now().Add(wsPongWait))
		return nil
	})

	// Read device info
	message := &proto.CommonMessage{}
	if err := ws.ReadJSON(message); err != nil {
		log.Warn("error: read json message")
		return
	}
	if message.Type != proto.DeviceInfoMessage {
		log.Warnf("error: first message must be device info, but got %v", message.Type)
		return
	}
	devInfo := new(proto.DeviceInfo)
	jsonData, _ := json.Marshal(message.Data)
	json.NewDecoder(bytes.NewReader(jsonData)).Decode(devInfo)
	if devInfo.Udid == "" {
		log.Warnf("error: udid is empty")
		return
	}
	devInfo.IP = host
	log.Debugf("client ip:%s product:%s brand:%s", devInfo.IP, devInfo.Model, devInfo.Brand)

	if devInfo.Memory != nil {
		around := int(math.Ceil(float64(devInfo.Memory.Total-512*1024) / 1024.0 / 1024.0)) // around
		devInfo.Memory.Around = fmt.Sprintf("%d GB", around)
	}

	db.DeviceUpdateOrInsert(*devInfo)
	defer func() {
		db.SetDeviceAbsent(devInfo.Udid)
		// TODO(ssx): global var, not very function programing
		if info, err := db.DeviceGet(devInfo.Udid); err == nil && dingtalkToken != "" {
			robot := dingrobot.New(dingtalkToken)
			if err := robot.Text(info.PropertyId + " " + info.Serial + "\n" + info.Brand + " " + info.Model + " " + info.IP + " offline"); err != nil {
				log.Println("dingding send text err:", err)
			}
		}
	}()

	// ping ticker
	go func() {
		pingTicker := time.NewTicker(wsPingPeriod)
		defer pingTicker.Stop()
		for {
			select {
			case <-pingTicker.C:
				ws.SetWriteDeadline(time.Now().Add(wsWriteWait))
				// here, writeMessage is not thread safe
				if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
					return
				}
			}
		}
	}()

	// Listen device info update
	for {
		mt, message, err := ws.ReadMessage()
		if err != nil {
			log.Println(host, "websocket connection closed")
			break
		}
		if mt == websocket.TextMessage {
			handleWebsocketMessage(host, message)
		}
	}
}

func runAndroidShell(ip string, command string) (output string, err error) {
	u, _ := url.Parse("http://" + ip + ":7912/shell")
	params := url.Values{}
	params.Add("command", command)
	u.RawQuery = params.Encode()
	resp, err := http.Get(u.String())
	if err != nil {
		return
	}
	defer resp.Body.Close()
	jsondata, err := ioutil.ReadAll(resp.Body)
	return string(jsondata), err
}

func batchRunCommand(command string) {
	wg := sync.WaitGroup{}

	devices, _ := db.DeviceList()
	for _, devInfo := range devices {
		if devInfo.Present == nil || !*devInfo.Present {
			continue
		}

		wg.Add(1)
		go func(ip string) {
			runAndroidShell(ip, command)
			wg.Done()
		}(devInfo.IP)
	}
	wg.Wait()
}

func main() {
	// Refs: atx-agent version https://github.com/openatx/atx-agent/releases
	kingpin.Flag("agent", "atx-agent version").Default(defaultATXAgentVersion).StringVar(&atxAgentVersion)
	// FIXME(ssx): Ding talk is disabled because of too many boring messages
	kingpin.Flag("ding-token", "DingDing robot token (env: DING_TOKEN)").OverrideDefaultFromEnvar("DING_TOKEN").StringVar(&dingtalkToken)
	kingpin.Version(version)
	kingpin.HelpFlag.Short('h')
	kingpin.Parse()

	// log.SetFlags(log.Lshortfile | log.LstdFlags)
	// log.SetLevel(log.DebugLevel)
	// log.SetFormatter(&log.TextFormatter{})
	// inforus.AddHookDefault()

	if *port != 8000 {
		*addr = fmt.Sprintf(":%d", *port)
	}

	if dingtalkToken != "" {
		log.Println("dingtalk notification enabled")
		if err := dingrobot.New(dingtalkToken).Text("atx-server started"); err != nil {
			log.Println("dingtalk test notification err:", err)
		}
	}

	log.Info("initial database")
	initDB(*rdbAddr, *rdbName)
	log.Info("listen address", *addr)
	log.Fatal(http.ListenAndServe(*addr, newHandler()))
}


================================================
FILE: proto/message.go
================================================
package proto

import (
	"encoding/json"
	"fmt"
	"time"

	"github.com/openatx/androidutils"
)

type MessageType int

const (
	DeviceInfoMessage = MessageType(0)
	PingMessage       = MessageType(1)
)

type CommonMessage struct {
	Type MessageType
	Data interface{}
}

func (m *CommonMessage) MarshalJSON() []byte {
	data, _ := json.Marshal(m)
	return data
}

type CpuInfo struct {
	Cores    int    `json:"cores"`
	Hardware string `json:"hardware"`
}

type MemoryInfo struct {
	Total  int    `json:"total"` // unit kB
	Around string `json:"around,omitempty"`
}

type OwnerInfo struct {
	IP string `json:"ip"`
}

type DeviceInfo struct {
	Udid                   string                `json:"udid,omitempty"`       // Unique device identifier
	PropertyId             string                `json:"propertyId,omitempty"` // For device managerment, eg: HIH-PHO-1122
	Version                string                `json:"version,omitempty"`    // ro.build.version.release
	Serial                 string                `json:"serial,omitempty"`     // ro.serialno
	Brand                  string                `json:"brand,omitempty"`      // ro.product.brand
	Model                  string                `json:"model,omitempty"`      // ro.product.model
	HWAddr                 string                `json:"hwaddr,omitempty"`     // persist.sys.wifi.mac
	Notes                  string                `json:"notes,omitempty"`      // device notes
	IP                     string                `json:"ip,omitempty"`
	Port                   int                   `json:"port,omitempty"`
	ReverseProxyAddr       string                `json:"reverseProxyAddr,omitempty"`
	ReverseProxyServerAddr string                `json:"reverseProxyServerAddr,omitempty"`
	Sdk                    int                   `json:"sdk,omitempty"`
	AgentVersion           string                `json:"agentVersion,omitempty"`
	Display                *androidutils.Display `json:"display,omitempty"`
	Battery                *androidutils.Battery `json:"battery,omitempty"`
	Memory                 *MemoryInfo           `json:"memory,omitempty"` // proc/meminfo
	Cpu                    *CpuInfo              `json:"cpu,omitempty"`    // proc/cpuinfo

	Owner    *OwnerInfo `json:"owner" gorethink:"owner,omitempty"`
	Reserved string     `json:"reserved,omitempty"`

	ConnectionCount   int       `json:"-"` // > 1 happended when phone redial server
	CreatedAt         time.Time `json:"-" gorethink:"createdAt,omitempty"`
	PresenceChangedAt time.Time `json:"presenceChangedAt,omitempty"`
	UsingBeganAt      time.Time `json:"usingBeganAt,omitempty" gorethink:"usingBeganAt,omitempty"`

	Ready   *bool `json:"ready,omitempty"`
	Present *bool `json:"present,omitempty"`
	Using   *bool `json:"using,omitempty"`

	Product  *Product  `json:"product" gorethink:"product_id,reference,omitempty" gorethink_ref:"id"`
	Provider *Provider `json:"provider" gorethink:"provider_id,reference,omitempty" gorethink_ref:"id"`

	// only works when there is provider
	ProviderForwardedPort int `json:"providerForwardedPort,omitempty"`

	// used for provider to known agent server url
	ServerURL string `json:"serverUrl,omitempty"`
}

// "Brand Model Memory CPU" together can define a phone
type Product struct {
	Id     string `json:"id" gorethink:"id,omitempty"`
	Name   string `json:"name" gorethink:"name,omitempty"`
	Brand  string `json:"brand" gorethink:"brand,omitempty"`
	Model  string `json:"model" gorethink:"model,omitempty"`
	Memory string `json:"memory,omitempty"` // eg: 4GB
	Cpu    string `json:"cpu,omitempty"`

	Coverage float32 `json:"coverage" gorethink:"coverage,omitempty"`
	Gpu      string  `json:"gpu,omitempty"`
	Link     string  `json:"link,omitempty"` // Outside link
	// AntutuScore int     `json:"antutuScore,omitempty"`
}

// u2init
type Provider struct {
	Id                string       `json:"id" gorethink:"id,omitempty"` // machine id
	IP                string       `json:"ip" gorethink:"ip,omitempty"`
	Port              int          `json:"port" gorethink:"port,omitempty"`
	Present           *bool        `json:"present,omitempty"`
	Notes             string       `json:"notes" gorethink:"notes,omitempty"`
	Devices           []DeviceInfo `json:"devices" gorethink:"devices,omitempty"`
	CreatedAt         time.Time    `json:"createdAt,omitempty"`
	PresenceChangedAt time.Time    `json:"presenceChangedAt,omitempty"`
}

// Addr combined with ip:port
func (p *Provider) Addr() string {
	return fmt.Sprintf("%s:%d", p.IP, p.Port)
}


================================================
FILE: rethinkdb-test/main.go
================================================
package main

import (
	"log"
	"strings"

	"github.com/openatx/atx-server/proto"

	r "gopkg.in/gorethink/gorethink.v3"
)

type RdbUtils struct {
	session *r.Session
}

func (db *RdbUtils) DBCreateAnyway(name string) error {
	res, err := r.DBList().Run(db.session)
	if err != nil {
		return err
	}
	defer res.Close()
	var dbNames []string
	if err := res.All(&dbNames); err != nil {
		return err
	}
	for _, dbName := range dbNames {
		log.Println(dbName)
		if dbName == name {
			log.Println("db exists atxserver")
			return nil
		}
	}
	err = r.DBCreate("atxserver").Exec(db.session)
	return err
}

func (db *RdbUtils) TableCreateAnyway(name string) error {
	err := r.TableCreate(name, r.TableCreateOpts{
		PrimaryKey: "udid",
	}).Exec(db.session)
	if err != nil && strings.Contains(err.Error(), "already exists") {
		return nil
	}
	return err
}

func (db *RdbUtils) UpdateOrInsertDevice(dev proto.DeviceInfo) error {
	return r.Table("devices").Insert(dev, r.InsertOpts{
		Conflict: func(id, oldDoc, newDoc r.Term) interface{} {
			return oldDoc.Merge(newDoc)
		},
	}).Exec(db.session)
}

func (db *RdbUtils) DeviceList() (devices []proto.DeviceInfo) {
	res, err := r.Table("devices").Run(db.session)
	if err != nil {
		return nil
	}
	defer res.Close()
	res.All(&devices)
	return
}

var db *RdbUtils

func init() {
	r.SetTags("gorethink", "json")
	r.SetVerbose(true)
	session, err := r.Connect(r.ConnectOpts{
		Address:    "localhost:28015",
		Database:   "atxserver",
		InitialCap: 10,
		MaxOpen:    10,
	})

	if err != nil {
		log.Fatal(err)
	}
	db = &RdbUtils{session}
}

func main() {
	log.Println("main")
	if err := db.DBCreateAnyway("atxserver"); err != nil {
		log.Fatal(err)
	}
	if err := db.TableCreateAnyway("devices"); err != nil {
		log.Fatal(err)
	}
	log.Println("table created")
	db.UpdateOrInsertDevice(proto.DeviceInfo{
		Udid:   "aaaabbbbccccdddd1234",
		Serial: "abcd123456",
		// Brand: "Huawei",
	})

	log.Println(db.DeviceList())

	feeds, err := r.Table("devices").Changes().Run(db.session)
	if err != nil {
		log.Fatal(err)
	}
	defer feeds.Close()
	var change r.ChangeResponse
	for feeds.Next(&change) {
		// var devInfo proto.DeviceInfo
		// log.Println(devInfo)
		// log.Println(change.State)
		log.Println(change.NewValue)
		log.Println(change.OldValue)
	}
}


================================================
FILE: scripts/img2video/main.py
================================================
# coding: utf-8
#
# Py3 only
from __future__ import print_function

import os
import uuid
import pathlib
import shutil
import time
import io
import json
import traceback

import numpy as np
import imageio
import tornado.ioloop
import tornado.web
import tornado.websocket
from PIL import Image, ImageOps
from tornado import gen
from tornado.httpclient import AsyncHTTPClient
from tornado.log import enable_pretty_logging
enable_pretty_logging()


class MainHandler(tornado.web.RequestHandler):
    @gen.coroutine
    def get(self):
        yield gen.sleep(.1)
        self.render("index.html")


class VideoHandler(tornado.web.RequestHandler):
    def get(self):
        content_type = self.request.headers.get('Content-Type')
        if content_type and 'application/json' in content_type:
            videopath = pathlib.Path("static/videos")
            data = []
            for p in sorted(
                    videopath.glob('*.mp4'), key=lambda p: p.stat().st_mtime):
                info = {
                    'uri': str(p).replace('\\', '/'),
                    'mtime': p.stat().st_mtime,
                }
                meta = pathlib.Path(str(p) + ".json")
                if meta.exists():
                    with meta.open('rb') as f:  # read_text not exists on py3.4
                        metainfo = json.loads(f.read().decode('utf-8'))
                        info.update(metainfo)
                data.append(info)
            self.write({'data': list(reversed(data))})
            return
        self.render("videos.html")

    def delete(self, name):
        mp4file = pathlib.Path("static/videos/" + name)
        mp4meta = pathlib.Path("static/videos/" + name + ".json")
        if mp4meta.exists():
            mp4meta.unlink()
        if mp4file.exists():
            mp4file.unlink()
            self.write({"success": True})
        else:
            self.write({"success": False})


def resizefit(im, size):  # resize but keep aspect ratio
    w, h = size
    oldw, oldh = old_size = im.size
    old_ratio = oldw / oldh
    new_ratio = w / h
    if new_ratio > old_ratio:
        padw = int(oldh * new_ratio - oldw) // 2
        im = ImageOps.expand(im, (padw, 0, padw, 0), (0, 0, 255))
    else:
        padh = int(oldw / new_ratio - oldh) // 2
        im = ImageOps.expand(im, (0, padh, 0, padh), (0, 255, 255))
    return im.resize(size, Image.ANTIALIAS)


class CorsMixin(object):
    def set_default_headers(self):
        self.set_header("Access-Control-Allow-Origin", "*")
        self.set_header("Access-Control-Allow-Headers", "x-requested-with")
        self.set_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')

    def options(self):
        # no body
        self.set_status(204)
        self.finish()


class Image2VideoWebsocket(tornado.websocket.WebSocketHandler):
    def check_origin(self, origin):
        return True

    def open(self):
        self.udid = self.get_argument(
            'udid')  # device udid(unique device identifier)
        self.name = self.get_argument('name')
        self.video_path = 'static/videos/%s-%d.mp4' % (self.name,
                                                       int(time.time() * 1000))
        self.video_tmp_path = 'tmpdir/ws-%s.mp4' % str(uuid.uuid1())
        fps = int(self.get_argument('fps', 10))
        self.writer = imageio.get_writer(self.video_tmp_path, fps=fps)
        self.size = ()
        print("websocket opened")

    def on_message(self, message):
        if isinstance(message, bytes):
            try:
                image = Image.open(io.BytesIO(message))
                if not self.size:  # always horizontal
                    w, h = self.size = image.size
                    if w < h:
                        self.size = (h, w)
                if self.size != image.size:
                    image = resizefit(image, self.size)
                imarray = np.asarray(image)
                del image
                self.writer.append_data(imarray)
            except Exception as e:
                print("Receive image format error")
                traceback.print_exc()
        else:
            print("receive", message)

    def on_close(self):
        self.writer.close()
        if not os.path.exists(self.video_tmp_path):
            print("no video file generated")
            return
        shutil.move(self.video_tmp_path, self.video_path)
        with open(self.video_path + '.json', 'wb') as f:
            f.write(
                json.dumps({
                    "udid": self.udid,
                    "name": self.name
                }).encode('utf-8'))
        print("websocket closed, video generated", self.video_path)


class Image2VideoHandler(CorsMixin, tornado.web.RequestHandler):
    @gen.coroutine
    def post(self):
        filemetas = self.request.files['file']
        tmpdir = pathlib.Path('tmpdir/' + str(uuid.uuid1()))
        if not tmpdir.is_dir():
            tmpdir.mkdir(parents=True)

        video_file = 'static/video-%s.mp4' % int(time.time() * 1000)
        writer = imageio.get_writer(video_file, fps=20)
        try:
            size = ()
            for (i, meta) in enumerate(filemetas):
                jpgfile = tmpdir / ('%d.jpg' % i)
                with jpgfile.open('wb') as f:
                    f.write(meta['body'])
                imarray = imageio.imread(str(jpgfile))
                if not size:
                    size = imarray.shape[1::-1]  # same as reversed(shape[:2])
                if size != imarray.shape[1::-1]:
                    im = Image.fromarray(imarray)  # convert to PIL
                    im = resizefit(im, size)
                    imarray = np.asarray(im)  # convert to numpy
                    del im
                writer.append_data(imarray)
        finally:
            shutil.rmtree(str(tmpdir))
            writer.close()

        self.write({
            "success": True,
            "url": "http://" + self.request.host + "/" + video_file
        })


def make_app(**settings):
    settings['template_path'] = 'templates'
    settings['static_path'] = 'static'
    settings['cookie_secret'] = os.environ.get("SECRET", "SECRET:_")
    settings['login_url'] = '/login'
    return tornado.web.Application([
        (r"/", MainHandler),
        (r"/videos", VideoHandler),
        (r"/videos/([^/]+)", VideoHandler),
        (r"/video/convert", Image2VideoWebsocket),
        (r"/img2video", Image2VideoHandler),
    ], **settings)


if __name__ == "__main__":
    hotreload = bool(os.getenv("DEBUG"))
    app = make_app(debug=hotreload)
    app.listen(7000)
    try:
        tornado.ioloop.IOLoop.instance().start()
    except KeyboardInterrupt:
        tornado.ioloop.IOLoop.instance().stop()

================================================
FILE: scripts/img2video/requirements.txt
================================================
tornado
imageio
numpy
pillow

================================================
FILE: scripts/img2video/static/.gitkeep
================================================


================================================
FILE: scripts/img2video/static/videos/.gitkeep
================================================


================================================
FILE: scripts/img2video/templates/index.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>IMG2VIDEO</title>
</head>

<body>
    <form method="post" action="/img2video" enctype="multipart/form-data">
        <input type="file" name="file">
        <br>
        <input type="file" name="file">
        <br>
        <input type="file" name="file">
        <br>
        <input type="file" name="file">
        <br>
        <button type="submit">Submit</button>
    </form>
</body>

</html>

================================================
FILE: scripts/img2video/templates/videos.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Videos</title>
    <link rel="stylesheet" href="//cdn.jsdelivr.net/bootstrap/3.3.7/css/bootstrap.min.css">
    <link rel="stylesheet" href="//cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
</head>

<body>
    <nav class="navbar navbar-default" style="margin-bottom: 0px">
        <div class="navbar-header">
            <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1"
                aria-expanded="false">
                <span class="sr-only">Toggle navigation</span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
            </button>
            <a class="navbar-brand" href="/">
                <i class="fa fa-video-camera"></i>
                <strong>Videos</strong>
            </a>
        </div>
        <!-- <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
            <form class="navbar-form navbar-left">
                <button class="btn btn-sm btn-default" @click.prevent="toggleScreen">
                    <i v-if="screenWS" class="fa fa-eye"></i>
                    <i v-else class="fa fa-eye-slash"></i>
                </button>
            </form>
        </div> -->
    </nav>
    <div id="app">
        <div class="container-fluid">
            <div v-for="g in groupedVideos">
                <h3 style="font-family: 'Courier New'">{{!g.date}}</h3>
                <div class="row">
                    <div class="col-md-2" v-for="v in g.videos">
                        <videoplayer :src="v.uri" :mtime="v.mtime" :udid="v.udid" :name="v.name"></videoplayer>
                    </div>
                </div>
            </div>
        </div>
    </div>
</body>

<script src="//cdn.jsdelivr.net/npm/vue@2.5.13/dist/vue.js"></script>
<script src="//cdn.jsdelivr.net/npm/jquery@3.2.1/dist/jquery.min.js"></script>
<script src="//cdn.jsdelivr.net/bootstrap/3.3.7/js/bootstrap.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/moment@2.21.0/moment.min.js"></script>
<script type="text/x-template" id="video-component">
    <div class="video-container">
        <div>
            <div @dblclick="requestFullScreen" style="position: relative">
                <video ref="video" style="width: 100%; border: 1px solid gray" :src="src"></video>
                <span style="background: black; position: absolute; right: 10px; bottom: 15px; color: white; font-family: 'Courier New'">
                    <span ref="currenttime" style="color: yellow"></span>
                    <span ref="totaltime"></span>
                </span>
            </div>
            <p>
                <button @click="deleteVideo" class="pull-right btn btn-xs btn-danger"><i class="fa fa-trash"></i></button>
                <a target="_blank" :href="controlUrl"><strong v-text="nametext"></strong></a>
                <br>
                <small><i v-text="mtimetext"></i></small>
            </p>
        </div>
    </div>
</script>
<script>
    // self defined video component
    Vue.component('videoplayer', {
        template: '#video-component',
        props: ["src", "mtime", "udid", "name"],
        computed: {
            mtimetext: function () {
                var m = moment.unix(this.mtime)
                return m.format("GGGG/MM/DD HH:mm:ss")
            },
            nametext: function () {
                return this.name || "unknown";
            },
            controlUrl: function () {
                return location.protocol + "//" + location.host + "/devices/" + this.udid + "/remote"
            }
        },
        data: function () {
            return {
                videoElement: null
            }
        },
        mounted: function () {
            var v = this.$refs.video;
            var t = this.$refs.totaltime;
            var c = this.$refs.currenttime;
            // v.play();
            // video refs: https://www.w3schools.com/tags/ref_av_dom.asp
            function formatDuration(duration) {
                var m = moment.duration(duration, 'seconds')
                if (duration > 3600) {
                    return moment.utc(m.asMilliseconds()).format("HH:mm:ss");
                }
                return moment.utc(m.asMilliseconds()).format("mm:ss");
            }

            v.addEventListener('mouseover', function () {
                this.controls = true;
                t.hidden = true;
                c.hidden = true;
            })
            v.addEventListener('mouseout', function () {
                this.controls = false;
                t.hidden = false;
                c.hidden = false;
            })
            v.addEventListener('timeupdate', function () {
                c.innerText = formatDuration(this.currentTime);
            })
            v.addEventListener('canplay', function () {
                t.innerText = formatDuration(this.duration)
            })
            v.addEventListener('click', function () {
                if (this.paused) {
                    this.play();
                } else {
                    this.pause();
                }
            })

            document.addEventListener('keydown', function (e) {
                // console.log(e.keyCode)
                var v = document.webkitFullscreenElement
                if (v && e.keyCode == 32) { // space key pressed
                    v.paused ? v.play() : v.pause()
                }
            })
        },
        methods: {
            requestFullScreen: function () {
                var v = this.$refs.video;
                if (!document.webkitFullscreenElement) {
                    v.webkitRequestFullscreen()
                } else {
                    v.webkitExitFullscreen()
                }
            },
            deleteVideo: function () {
                if (!confirm("Delete?")) {
                    return;
                }
                this.$el.parentNode.removeChild(this.$el)
                $.ajax({
                    url: this.src.replace(/^static/, ""), // remove prefix
                    method: "delete",
                    dataType: "json",
                }).then(function (ret) {
                    console.log(ret)
                })
            }
        }
    })

    new Vue({
        el: "#app",
        data: {
            videos: [],
            groupedVideos: [],
        },
        mounted: function () {
            $.ajax({
                url: "/videos",
                method: "get",
                contentType: "application/json",
                dataType: "json"
            }).then(function (ret) {
                console.log(ret)
                this.videos = ret.data;

                var maps = {};
                var groupedVideos = [];
                // var currentList;
                ret.data.forEach(function (v) {
                    var date = moment.unix(v.mtime).format("GGGG/MM/DD");
                    if (!(date in maps)) {
                        groupedVideos.push({
                            date: date,
                            videos: maps[date] = []
                        });
                    }
                    maps[date].push(v)
                })
                this.groupedVideos = groupedVideos;
                console.log(this.groupedVideos)
            }.bind(this))
        }
    })
</script>

</html>

================================================
FILE: scripts/levenshtein.py
================================================
# coding: utf-8
#

import numpy as np


def match_string(a, b):
    a = ' ' + a
    b = ' ' + b
    array = np.zeros((len(a), len(b)), dtype=np.int)
    steps = np.zeros((len(a), len(b)), dtype=np.int)
    array[0] = np.arange(len(b))
    array[:, 0] = np.arange(len(a))
    steps[0] = 1

    for i in range(1, len(a)):
        for j in range(1, len(b)):
            sub_cost = 0 if a[i] == b[j] else 2
            minval = array[i-1, j-1]+sub_cost # substitution
            steps[i, j] = 2
            
            del_cost = 1 if a[i] != ' ' else 0
            ins_cost = 1 if b[j] != ' ' else 0
            # delete, insertion
            for step, val in enumerate((array[i-1, j]+del_cost, array[i, j-1]+ins_cost)):
                if minval > val:
                    steps[i, j] = step
                    minval = val
            array[i, j] = minval
    
    # print(array)
    # print(steps)
    return array, steps


def backward(steps, a, b):
    assert len(steps) == len(a)+1
    assert len(steps[0]) == len(b)+1

    x = len(a)
    y = len(b)

    ss = []
    while x > 0 or y > 0:
        step = steps[x, y]
        # print(x, y)
        if step == 0:
            x, y = x-1, y
            ss.append(['d', a[x], ' '])
        elif step == 1:
            x, y = x, y-1
            ss.append(['i', ' ', b[y]])
        elif step == 2:
            x, y = x-1, y-1
            if a[x] == b[y]:
                ss.append(['=', a[x], b[y]])
            else:
                ss.append(['r', a[x], b[y]])
    # for i in range(len(ss)):
        # print()
    for line in map(list, zip(*ss)):
        print(''.join(reversed(line)))


def main():
    a = "sitting"
    b = "kitten"
    # a, b = "Monday", "Hello world"
    array, steps = match_string(a, b)
    backward(steps, a, b)


def match_distance(a, b):
    array, steps = match_string(a, b)
    if False:
        backward(steps, a, b)
    return int(array[-1, -1])


if __name__ == '__main__':
    print(match_distance('sitting', 'kitten'))

================================================
FILE: scripts/update_coverage_umeng.py
================================================
# coding: utf-8
#
'''
用途:更新ATX-Server上的覆盖率数据(来源umeng)

{
    "equipment": {
        "item22": {
            "android": {
                "2017-10": {
                    "brandRankData": [
                        {
                            "name": "OPPO",
                            "value": 18.57,
                            "children": [
                                {
                                    "name": "OPPO R9",
                                    "value": 2.15
                                }
                            ]
                        },
                        {
                            "name": "vivo",
                            "value": 16.84,
                            "children": []
                        }
                    ],
                    "phoneTypeData": [
                        {
                            "name": "OPPO R9",
                            "value": 2.15,
                            "trend": "up"
                        }
                    ]
                },
                "2017-09": {},
                "2017-08": {},
                "2017-07": {},
                "2017-06": {},
                "2017-05": {},
                "2017-04": {},
                "2017-03": {}
            },
            "ios": {
                "2017-10": {},
                "2017-09": {},
                "2017-08": {},
                "2017-07": {},
                "2017-06": {},
                "2017-05": {},
                "2017-04": {},
                "2017-03": {}
            }
        }
    }
}

'''
import requests
import json
import levenshtein


def get_umeng_data():
    umeng_data = requests.get('http://compass.umeng.com/data/equipmentItem2_2.json').json()
    android_datas = umeng_data['equipment']['item22']['android']
    keys = sorted(android_datas.keys(), reverse=True)
    key = keys[0]
    print("Year-month:", key)
    rank_data = android_datas[key]['brandRankData']
    data = {}

    for brand in rank_data:
        for cov in brand['children']:
            name, value = cov['name'], cov['value']
            if name.startswith('畅玩'):
                name = '荣耀'+name
            if name.startswith('畅享'):
                name = '华为'+name
            data[name] = value
    
    rank_data = android_datas[keys[1]]['brandRankData']
    for brand in rank_data:
        for cov in brand['children']:
            name, value = cov['name'], cov['value']
            if name.startswith('畅玩'):
                name = '荣耀'+name
            if name.startswith('畅享'):
                name = '华为'+name
            if name not in data:
                data[name] = value
    return data


def main():
    data = get_umeng_data()
    keys = data.keys()

    for device in requests.get('http://10.246.46.160:8200/list').json():
        product = device.get('product') or {}
        name = product.get('name').lower()
        if not name:
            continue
        
        udid = device.get('udid')

        # 查找最匹配的名字
        mindist = 1e9
        bestkey = ''
        for key in keys:
            dist = levenshtein.match_distance(name, key.lower())
            if mindist > dist:
                mindist = dist
                bestkey = key
        
        # 全部匹配自动更新
        if mindist == 0:
            print(name, "==", bestkey, ">>", mindist, data[bestkey])
            requests.put('http://10.246.46.160:8200/devices/'+udid+'/product', data=json.dumps({
                'id': product['id'], 
                'coverage': data[bestkey]
            }))
        else:
            print("?", name, "==", bestkey, ">>", mindist, data[bestkey])
            confirm = input("Confirm update [Y/n]")
            if confirm == '':
                # print('update name')
                # requests.post('http://10.246.46.160:8200/devices/'+udid+'/info', data=json.dumps({'name': bestkey}))
                print('update coverage')
                requests.put('http://10.246.46.160:8200/devices/'+udid+'/product', data=json.dumps({
                    'id': product['id'], 
                    'name': bestkey,
                    'coverage': data[bestkey]
                }))


if __name__ == '__main__':
    main()
    


================================================
FILE: templates/edit.html
================================================
<!DOCTYPE html>
<html lang="cn">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
  <script src="https://cdn.jsdelivr.net/npm/vue@2.5.3/dist/vue.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/jquery@3.2.1/dist/jquery.min.js"></script>
  <title>Product</title>
</head>

<body>
  <div id="app">
    <h1>
      <a href="/"><i class="fa fa-arrow-left"></i></a> 手机信息修改</h1>
    <div>
      <p>UDID: {{udid}}</p>
      <dl>
        <dt>Serial</dt>
        <dd>{{device.serial}}</dd>
        <dt>CPU</dt>
        <dd>{{device.cpu && device.cpu.hardware}}</dd>
        <dt>Memory</dt>
        <dd>{{device.memory && device.memory.around}}</dd>
      </dl>
      <h4>Product</h4>
      <p>
        <select v-model="product_id" @change="productChange(product_id)">
          <option v-for="v in products" v-bind:value="v.id">{{v.id}}</option>
        </select>
      </p>
      <p>ID: {{product.id}}</p>
      <p>Brand: {{product.brand}}</p>
      <p>
        Model: {{product.model}}
        <a target="_blank" :href='"https://www.baidu.com/s?wd="+device.model'>百度</a>
        <a target="_blank" :href='"https://www.google.com/search?q=" + device.model'>Google</a>
      </p>
      <p>Name: <input type="text" v-model.trim="product.name"></p>
      <p>CPU: <input type="text" v-model.trim="product.cpu"></p>
      <p>GPU: <input type="text" v-model.trim="product.gpu"></p>
      <p>Link: <input type="text" v-model.trim="product.link"></p>
      <p>Coverage: <input type="number" v-model.number="product.coverage">%</p>
      <button class="btn btn-default" @click.prevent="update">更新</button>
      <button class="btn btn-default" @click.prevent="updateAndBack">更新并返回首页</button> {{message}}
    </div>
  </div>

  <script>
    var udid = "[[.]]"; // device udid

    new Vue({
      el: "#app",
      data: {
        udid: "",
        device: {},
        product: {
          id: "1242o3iryodjifasdf",
          name: "some-product"
        },
        product_id: '',
        products: [],
        message: '',
      },
      mounted: function () {
        this.udid = udid;
        $.ajax({
          url: "/devices/" + udid + "/info",
        })
          .then(function (ret) {
            console.log(ret)
            this.device = ret;
            this.product = ret.product || {};
            this.product_id = this.product.id;
            return ret;
          }.bind(this))
          .then(function (ret) {
            return $.ajax({
              url: "/products/" + ret.brand + "/" + ret.model
            })
          })
          .then(function (ret) {
            console.log("products:", ret)
            this.products = ret;
          }.bind(this))
          .then(function (ret) {
            if (!this.product_id && this.products.length == 1) {
              this.product = this.products[0];
              this.product_id = this.product.id;
            }
            if (!this.product.cpu && this.device.cpu) {
              this.product.cpu = this.device.cpu.hardware;
            }
          }.bind(this))

        console.log(udid)
      },
      methods: {
        updateAndBack: function () {
          this.update().then(function (ret) {
            window.location = "/";
          })
        },
        update: function () {
          console.log("update")
          this.message = "updating"
          return $.ajax({
            url: "/devices/" + udid + "/product",
            method: "put",
            data: JSON.stringify(this.product),
          })
            .then(function (ret) {
              console.log(ret)
              this.message = "Update success !!"
            }.bind(this))
        },
        productChange: function (product_id) {
          console.log(product_id)
          this.product = this.products.filter(function (p) {
            return p.id == product_id
          })[0]
        }
      }
    })
  </script>
</body>

</html>

================================================
FILE: templates/index.html
================================================
<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
  <link rel="stylesheet" href="/assets/style.css">

  <script src="https://cdn.jsdelivr.net/npm/jquery@3.2.1/dist/jquery.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/js/bootstrap.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue@2.5.3/dist/vue.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/moment@2.20.1/moment.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.1/dist/clipboard.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/notifyjs-browser@0.4.2/dist/notify.min.js"></script>
  <style>
  </style>
</head>

<body>
  <nav class="navbar navbar-default">
    <div class="navbar-header">
      <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1"
        aria-expanded="false">
        <span class="sr-only">Toggle navigation</span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </button>
      <a class="navbar-brand" href="/">
        <b>ATX</b> -
        <strong>Server</strong>
      </a>
    </div>
    <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
      <p class="navbar-text"></p>
      <ul class="nav navbar-nav">
        <li class="active">
          <a href="/">
            <i class="fa fa-list-alt"></i> 设备列表
          </a>
        </li>
        <li>
          <a href="/providers">
            <i class="fa fa-bandcamp"></i> 节点列表
          </a>
        </li>
        <li>
          <a href="/videos">
            <i class="fa fa-film"></i> 视频列表</a>
        </li>
      </ul>
    </div>
  </nav>
  <div class="container-fluid">
    <div class="row">
      <div class="col-md-12" v-if="errmsg">
        <p class="color-red">{{errmsg}}
          <button class="btn btn-warning btn-xs" @click="loadWatches">点击重连</button>
        </p>
      </div>
    </div>
    <form class="form-inline hidden" v-on:submit.prevent>
      <div class="form-group">
        <strong>批量操作</strong>
        <button @click.prevent="batchUnlock" class="btn btn-sm btn-default">
          <i class="fa fa-unlock"></i> 解锁</button>
        <button @click.prevent="batchLock" class="btn btn-sm btn-default">
          <i class="fa fa-lock"></i> 锁屏</button>
        <button @click.prevent="batchIdentify('red')" class="btn btn-sm btn-default">
          <i class="fa fa-location-arrow"></i> 红色</button>
        <button @click.prevent="batchIdentify('black')" class="btn btn-sm btn-default">
          <i class="fa fa-location-arrow"></i> 黑色</button>
        <!-- <button @click.prevent="batchPower" class="btn btn-sm btn-danger"><i class="fa fa-power-off"></i> 关机</button> -->
        <button @click.prevent="setProperty" class="btn btn-sm btn-info">资产设置</button>
      </div>
    </form>
    <form class="form-inline" v-on:submit.prevent>
      <div class="form-group">
        <div class="input-group input-group-sm">
          <!-- <div class="input-group-addon">Search</div> -->
          <!-- autofocus is not working, wired -->
          <div class="input-group-addon" title="在线设备">
            <i class="fa fa-meh-o"></i>
            <strong v-text="onlineCount"></strong>
          </div>
          <div class="input-group-addon" title="已标记设备">
            <i class="fa fa-smile-o"></i>
            <strong v-text="count(true, true, null)"></strong>
          </div>
          <div class="input-group-addon" title="未标记设备">
            <i class="fa fa-frown-o"></i>
            <strong v-text="count(true, false, null)"></strong>
          </div>
          <div class="input-group-addon" title="离线设备">
            <i class="fa fa-snowflake-o"></i>
            <strong v-text="count(false, null, null)"></strong>
          </div>
          <input type="text" v-model="searchText" placeholder="搜索" class="form-control" v-focus>
          <div class="input-group-addon">
            <i class="fa fa-search"></i>
          </div>
        </div>
      </div>
    </form>
    <div class="table-responsive">
      <table class="table table-hover table-condensed">
        <thead>
          <tr>
            <th class="hidden">#</th>
            <th>@</th>
            <th class="text-center">:)</th>
            <th class="hidden-xs">
              <u>IP</u>
            </th>
            <th class="hidden-xs">
              <u>Serial</u>
            </th>
            <th class="hidden-xs">
              <u>资产编号</u>
            </th>
            <th>
              <u>名字</u>
            </th>
            <th class="hidden-xs">
              <u>Brand</u>
            </th>
            <th class="hidden">Model</th>
            <th class="hidden-xs">
              <u>Version</u>(SDK)</th>
            <th class="hidden">CPU</th>
            <th class="hidden">MAC</th>
            <th class="hidden">Memory</th>
            <th class="hidden-xs">Agent</th>
            <th>
              <span class="hidden-xs">电量</span>
              <i class="fa fa-battery"></i>
            </th>
            <th>
              <span class="hidden-xs">温度</span>
              <i class="fa fa-thermometer"></i>
            </th>
            <th class="hidden-xs">上线时间</th>
            <th>备注</th>
            <th class="hidden-xs">更多</th>
            <!-- <th class="hidden-xs">Other</th> -->
          </tr>
        </thead>
        <tbody>
          <tr v-for="d in filteredDevices" :key="d.udid" v-bind:class='{"offline": !d.present}'>
            <td class="hidden">
              <input type="checkbox" disabled=true>
            </td>
            <td>
              <div @click="d.present && toggleReady(d)">
                <span v-show="d.ready" class="fa fa-thumbs-o-up" v-bind:class='{"color-green": d.present}'></span>
                <span v-show="!d.ready" class="fa fa-thumbs-o-down"></span>
              </div>
            </td>
            <td class="text-center">
              <span v-if="!d.present" class="color-yellow">Offline</span>
              <span v-else>
                <a v-if="!d.using" target="_blank" class="hidden-xs btn btn-xs btn-link" :href='"/devices/"+d.udid+"/remote"'>
                  Use
                </a>
                <span v-if="!d.using" class="visible-xs" style="cursor: pointer" @click="d.present && identify(d)">
                  <!-- locate idle device-->
                  <i class="fa fa-location-arrow" :class="{'fa-spin': d.identifying}"></i>
                </span>
                <span class="hidden-xs color-red" v-if="d.using" @dblclick.alt="releaseDevice(d.udid)">{{d.owner && d.owner.ip}}</span>
                <span class="visible-xs color-red" v-if="d.using">Busy</span>
              </span>
            </td>
            <td class="hidden-xs">
              <span v-text="d.ip"></span>
            </td>
            <td class="hidden-xs" style="font-family: 'Courier New', Courier, monospace">
              {{d.serial | shortString(10)}}
              <!-- <span v-text='d.serial && d.serial.slice(4)+"..."' class="visible-xs"></span> -->
            </td>
            <td class="hidden-xs" v-text="d.propertyId"></td>
            <td>
              <span v-text="(d.product || {}).name"></span>
              <a :href="'/devices/'+d.udid+'/edit'" class="fa fa-edit"></a>
            </td>
            <td class="hidden-xs" v-text="d.brand"></td>
            <td class="hidden" v-text="d.model"></td>
            <td class="hidden-xs">{{d.version}}({{d.sdk}})</td>
            <td class="hidden">
              <span v-if="d.cpu">{{d.cpu.hardware.replace('Qualcomm Technologies, Inc', '骁龙')}}</span>
            </td>
            <td class="hidden text-uppercase">
              <span v-text="d.hwaddr"></span>
            </td>
            <td class="hidden">{{d.memory && d.memory.around}}</td>
            <td class="hidden-xs">
              <span style="cursor: pointer" @click="d.present && identify(d)">
                <i class="fa fa-location-arrow" :class="{'fa-spin': d.identifying}"></i>
                {{d.agentVersion}}
              </span>
            </td>
            <td>
              <span v-if="d.battery" :class="{'color-red': d.battery.level < 30}">
                {{d.battery.level}}%
                <span class="hidden-xs" v-if="d.present && d.battery">
                  <i class="fa fa-usb" v-if="d.provider"></i>
                  <!-- 2: charging -->
                  <i class="fa fa-plug color-rest" v-else-if="d.battery.status == 2"></i>
                  <i class="fa-fix-height fa" v-else :class='"fa-battery-" + Math.floor(d.battery.level/25)'></i>
                </span>
              </span>
            </td>
            <td>
              <!-- temperature-->
              <span v-if="d.battery" :class="{'color-red': d.battery.temperature > 400}">
                {{d.battery.temperature/10}}℃
              </span>
            </td>
            <td class="hidden-xs">
              {{d.presenceChangedAt | timeSince}}
            </td>
            <td>
              <editable-span :content="d.notes" @change="changeNotes(d, $event)" />
            </td>
            <td class="hidden-xs">
              <!-- <a target="_blank" :href='"http://"+d.ip+":7912/remote"'><i class="fa fa-television"></i></a> -->
              <a class="hidden-xs" target="_blank" :href='"http://"+d.ip+":7912/term"'>
                <i class="fa fa-terminal"></i>
              </a>
              <button class="hidden-xs btn btn-link btn-copy btn-xs" :data-clipboard-text="d.udid">
                <i class="fa fa-clipboard" alt="Copy to clipboard"></i>
              </button>
              <a target="_blank" :href='"http://"+d.ip+":7912/screenshot"'>
                <i class="fa fa-camera" alt="Take screenshot"></i>
              </a>
              <a class="hidden-xs btn btn-link btn-copy btn-xs" target="_blank" :href='"http://"+d.ip+":7912/remote"'>
                <i class="fa fa-eye" alt="Remote control"></i>
              </a>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
  <script src='[["/assets/vue-components.js" | urlhash]]'></script>
  <script>
    new Vue({
      el: ".container-fluid",
      data: {
        devices: [],
        errmsg: "",
        searchText: "",
      },
      directives: {
        focus: {
          inserted: function (el) {
            el.focus();
          }
        }
      },
      filters: {
        timeSince: function (value) {
          return moment(value).fromNow()
        },
        shortString: function (value, length) {
          length = parseInt(length || "10", 10);
          if (!value || value.length < length) {
            return value;
          }
          var preLen = Math.max(1, Math.floor(length / 2 - 2));
          var postLen = length - preLen - 2;
          return value.substr(0, preLen) + '..' + value.substr(-postLen);
        }
      },
      mounted: function () {
        this.loadWatches()

        var clipboard = new ClipboardJS(".btn-copy")
        clipboard.on('success', function (e) {
          $(e.trigger).notify("Copied!", {
            className: "success",
            position: "right",
            autoHideDelay: 800
          })
        })
      },
      computed: {
        filteredDevices: function () {
          var searchText = this.searchText.trim();
          if (!searchText.length) {
            return this.onlineDevices;
          }
          var keywords = searchText.split(/\s+/).map(function (key) {
            return key.toLowerCase()
          })
          return this.onlineDevices.filter(function (d) {
            return keywords.every(function (key) {
              // searched properties
              return [d.propertyId, d.ip, d.serial, d.brand, d.version, (d.product || {}).name].some(function (value) {
                return value && ("" + value).toLowerCase().includes(key)
              })
            })
          })
        },
        onlineDevices: function () {
          return this.devices.filter(function (d) {
            return d.present;
          })
        },
        onlineCount: function () {
          return this.filteredDevices.filter(function (d) {
            return d.present
          }).length;
        },
      },
      methods: {
        releaseDevice: function (udid) {
          $.ajax({
            url: "/devices/" + udid + "/reserved",
            method: "delete"
          })
        },
        count: function (present, ready, using) {
          return this.filteredDevices.filter(function (d) {
            if (present != null && d.present != present) {
              return false;
            }
            if (ready != null && d.ready != ready) {
              return false;
            }
            if (using != null && d.using != using) {
              return false;
            }
            return true;
          }).length
        },
        loadWatches: function () {
          // init data
          this.loadDevices()
          this.errmsg = "Connecting"

          var ws = new WebSocket("ws://" + location.host + "/feeds")
          var key = setInterval(function () {
            ws.send("ping")
          }, 5000);
          ws.onopen = function () {
            console.log("websocket connected")
            this.errmsg = "";
          }.bind(this)
          ws.onmessage = function (evt) {
            var jdata = JSON.parse(evt.data);
            if (jdata.error) {
              this.errmsg = jdata.error;
              return
            }
            var dataNew = jdata.new,
              dataOld = jdata.old;
            if (dataNew && dataOld) {
              $.ajax({
                url: "/devices/" + dataNew.udid + "/info",
                dataType: "json",
              }).then(function (ret) {
                this.devices = this.devices.map(function (d) {
                  if (d.udid == dataNew.udid) {
                    return ret;
                  }
                  return d;
                })
              }.bind(this))
            } else {
              this.loadDevices();
            }
            // console.log("websocket recv:", evt.data)
          }.bind(this)
          ws.onclose = function (evt) {
            console.log("websocket closed")
            clearInterval(key)
            this.errmsg = "Server connection closed. " + this.errmsg;
          }.bind(this)
        },
        loadDevices: function () {
          return $.ajax({
            url: "/list",
          }).then(function (ret) {
            this.devices = ret.map(function (d) {
              d.identifying = false;
              return d
            })
          }.bind(this))
        },
        identify: function (d) {
          d.identifying = true;
          $.ajax({
            url: "/devices/" + d.udid + "/shell",
            method: "post",
            data: {
              command: "input keyevent HOME && am start -W --user 0 -a com.github.uiautomator.ACTION_IDENTIFY -e theme red"
            }
          })
            .always(function () {
              d.identifying = false
            }.bind(this))
        },
        release: function (d) {
          $.ajax({
            url: "/devices/" + d.udid + "/reserved",
            method: "delete",
          }).then(this.loadDevices)
        },
        toggleReady: function (d) {
          d.ready = !d.ready;
          this.updateDeviceInfo(d.udid, { ready: d.ready });
        },
        changeNotes: function (d, notes) {
          d.notes = notes + "..";
          this.updateDeviceInfo(d.udid, { notes: notes })
        },
        updateDeviceInfo: function (udid, info) {
          return $.ajax({
            url: "/devices/" + udid + "/info",
            method: "post",
            data: JSON.stringify(info),
          })
        },
        batchIdentify: function (theme) {
          $.ajax({
            url: "/api/v1/batch/shell",
            method: "post",
            data: {
              command: "input keyevent BACK && am start -W -a com.github.uiautomator.ACTION_IDENTIFY -e theme " + theme,
            }
          })
        },
        batchLock: function () {
          $.ajax({
            url: "/api/v1/batch/lock",
            method: "post",
          })
        },
        batchUnlock: function () {
          $.ajax({
            url: "/api/v1/batch/unlock",
            method: "post",
          })
        },
        batchPower: function () {
          $.ajax({
            url: "/api/v1/batch/shell",
            method: "post",
            data: {
              command: "reboot -p"
            }
          })
        },
        setProperty: function () {
          $.ajax({
            url: "/api/v1/batch/shell",
            method: "post",
            data: {
              command: "am start -a android.intent.action.VIEW -d http://" + location.host + "/property",
            }
          })
        }
      }
    })
  </script>
</body>

</html>

================================================
FILE: templates/property.html
================================================
<html>

<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
  <link rel="stylesheet" href="/assets/style.css">

  <script src="https://cdn.jsdelivr.net/npm/jquery@3.2.1/dist/jquery.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/js/bootstrap.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue@2.5.3/dist/vue.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/moment@2.20.1/moment.min.js"></script>
  <style>

  </style>
</head>

<body>
  <div class="container-fluid">
    <form class="form" method="post" action="/property">
      <h2>资产编号修改</h2>
      <div class="form-group">
        <label>当前编号</label>
        <input class="form-control" type="text" disabled=true value="[[.]]">
      </div>
      <div class="form-group">
        <label>修改编号</label>
        <input class="form-control" type="text" name="id" placeholder="非HIH-PHO-开头填写在这里">
      </div>
      <div class="form-group">
        <div class="input-group">
          <span class="input-group-addon">HIH-PHO-</span>
          <input class="form-control" type="number" name="id_number" autofocus="autofocus">
        </div>
      </div>
      <button class="btn btn-default">提交</button>
    </form>
  </div>
</body>

</html>

================================================
FILE: templates/providers.html
================================================
<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
    <link rel="stylesheet" href="/assets/style.css">

    <script src="https://cdn.jsdelivr.net/npm/jquery@3.2.1/dist/jquery.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/js/bootstrap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.5.3/dist/vue.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/moment@2.20.1/moment.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.1/dist/clipboard.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/notifyjs-browser@0.4.2/dist/notify.min.js"></script>
    <style>
        .color-green {
            color: green;
        }

        .cursor {
            cursor: pointer;
        }
    </style>
</head>

<body>
    <nav class="navbar navbar-default">
        <div class="navbar-header">
            <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1"
                aria-expanded="false">
                <span class="sr-only">Toggle navigation</span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
            </button>
            <a class="navbar-brand" href="/">
                <b>ATX</b> -
                <strong>Server</strong>
            </a>
        </div>
        <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
            <p class="navbar-text"></p>
            <ul class="nav navbar-nav">
                <li>
                    <a href="/">
                        <i class="fa fa-list-alt"></i> 设备列表
                    </a>
                </li>
                <li class="active">
                    <a href="/providers">
                        <i class="fa fa-bandcamp"></i> 节点列表
                    </a>
                </li>
            </ul>
        </div>
    </nav>
    <div class="container-fluid" id="app">
        <table class="table">
            <thead>
                <tr>
                    <th>Present ({{presentCount}})</th>
                    <th>IP</th>
                    <th>Notes</th>
                    <th>Uptime</th>
                    <th>Devices</th>
                    <th>ID</th>
                </tr>
            </thead>
            <tbody>
                <tr v-for="p in providers" :key="p.id">
                    <td>
                        <i class="fa fa-smile-o color-green" v-if="p.present"></i>
                    </td>
                    <td>{{p.ip}}</td>
                    <td @click="updateNotes(p)">{{p.notes}} <i class="cursor fa fa-edit"></i></td>
                    <td>
                        <span v-show="p.present">
                            {{p.presenceChangedAt | timeSince}}
                        </span>
                    </td>
                    <td>
                        <i class="fa fa-mobile" v-for="d in p.devices" style="padding-right: 5px"></i>
                    </td>
                    <td v-text="p.id"></td>
                </tr>
            </tbody>
        </table>
    </div>
    <script>
        new Vue({
            el: "#app",
            data: {
                providers: [],
            },
            mounted: function () {
                $.ajax({
                    method: "GET",
                    url: "/providers?json",
                }).then(function (ret) {
                    console.log(ret)
                    this.providers = ret;
                }.bind(this))
            },
            methods: {
                updateNotes: function (v) {
                    var newNotes = window.prompt("Notes", v.notes);
                    console.log(newNotes, newNotes == null)
                    if (newNotes === null || newNotes == v.notes) {
                        return
                    }
                    v.notes = newNotes + "..";
                    console.log("update notes", v.notes)
                    $.ajax({
                        method: "PUT",
                        url: "/providers/" + v.id,
                        data: JSON.stringify({
                            "notes": newNotes,
                        })
                    }).then(function (ret) {
                        console.log(ret)
                        v.notes = newNotes;
                    }.bind(this))
                }
            },
            computed: {
                presentCount: function () {
                    return this.providers.filter(function (v) {
                        return v.present;
                    }).length;
                }
            },
            filters: {
                timeSince: function (value) {
                    return moment(value).fromNow().replace(" ago", "");
                }
            }
        })
    </script>
</body>

</html>

================================================
FILE: templates/remote.html
================================================
<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8" />
  <title>DRC [[.]]</title>
  <link rel="stylesheet" href="//cdn.jsdelivr.net/bootstrap/3.3.7/css/bootstrap.min.css">
  <link rel="stylesheet" href="//cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
  <link rel="stylesheet" href="//cdn.jsdelivr.net/bootstrap.select/1.12.2/css/bootstrap-select.min.css">
  <link rel="stylesheet" href='[["/assets/remote.css" | urlhash]]'>
  <link rel="stylesheet" href="/assets/bootstrap-tabs.css">
  <link rel="stylesheet" href="/assets/logcat.css">
  <link rel="stylesheet" href="/assets/libs/dropzone/dropzone.min.css">
  <!-- jstree -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jstree@3.3.4/dist/themes/default/style.min.css">
  <style>
    /* body {
      background: #EDECEC;
      padding: 50px
    } */

    .dropzone {
      display: block;
      /*text-align: center;*/
      border: 2px dashed #666;
      border-radius: 5px;
      cursor: pointer;
      height: 74x;
      line-height: 70px;
      font-size: 20px;
      position: relative;
    }

    .dropzone.dz-drag-hover {
      border-style: solid;
      border-color: #4285F4;
    }
  </style>
</head>

<body>
  <div id="app">
    <nav class="navbar navbar-default" style="margin-bottom: 0px">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1"
          aria-expanded="false">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="/">
          <b>ATX</b> -
          <strong>Server</strong>
        </a>
      </div>
      <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
        <p class="navbar-text">{{deviceInfo.udid}} {{device.ip}}
          <a target="_blank" v-bind:href='"http://"+device.ip+":"+device.port+"/term"'>
            终端
          </a>
        </p>
        <form class="navbar-form navbar-left">
          <button class="btn btn-sm btn-default" @click.prevent="toggleScreen">
            <i v-if="screenWS" class="fa fa-eye"></i>
            <i v-else class="fa fa-eye-slash"></i>
          </button>
        </form>
        <p class="navbar-text">
          <a href="javascript:window.close()">标签页关闭</a>,设备自动释放</p>
      </div>
    </nav>
    <div id="upper">
      <div id="left">
        <div class="text-center" style="background-color:white">
          <!-- notification here -->
        </div>
        <section id="screen">
          <canvas id="fgCanvas" class="canvas-fg" v-bind:style="canvasStyle"></canvas>
          <canvas id="bgCanvas" class="canvas-bg" v-bind:style="canvasStyle"></canvas>
          <span class="finger finger-0" style="transform: translate3d(200px, 100px, 0px)"></span>
          <span class="finger finger-1" style="transform: translate3d(200px, 100px, 0px)"></span>
          <!-- <img style="z-index: 10" v-if="loading" src="/assets/loading.svg"> -->
        </section>
        <section id="footer">
          <button class="btn" @click="keyevent('power')">
            <i class="fa fa-power-off color-red"></i> Power</button>
          <button class="btn" @click="keyevent('menu')">
            <i class="glyphicon glyphicon-menu-hamburger"></i> Menu</button>
          <button class="btn" @click="keyevent('home')">
            <i class="fa fa-home"></i> Home</button>
          <button class="btn" @click="keyevent('back')">
            <i class="fa fa-chevron-left"></i> Back</button>
        </section>
      </div>
      <div id="vertical-gap1" class="handle vertical-gap"></div>
      <div id="right">

        <div class="row box">
          <div class="col-md-12 box">
            <!-- Nav tabs -->
            <div class="card">
              <ul class="nav nav-tabs" role="tablist">
                <li role="presentation" @click="navtabs.active=v.id; changeTab(v.id)" :class='{active: v.id == navtabs.active}' v-for="v in navtabs.tabs">
                  <a :href="'#'+v.id" :aria-controls="v.id" role="tab" data-toggle="tab">{{v.name}}</a>
                </li>
                <!-- <li role="presentation"><a href="#logcat" aria-controls="logcat" role="tab" data-toggle="tab">Logcat</a></li> -->
                <!-- <li role="presentation"><a href="#install" aria-controls="install" role="tab" data-toggle="tab">App install</a></li> -->
                <!-- <li role="presentation"><a href="#settings" aria-controls="settings" role="tab" data-toggle="tab">Settings</a></li> -->
                <!-- <li role="presentation"><a href="#home" aria-controls="home" role="tab" data-toggle="tab">TODO</a></li> -->
                <li v-show='navtabs.active == "logcat"' class="follow-log" v-bind:style="{'border-color': logcat.follow ? 'green' : 'gray'}"
                  @click="followLog">
                  <span class="hover-content">
                    <i class="glyphicon glyphicon-arrow-down"></i> Scroll to End of Log</span>
                </li>
              </ul>

              <!-- Tab panes -->
              <div class="tab-content" ref="tab_content" @mousewheel="tabScroll">
                <tab-item v-on:additem="addTabItem" :active="navtabs.active" tabid="home" name="常用">
                  <button @click="fixRotation" class="btn btn-default btn-sm">
                    <span class="glyphicon glyphicon-repeat"></span> 修复旋转</button>
                  <button @click="fixMinicap" class="btn btn-default btn-sm" disabled=true>
                    <span class="fa fa-legal"></span> 修复minicap</button>
                  <div style="padding-top: 10px" v-text="fixConsole"></div>
                  <p>
                    安装
                    <a href="https://chrome.google.com/webstore/detail/tab-capturing-sharing/pcnepejfgcmidedoimegcafiabjnodhk">Chrome 插件 Tap Sharing</a> 让其他人也能看到你的操作
                  </p>

                  <div class="panel panel-default">
                    <div class="panel-heading">
                      <i class="fa fa-font"></i> 快捷输入(开发中)</div>
                    <div class="panel-body">
                      <textarea class="form-control" v-model="inputText"></textarea>
                      <p class="description">
                        <strong>Shift+Enter</strong> to start a new line,
                        <strong>Enter</strong> to send</p>
                    </div>
                  </div>

                  <div class="panel panel-default">
                    <div class="panel-heading">
                      <i class="fa fa-android"></i> 软件安装</div>
                    <div class="panel-body">
                      <div>
                        <div class="input-group">
                          <span class="input-group-addon" id="sizing-addon2">
                            <i class="fa fa-globe"></i>
                          </span>
                          <input type="text" class="form-control" v-model="browserURL" placeholder="输入安装包地址 http://.." @keyup.enter="openBrowser(browserURL)">
                          <span class="input-group-btn">
                            <button type="button" class="btn btn-default" @click='openBrowser(browserURL)'>下载</button>
                          </span>
                        </div>
                        <p class="description">打开浏览器下载,下载完成后需手动安装</p>
                      </div>

                    </div>
                  </div>

                  <div class="panel panel-default">
                    <div class="panel-heading">截图 &amp; 录制</div>
                    <div class="panel-body">
                      <button @click="saveScreenshot()" class="btn btn-sm btn-default">
                        <i class="fa fa-image"></i> 截图</button>
                      <button v-if="!videoReceiver" type="button" class="btn btn-sm btn-default" @click="startVideoRecord">
                        高速录制
                        <i class="fa fa-circle color-red"></i>
                      </button>
                      <button v-if="!videoReceiver" type="button" class="btn btn-sm btn-default" @click="startLowQualityScreenRecord">
                        低速录制
                        <i class="fa fa-circle color-red"></i>
                      </button>
                      <!-- <button v-if="!videoReceiver" -->
                      <button v-if="videoReceiver" type="button" class="btn btn-sm btn-default" @click="stopVideoRecord">
                        停止录制
                        <i class="fa fa-stop color-blue"></i>
                      </button>
                      <p class="description">高速录制时需要保持屏幕开启,每秒仅采集6张图片, 播放时约2倍速度快放。低速录制每一秒采集一张图片</p>
                      <p>
                        <a target="_blank" href="/videos">
                          <i class="fa fa-external-link"></i>
                          查看所有录制</a>
                      </p>
                      <div>
                        <hr>
                        <div>
                          <button @click="saveShortVideo" class="btn btn-sm btn-default">
                            <i class="fa fa-video-camera"></i> 保存最近~10s视频 (Beta)</button>
                        </div>
                        <div v-show="videoUrl" class="text-center">
                          <hr>
                          <div class="input-group">
                            <span class="input-group-addon" id="sizing-addon2">
                              视频地址
                            </span>
                            <input type="text" v-model="videoUrl" class="form-control" disabled>
                            <span class="input-group-btn">
                              <button class="btn btn-default btn-copy" type="button" v-bind:data-clipboard-text="videoUrl">
                                Copy
                              </button>
                            </span>
                          </div>
                          <br>
                          <video :src="videoUrl" controls=controls style="max-height: 400px; border: 1px solid gray">
                            您的浏览器不支持 video 标签
                          </video>
                        </div>
                      </div>
                    </div>
                  </div>

                </tab-item>

                <tab-item v-on:additem="addTabItem" :active="navtabs.active" name="Logcat(TODO)">
                  <p>最大保存行数:
                    <input type="number" v-model.number="logcat.maxKeep">
                  </p>
                  <div class="input-group input-group-sm" style="width: 400px">
                    <span class="input-group-addon">Package name</span>
                    <input type="text" class="form-control" placeholder="com.example...">
                    <span class="input-group-btn">
                      <button class="btn btn-default" type="button">
                        <span class="glyphicon glyphicon-refresh"></span>
                      </button>
                    </span>
                  </div>
                  <br>
                  <table class="logcat" ref="logcat">
                    <tbody>
                      <tr v-for="v in logcat.logs">
                        <td class="logcat-lineno" :style="{color: logcatTag2Color(v.tag)}">{{v.lineno}}</td>
                        <td class="logcat-tag" :style="{color: logcatTag2Color(v.tag)}">{{v.tag}}</td>
                        <td class="logcat-level" :style="{'background-color': logcatLevel2Color(v.level)}">{{v.level}}</td>
                        <td class="logcat-content">{{v.content}}</td>
                      </tr>
                      <tr>
                        <td class="logcat-lineno">2000</td>
                        <td class="logcat-tag" style="color: rgb(105, 155, 221)">EsService</td>
                        <td class="logcat-level">I</td>
                        <td>readResults: read results: 32, lastRequestId: 330</td>
                      </tr>
                      <tr>
                        <td class="logcat-lineno">2000</td>
                        <td></td>
                        <td class="logcat-level">I</td>
                        <td class="logcat-content">Kgd.KeyguardUpdateMonitor: mTimeTickBroadcastReceiver current state is mBootCompleted=true mDeviceProvisioned=true
                          mPhoneState=0 Keyguard.isShowing = false Keyguard.isOccluded = false mScreenOn=true</td>
                      </tr>
                      <tr>
                        <td class="logcat-lineno">2000</td>
                        <td class="logcat-tag">libEGL</td>
                        <td class="logcat-level" style="background-color: gray">D</td>
                        <td class="logcat-content">loaded /system/lib/egl/libEGL_adreno200.so</td>
                      </tr>
                    </tbody>
                  </table>
                </tab-item>

                <tab-item v-on:additem="addTabItem" :active="navtabs.active" tabid="explorer" name="文件(TODO)">
                  <!-- <label>从URL安装(todo)</label>
                  <div style="max-width: 500px" class="input-group">
                    <input type="text" placeholder="https://..." class="form-control">
                    <span class="input-group-btn">
                      <button class="btn btn-default" type="button">Go</button>
                    </span>
                  </div> -->

                  <form action="/upload/sdcard/tmp/" method="post" enctype="multipart/form-data" class="dropzone" ref="upload">
                  </form>
                  <div class="form-group">
                    <label>上传到 (还在开发中) </label>
                    <input type="text" placeholder="上传路径" class="form-control">
                    <p class="help-block">上传路径为空,会根据上传的文件名自动分析要上传的路径</p>
                  </div>
                  <!-- <button class="btn btn-default btn-sm">上传</button> -->

                  <form v-on:submit.prevent="uploadFile">
                    <div class="form-group">
                      <label for="file">Choose file to upload</label>
                      <input type="file" name="file">
                      <p class="help-block">file will be uploaded to "/sdcard/tmp/"</p>
                    </div>
                    <div>
                      <button class="btn btn-default btn-sm">上传</button>
                    </div>
                  </form>

                </tab-item>
                <tab-item v-on:additem="addTabItem" :active="navtabs.active" name="Settings">
                  Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy
                  text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type
                  specimen book. It has survived not only five centuries, but also the leap into electronic typesetting,
                  remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing
                  Lorem Ipsum passage..
                </tab-item>
                <tab-item v-on:additem="addTabItem" :active="navtabs.active" tabid="terminal" name="终端(TODO)">
                  TODO
                </tab-item>
                <tab-item v-on:additem="addTabItem" :active="navtabs.active" tabid="aboutus" name="关于我们">
                  远程真机项目,如有问题欢迎联系 hzsunshx (网易内部) 499563266 (QQ群)
                </tab-item>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- alert and dialogs -->
    <div class="modal" tabindex="-1" role="dialog">
      <div class="modal-dialog" role="document">
        <div class="modal-content">
          <div class="modal-header">
            <button type="button" class="close" data-dismiss="modal" aria-label="Close">
              <span aria-hidden="true">&times;</span>
            </button>
            <h4 class="modal-title">
              <span class="color-red">
                <i class="fa fa-warning"></i> Error</span>
            </h4>
          </div>
          <div class="modal-body">
            <pre v-html="error"></pre>
          </div>
          <div class="modal-footer">
            <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
          </div>
        </div>
      </div>
    </div>
  </div>
</body>
<script src="//cdn.jsdelivr.net/npm/vue@2.5.13/dist/vue.js"></script>
<script src="//cdn.jsdelivr.net/npm/jquery@3.2.1/dist/jquery.min.js"></script>
<script src="//cdn.jsdelivr.net/bootstrap/3.3.7/js/bootstrap.min.js"></script>
<script src="//cdn.jsdelivr.net/bootstrap.select/1.12.2/js/bootstrap-select.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/jstree@3.3.4/dist/jstree.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/file-saver@1.3.3/FileSaver.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/clipboard@1.5.12/dist/clipboard.min.js"></script>
<script src="/assets/libs/notify.js"></script>
<script src="/assets/libs/jquery-tiny-pubsub.js"></script>
<script src='/assets/libs/dropzone/dropzone.min.js'></script>
<script src='[["/assets/common.js" | urlhash]]'></script>
<script type="text/x-template" id="tab-item-component">
  <div role="tabpanel" class="tab-pane" :class='{active: active == id}' :id="id">
    <slot></slot>
  </div>
</script>
<script>
  var deviceIp = "[[.IP]]"
  var devicePort = "[[.Port]]"
  var deviceUdid = "[[.Udid]]"

  console.log("Infos:", deviceIp, devicePort, deviceUdid)

  Vue.component('tab-item', {
    template: '#tab-item-component',
    props: ["name", "active", "tabid"],
    computed: {
      id: function () {
        return this.tabid || this.name.toLowerCase().replace(" ", "-")
      }
    },
    mounted: function () {
      var item = {
        name: this.name,
        id: this.id,
      }
      this.$emit("additem", item);
    }
  });
</script>
<!-- need set var "deviceIp" before -->
<script src='[["/assets/remote.js" | urlhash]]'></script>

</html>

================================================
FILE: utils.go
================================================
package main

func newBool(v bool) *bool {
	return &v
}

func toBool(v *bool) bool {
	if v == nil {
		return false
	}
	return *v
}
Download .txt
gitextract_my193frc/

├── .fsw.yml
├── .github/
│   └── stale.yml
├── .gitignore
├── .goreleaser.yml
├── .travis.yml
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── assets/
│   ├── bootstrap-tabs.css
│   ├── common.js
│   ├── libs/
│   │   ├── jquery-tiny-pubsub.js
│   │   └── notify.js
│   ├── logcat.css
│   ├── remote.css
│   ├── remote.js
│   ├── style.css
│   └── vue-components.js
├── database.go
├── database_test.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── heartbeat/
│   └── heartbeat.go
├── hostsmanager.go
├── httplog.go
├── httpserver.go
├── main.go
├── proto/
│   └── message.go
├── rethinkdb-test/
│   └── main.go
├── scripts/
│   ├── img2video/
│   │   ├── main.py
│   │   ├── requirements.txt
│   │   ├── static/
│   │   │   ├── .gitkeep
│   │   │   └── videos/
│   │   │       └── .gitkeep
│   │   └── templates/
│   │       ├── index.html
│   │       └── videos.html
│   ├── levenshtein.py
│   └── update_coverage_umeng.py
├── templates/
│   ├── edit.html
│   ├── index.html
│   ├── property.html
│   ├── providers.html
│   └── remote.html
└── utils.go
Download .txt
SYMBOL INDEX (114 symbols across 16 files)

FILE: assets/common.js
  function copyToClipboard (line 8) | function copyToClipboard(text) {
  function ImagePool (line 31) | function ImagePool(size) {
  function b64toBlob (line 53) | function b64toBlob(b64Data, contentType, sliceSize) {
  function sendJSON (line 82) | function sendJSON(obj) {
  function coords (line 172) | function coords(boundingW, boundingH, relX, relY, rotation) {
  function HSVtoRGB (line 215) | function HSVtoRGB(h, s, v) {
  function getRandomRgb (line 252) | function getRandomRgb(brightness) {

FILE: assets/libs/notify.js
  function Notification (line 275) | function Notification(elem, data, options) {

FILE: assets/remote.js
  function receiver (line 241) | function receiver(_, data) {
  function dragMoveListener (line 447) | function dragMoveListener(evt) {
  function dragStopListener (line 453) | function dragStopListener(evt) {
  function calculateBounds (line 684) | function calculateBounds() {
  function activeFinger (line 698) | function activeFinger(index, x, y, pressure) {
  function deactiveFinger (line 705) | function deactiveFinger(index) {
  function mouseDownListener (line 709) | function mouseDownListener(event) {
  function mouseMoveListener (line 737) | function mouseMoveListener(event) {
  function mouseUpListener (line 757) | function mouseUpListener(event) {
  function stopMousing (line 773) | function stopMousing() {
  function mouseHoverListener (line 780) | function mouseHoverListener(event) {
  function markPosition (line 795) | function markPosition(pos) {
  function mouseWheelDelayTouchUp (line 812) | function mouseWheelDelayTouchUp() {
  function mouseWheelListener (line 823) | function mouseWheelListener(event) {

FILE: database.go
  function initDB (line 18) | func initDB(address, dbName string) {
  type RdbUtils (line 59) | type RdbUtils struct
    method DBCreateAnyway (line 63) | func (db *RdbUtils) DBCreateAnyway(name string) error {
    method TableMustCreate (line 84) | func (db *RdbUtils) TableMustCreate(name string, optArgs ...r.TableCre...
    method TableCreateAnyway (line 90) | func (db *RdbUtils) TableCreateAnyway(name string, optArgs ...r.TableC...
    method DeviceUpdateOrInsert (line 99) | func (db *RdbUtils) DeviceUpdateOrInsert(dev proto.DeviceInfo) error {
    method DeviceUpdate (line 118) | func (db *RdbUtils) DeviceUpdate(udid string, arg interface{}) error {
    method DeviceList (line 123) | func (db *RdbUtils) DeviceList() (devices []proto.DeviceInfo, err erro...
    method DeviceGet (line 141) | func (db *RdbUtils) DeviceGet(udid string) (info proto.DeviceInfo, err...
    method DeviceFindAll (line 157) | func (db *RdbUtils) DeviceFindAll(info proto.DeviceInfo) (infos []prot...
    method ProvidersAll (line 179) | func (db *RdbUtils) ProvidersAll() (providers []proto.Provider, err er...
    method SetDeviceAbsent (line 197) | func (db *RdbUtils) SetDeviceAbsent(udid string) error {
    method WatchDeviceChanges (line 205) | func (db *RdbUtils) WatchDeviceChanges() (feeds chan r.ChangeResponse,...
    method ProductsFindAll (line 224) | func (db *RdbUtils) ProductsFindAll(brand, model string) (products []p...
    method ProductUpdate (line 245) | func (db *RdbUtils) ProductUpdate(id string, product proto.Product) er...
    method ProviderUpdateOrInsert (line 252) | func (db *RdbUtils) ProviderUpdateOrInsert(machineId string, ip string...
    method ProviderUpdate (line 271) | func (db *RdbUtils) ProviderUpdate(id string, provider proto.Provider)...
    method ProviderOffline (line 277) | func (db *RdbUtils) ProviderOffline(id string) error {
    method ProviderGet (line 290) | func (db *RdbUtils) ProviderGet(id string) (provider proto.Provider, e...

FILE: database_test.go
  function TestInsertOrUpdateDevice (line 9) | func TestInsertOrUpdateDevice(t *testing.T) {

FILE: heartbeat/heartbeat.go
  type Server (line 20) | type Server struct
    method ServeHTTP (line 34) | func (h *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  function NewServer (line 27) | func NewServer(receiver Receiver) *Server {
  type Receiver (line 97) | type Receiver interface
  type Session (line 103) | type Session struct
    method Update (line 111) | func (hs *Session) Update() {
    method drain (line 118) | func (hs *Session) drain() {
  type Context (line 128) | type Context struct

FILE: hostsmanager.go
  function deviceQueryToUdid (line 10) | func deviceQueryToUdid(query string) (udid string, err error) {
  function extractUdidFromInfos (line 18) | func extractUdidFromInfos(infos []proto.DeviceInfo) (udid string, err er...

FILE: httplog.go
  function init (line 18) | func init() {
  type HTTPLogger (line 26) | type HTTPLogger struct
    method Log (line 31) | func (l HTTPLogger) Log(record accesslog.LogRecord) {

FILE: httpserver.go
  function init (line 48) | func init() {
  function renderHTML (line 62) | func renderHTML(w http.ResponseWriter, filename string, value interface{...
  function renderJSON (line 69) | func renderJSON(w http.ResponseWriter, data interface{}) {
  function newHandler (line 80) | func newHandler() http.Handler {
  type ProviderReceiver (line 482) | type ProviderReceiver struct
    method OnConnect (line 484) | func (p *ProviderReceiver) OnConnect(ctx hb2.Context) error {
    method OnDisconnect (line 493) | func (p *ProviderReceiver) OnDisconnect(id string) {
    method OnRequest (line 501) | func (p *ProviderReceiver) OnRequest(ctx hb2.Context) error {

FILE: main.go
  constant version (line 23) | version                = "dev"
  constant defaultATXAgentVersion (line 24) | defaultATXAgentVersion = "0.4.3"
  function handleWebsocketMessage (line 37) | func handleWebsocketMessage(host string, message []byte) {
  function echo (line 41) | func echo(w http.ResponseWriter, r *http.Request) {
  function runAndroidShell (line 127) | func runAndroidShell(ip string, command string) (output string, err erro...
  function batchRunCommand (line 141) | func batchRunCommand(command string) {
  function main (line 159) | func main() {

FILE: proto/message.go
  type MessageType (line 11) | type MessageType
  constant DeviceInfoMessage (line 14) | DeviceInfoMessage = MessageType(0)
  constant PingMessage (line 15) | PingMessage       = MessageType(1)
  type CommonMessage (line 18) | type CommonMessage struct
    method MarshalJSON (line 23) | func (m *CommonMessage) MarshalJSON() []byte {
  type CpuInfo (line 28) | type CpuInfo struct
  type MemoryInfo (line 33) | type MemoryInfo struct
  type OwnerInfo (line 38) | type OwnerInfo struct
  type DeviceInfo (line 42) | type DeviceInfo struct
  type Product (line 85) | type Product struct
  type Provider (line 100) | type Provider struct
    method Addr (line 112) | func (p *Provider) Addr() string {

FILE: rethinkdb-test/main.go
  type RdbUtils (line 12) | type RdbUtils struct
    method DBCreateAnyway (line 16) | func (db *RdbUtils) DBCreateAnyway(name string) error {
    method TableCreateAnyway (line 37) | func (db *RdbUtils) TableCreateAnyway(name string) error {
    method UpdateOrInsertDevice (line 47) | func (db *RdbUtils) UpdateOrInsertDevice(dev proto.DeviceInfo) error {
    method DeviceList (line 55) | func (db *RdbUtils) DeviceList() (devices []proto.DeviceInfo) {
  function init (line 67) | func init() {
  function main (line 83) | func main() {

FILE: scripts/img2video/main.py
  class MainHandler (line 27) | class MainHandler(tornado.web.RequestHandler):
    method get (line 29) | def get(self):
  class VideoHandler (line 34) | class VideoHandler(tornado.web.RequestHandler):
    method get (line 35) | def get(self):
    method delete (line 56) | def delete(self, name):
  function resizefit (line 68) | def resizefit(im, size):  # resize but keep aspect ratio
  class CorsMixin (line 82) | class CorsMixin(object):
    method set_default_headers (line 83) | def set_default_headers(self):
    method options (line 88) | def options(self):
  class Image2VideoWebsocket (line 94) | class Image2VideoWebsocket(tornado.websocket.WebSocketHandler):
    method check_origin (line 95) | def check_origin(self, origin):
    method open (line 98) | def open(self):
    method on_message (line 110) | def on_message(self, message):
    method on_close (line 129) | def on_close(self):
  class Image2VideoHandler (line 144) | class Image2VideoHandler(CorsMixin, tornado.web.RequestHandler):
    method post (line 146) | def post(self):
  function make_app (line 179) | def make_app(**settings):

FILE: scripts/levenshtein.py
  function match_string (line 7) | def match_string(a, b):
  function backward (line 36) | def backward(steps, a, b):
  function main (line 65) | def main():
  function match_distance (line 73) | def match_distance(a, b):

FILE: scripts/update_coverage_umeng.py
  function get_umeng_data (line 64) | def get_umeng_data():
  function main (line 95) | def main():

FILE: utils.go
  function newBool (line 3) | func newBool(v bool) *bool {
  function toBool (line 7) | func toBool(v *bool) bool {
Condensed preview — 44 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (206K chars).
[
  {
    "path": ".fsw.yml",
    "chars": 288,
    "preview": "desc: Auto generated by fswatch [atx-server]\ntriggers:\n- name: \"\"\n  pattens:\n  - '**/*.go'\n  - '**/*.c'\n  - '**/*.py'\n  "
  },
  {
    "path": ".github/stale.yml",
    "chars": 703,
    "preview": "# Number of days of inactivity before an issue becomes stale\ndaysUntilStale: 30\n# Number of days of inactivity before a "
  },
  {
    "path": ".gitignore",
    "chars": 366,
    "preview": "# Binaries for programs and plugins\n*.exe\n*.dll\n*.so\n*.dylib\n*.mp4\n\n# Test binary, build with `go test -c`\n*.test\n\n# Out"
  },
  {
    "path": ".goreleaser.yml",
    "chars": 160,
    "preview": "builds:\n  -\n    goos:\n      - linux\n      - windows\n      - darwin\n    goarch:\n      - amd64\n      - 386\n    flags: -tag"
  },
  {
    "path": ".travis.yml",
    "chars": 234,
    "preview": "---\nlanguage: go\nsudo: false\nservices:\n  - docker\ngo:\n  - \"1.11\"\nenv:\n  - GO111MODULE=on\n\ninstall: true\n\nscript:\n  - go "
  },
  {
    "path": "Dockerfile",
    "chars": 215,
    "preview": "FROM golang:1.11\nRUN mkdir /app\nADD . /app/\nWORKDIR /app\nRUN go build\n\nFROM debian:stretch\nWORKDIR /root/\nCOPY --from=0 "
  },
  {
    "path": "LICENSE",
    "chars": 1078,
    "preview": "The MIT License (MIT)\nCopyright (c) 2017 shengxiang\n\n\nPermission is hereby granted, free of charge, to any person obtain"
  },
  {
    "path": "Makefile",
    "chars": 295,
    "preview": "dc_cmd=docker-compose -p atx\natx_server_addr=192.168.147.230:8000\n# --serial $SERIAL\n\nup:\n\t$(dc_cmd) up -d --build\n\ndown"
  },
  {
    "path": "README.md",
    "chars": 5602,
    "preview": "# Deprecated\nPlease use <https://github.com/openatx/atxserver2> instead.\n\n-----------------\n\n# ATX-SERVER\n[![GitHub star"
  },
  {
    "path": "assets/bootstrap-tabs.css",
    "chars": 1121,
    "preview": "/**\n  Make better bootstrap tabs\n  Thanks to https://bootsnipp.com/snippets/featured/material-design-tab-style\n*/\n\n.nav-"
  },
  {
    "path": "assets/common.js",
    "chars": 6440,
    "preview": "// Copies a string to the clipboard. Must be called from within an \n// event handler such as click. May return false if "
  },
  {
    "path": "assets/libs/jquery-tiny-pubsub.js",
    "chars": 418,
    "preview": "/*! Tiny Pub/Sub - v0.7.0 - 2013-01-29\n* https://github.com/cowboy/jquery-tiny-pubsub\n* Copyright (c) 2013 \"Cowboy\" Ben "
  },
  {
    "path": "assets/libs/notify.js",
    "chars": 21509,
    "preview": "/* Notify.js - http://notifyjs.com/ Copyright (c) 2015 MIT */\n(function (factory) {\n\t// UMD start\n\t// https://github.com"
  },
  {
    "path": "assets/logcat.css",
    "chars": 1202,
    "preview": ".logcat {\n  font-family: \"Courier New\", Courier, monospace;\n  border-collapse: separate;\n  border-spacing: 4px 0px;\n  ba"
  },
  {
    "path": "assets/remote.css",
    "chars": 2796,
    "preview": "* {\n  margin: 0px;\n  padding: 0px;\n}\n\n.color-red {\n  color: red;\n}\n\n.color-green {\n  color: green;\n}\n\n.color-blue {\n  co"
  },
  {
    "path": "assets/remote.js",
    "chars": 26010,
    "preview": "/* Javascript */\n$(function () {\n  $('.btn-copy')\n    .mouseleave(function () {\n      var $element = $(this);\n      $ele"
  },
  {
    "path": "assets/style.css",
    "chars": 600,
    "preview": "body {\n  font-family: \"Segoe UI\", Arial, \"Microsoft Yahei\", sans-serif;\n}\n\n.color-inverse {\n  color: white;\n  background"
  },
  {
    "path": "assets/vue-components.js",
    "chars": 1094,
    "preview": "/* require fontawesome\n*/\n\n/*\nExample:\n<editable-span :content=\"content\" @change=\"content=$event\"/>\n*/\nVue.component('ed"
  },
  {
    "path": "database.go",
    "chars": 7949,
    "preview": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/openatx/atx-server/proto\"\n\t\"github.c"
  },
  {
    "path": "database_test.go",
    "chars": 384,
    "preview": "package main\n\nimport (\n\t\"testing\"\n\n\tr \"gopkg.in/gorethink/gorethink.v4\"\n)\n\nfunc TestInsertOrUpdateDevice(t *testing.T) {"
  },
  {
    "path": "docker-compose.yml",
    "chars": 382,
    "preview": "version: '3'\nservices:\n  atxserver:\n    build: .\n    container_name: atxserver\n    ports:\n      - \"8000:8000\"\n    depend"
  },
  {
    "path": "go.mod",
    "chars": 1479,
    "preview": "module github.com/openatx/atx-server\n\nrequire (\n\tgithub.com/alecthomas/kingpin v2.2.6+incompatible\n\tgithub.com/alecthoma"
  },
  {
    "path": "go.sum",
    "chars": 7596,
    "preview": "github.com/alecthomas/kingpin v2.2.6+incompatible h1:5svnBTFgJjZvGKyYBtMB0+m5wvrbUHiqye8wRJMlnYI=\ngithub.com/alecthomas/"
  },
  {
    "path": "heartbeat/heartbeat.go",
    "chars": 2401,
    "preview": "/*\nFormValue id and port is required\n\nClient send request example\n\n$ curl -X POST -F id=cfa124af -F port=8000\n*/\npackage"
  },
  {
    "path": "hostsmanager.go",
    "chars": 2849,
    "preview": "package main\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\n\t\"github.com/openatx/atx-server/proto\"\n)\n\nfunc deviceQueryToUdid(query stri"
  },
  {
    "path": "httplog.go",
    "chars": 1146,
    "preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\taccesslog \"github.com/mash/go-accesslog\"\n\ti"
  },
  {
    "path": "httpserver.go",
    "chars": 14356,
    "preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"io\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"net/"
  },
  {
    "path": "main.go",
    "chars": 5110,
    "preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"math\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sync\"\n\t\"tim"
  },
  {
    "path": "proto/message.go",
    "chars": 4507,
    "preview": "package proto\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/openatx/androidutils\"\n)\n\ntype MessageType int\n\ncon"
  },
  {
    "path": "rethinkdb-test/main.go",
    "chars": 2282,
    "preview": "package main\n\nimport (\n\t\"log\"\n\t\"strings\"\n\n\t\"github.com/openatx/atx-server/proto\"\n\n\tr \"gopkg.in/gorethink/gorethink.v3\"\n)"
  },
  {
    "path": "scripts/img2video/main.py",
    "chars": 6726,
    "preview": "# coding: utf-8\n#\n# Py3 only\nfrom __future__ import print_function\n\nimport os\nimport uuid\nimport pathlib\nimport shutil\ni"
  },
  {
    "path": "scripts/img2video/requirements.txt",
    "chars": 28,
    "preview": "tornado\nimageio\nnumpy\npillow"
  },
  {
    "path": "scripts/img2video/static/.gitkeep",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "scripts/img2video/static/videos/.gitkeep",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "scripts/img2video/templates/index.html",
    "chars": 607,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-widt"
  },
  {
    "path": "scripts/img2video/templates/videos.html",
    "chars": 7632,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-widt"
  },
  {
    "path": "scripts/levenshtein.py",
    "chars": 2002,
    "preview": "# coding: utf-8\n#\n\nimport numpy as np\n\n\ndef match_string(a, b):\n    a = ' ' + a\n    b = ' ' + b\n    array = np.zeros((le"
  },
  {
    "path": "scripts/update_coverage_umeng.py",
    "chars": 4207,
    "preview": "# coding: utf-8\n#\n'''\n用途:更新ATX-Server上的覆盖率数据(来源umeng)\n\n{\n    \"equipment\": {\n        \"item22\": {\n            \"android\": {"
  },
  {
    "path": "templates/edit.html",
    "chars": 4106,
    "preview": "<!DOCTYPE html>\n<html lang=\"cn\">\n\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, i"
  },
  {
    "path": "templates/index.html",
    "chars": 17427,
    "preview": "<!DOCTYPE html>\n<html>\n\n<head>\n  <meta charset=\"utf-8\">\n  <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n  <meta "
  },
  {
    "path": "templates/property.html",
    "chars": 1675,
    "preview": "<html>\n\n<head>\n  <meta charset=\"utf-8\">\n  <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n  <meta name=\"viewport\" "
  },
  {
    "path": "templates/providers.html",
    "chars": 5379,
    "preview": "<!DOCTYPE html>\n<html>\n\n<head>\n    <meta charset=\"utf-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    "
  },
  {
    "path": "templates/remote.html",
    "chars": 18126,
    "preview": "<!DOCTYPE html>\n<html>\n\n<head>\n  <meta charset=\"utf-8\" />\n  <title>DRC [[.]]</title>\n  <link rel=\"stylesheet\" href=\"//cd"
  },
  {
    "path": "utils.go",
    "chars": 131,
    "preview": "package main\n\nfunc newBool(v bool) *bool {\n\treturn &v\n}\n\nfunc toBool(v *bool) bool {\n\tif v == nil {\n\t\treturn false\n\t}\n\tr"
  }
]

About this extraction

This page contains the full source code of the openatx/atx-server GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 44 files (184.2 KB), approximately 54.9k tokens, and a symbol index with 114 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!