[
  {
    "path": ".fsw.yml",
    "content": "desc: Auto generated by fswatch [atx-server]\ntriggers:\n- name: \"\"\n  pattens:\n  - '**/*.go'\n  - '**/*.c'\n  - '**/*.py'\n  env:\n    DEBUG: \"1\"\n  cmd: go build && ./atx-server\n  shell: true\n  delay: 100ms\n  stop_timeout: 500ms\n  signal: KILL\n  kill_signal: \"\"\nwatch_paths:\n- .\nwatch_depth: 0\n"
  },
  {
    "path": ".github/stale.yml",
    "content": "# Number of days of inactivity before an issue becomes stale\ndaysUntilStale: 30\n# Number of days of inactivity before a stale issue is closed\ndaysUntilClose: 2\n# Issues with these labels will never be considered stale\nexemptLabels:\n  - pinned\n  - security\n  - feature-request\n# Label to use when marking an issue as stale\nstaleLabel: wontfix\n# Comment to post when marking an issue as stale. Set to `false` to disable\nmarkComment: >\n  This issue has been automatically marked as stale because it has not had\n  recent activity. It will be closed if no further activity occurs. Thank you\n  for your contributions.\n# Comment to post when closing a stale issue. Set to `false` to disable\ncloseComment: false"
  },
  {
    "path": ".gitignore",
    "content": "# 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# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736\n.glide/\n*.exe\n__pycache__\n\n# vim\n*.un~\n\n# vscode\nsettings.json\n\n*.mp4.json\n/data\n\natx-server\n"
  },
  {
    "path": ".goreleaser.yml",
    "content": "builds:\n  -\n    goos:\n      - linux\n      - windows\n      - darwin\n    goarch:\n      - amd64\n      - 386\n    flags: -tags vfs\n    hooks:\n      pre: go generate\n"
  },
  {
    "path": ".travis.yml",
    "content": "---\nlanguage: go\nsudo: false\nservices:\n  - docker\ngo:\n  - \"1.11\"\nenv:\n  - GO111MODULE=on\n\ninstall: true\n\nscript:\n  - go test -v\n  - docker build .\n\nafter_success:\n  - test -n \"$TRAVIS_TAG\" && curl -sL https://git.io/goreleaser | bash\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM golang:1.11\nRUN mkdir /app\nADD . /app/\nWORKDIR /app\nRUN go build\n\nFROM debian:stretch\nWORKDIR /root/\nCOPY --from=0 /app/atx-server .\nCOPY --from=0 /app/templates ./templates\nENTRYPOINT ./atx-server --port 8000\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\nCopyright (c) 2017 shengxiang\n\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\nDAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\nOTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE\nOR OTHER DEALINGS IN THE SOFTWARE.\n\n"
  },
  {
    "path": "Makefile",
    "content": "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:\n\t$(dc_cmd) down\n\nshell:\n\tdocker exec -it atxserver bash\n\nps:\n\t$(dc_cmd) ps\n\nlog:\n\t$(dc_cmd) logs -f atxserver\n\ninit:\n\tpython -m uiautomator2 init --server $(atx_server_addr)"
  },
  {
    "path": "README.md",
    "content": "# Deprecated\nPlease use <https://github.com/openatx/atxserver2> instead.\n\n-----------------\n\n# ATX-SERVER\n[![GitHub stars](https://img.shields.io/badge/govendor-vendor-blue.svg)](https://github.com/kardianos/govendor)\n[![Build Status](https://travis-ci.org/openatx/atx-server.svg?branch=master)](https://travis-ci.org/openatx/atx-server)\n\nManage batch of atx-agents\n\n# Testerhome上相关文章\n- [安卓设备集群管理 atx-server](https://testerhome.com/topics/11546) By [codeskyblue](https://testerhome.com/codeskyblue)\n- [atx 安卓集群管理 安装运行及自动化的实践](https://testerhome.com/topics/11588) By [cynic]: (https://testerhome.com/cynic)\n\n# Install\n重要：需要有go语言的基础，知道该如何编译一个go的程序\n\n1. Install and start [rethinkdb](https://rethinkdb.com)\n2. Install [go](https://golang.org)\n\nCompile with go\n\n```bash\n$ go get -v github.com/openatx/atx-server\n$ cd $GOPATH/src/github.com/openatx/atx-server\n$ go build\n```\n\n# Usage\nlaunch `rethinkdb`\n\n```bash\n$ rethinkdb\nRunning rethinkdb 2.3.6 (CLANG 8.1.0 (clang-802.0.42))...\nRunning on Darwin 16.6.0 x86_64\n...\n```\n\nlaunch `atx-server`\n\n```\n./atx-server --port 8000\n```\n\nInstall `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.\n\nSuppose server running `atx-server` got the ip `10.0.1.1`, listen port `8000`. Do the following command\n\n```bash\n$ pip install -U --pre uiautomator2\n$ python -m uiautomator2 init 10.0.1.1:8000\n```\n\nopen browser <http://localhost:8000>, you should see the device listed on the web.\n\n## Advanced usage\n### Set up <https://www.dingtalk.com> notification.\n1. Usage command flag\n\n    ```\n    ./atx-server --ding-token 13gb4db7c276d22e84f788fa693b729d53218b8e07d6ede43de79360c962 --port 8080\n    ```\n\n2. Set up env var\n\n    ```\n    export DING_TOKEN=\"13gb4db7c276d22e84f788fa693b729d53218b8e07d6ede43de79360c962\"\n    ./atx-server --port 8080\n    ```\n\n# APIs\n## /list 接口\n\n其中udid是通过hwaddr, model, serial组合生成的\n\n```bash\n$ curl $SERVER_URL/list\n[\n    {\n        \"udid\": \"741AEDR42P6YM-2c:57:31:4b:40:74-M2_E\",\n        \"ip\": \"10.240.218.20\",\n        \"present\": true,\n        \"ready\": true,\n        \"using\": true,\n        \"provider\": null,\n        \"serial\": \"741AEDR42P6YM\",\n        \"brand\": \"Meizu\",\n        \"model\": \"M2 E\",\n        \"hwaddr\": \"2c:57:31:4b:40:74\",\n        \"agentVersion\": \"0.1.1\",\n        \"battery\": {},\n        \"display\": {\n            \"width\": 1080,\n            \"height\": 1920\n        }\n    }\n]\n```\n\nThere are some fields you need pay attention.\n\n- `present` means device is online\n- `ready` is the thumb :thumbsup: you can see and edit in the web\n- `using` means if device is using by someone\n\n`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.\n\n```json\n\"provider\": {\n    \"id\": \"33576428\",\n    \"ip\": \"10.0.0.1\",\n    \"port\": 10000,\n    \"present\": true  # provider online of not\n}\n```\n\nif `provider` is `null` it means device is not plugged-in.\n\n## /devices/{query}/info\n```bash\n$ curl $SERVER_URL/devices/ip:10.0.0.1/info\n# or\n$ curl $SERVER_URL/devices/$UDID/info\n```\n\n返回值同/list的的单个结果，这里就不写了。\n\n## /version\n`atx-agent`通过检测该接口确定是否升级\n\n```bash\n$ curl /version\n{\n    \"server\": \"dev\",\n    \"atx-agent\": \"0.0.7\"\n}\n```\n\n## 执行shell命令\n```bash\n$ curl -X POST -F command=\"pwd\" $SERVER_URL/devices/{query}/shell\n{\n    \"output\": \"/\"\n}\n```\n\n## 设备管理\n占用、释放\n\n状态码 成功200,失败403\n\n### 占用设备\n```bash\n$ curl -X POST $SERVER_URL/devices/{query}/reserved\nSuccess\n```\n\n### 释放设备\n状态码 成功200,失败403\n\n```bash\n$ curl -X DELETE $SERVER_URL/devices/{query}/reserved\nRelease success\n```\n\n随机占用一台设备\n\n```bash\n$ curl -X POST $SERVER_URL/devices/:random/reserved\nSuccess\n```\n\n## Communication between provider(u2init) and server(atx-server)\nProvider send POST to Server\n**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.\n\n```bash\n$ curl -X POST -F id=$PROVIDER_ID -F port=11000 $SERVER_URL/provider/heartbeat\n```\n\nYou may need to add ip field if provider and server is not in the same network\n\n```bash\n$ PROVIDER_IP=10.0.0.1 # change to your provider ip\n$ PROVIDER_ID=ccdd11ff # change to your provider id\n$ curl -X POST \\\n    -F ip=$PROVIDER_IP \\\n    -F id=$PROVIDER_ID \\\n    -F port=11000 \\\n    $SERVER_URL/provider/heartbeat\n```\n\nServer response status 200 indicate success, or 400 and else means failure\n\nSend using bellow command when there is device plugged-in\n\n```bash\n$ DEVICE_UDID=\"3578298f-b4:0b:44:e6:1f:90-OD103\" # change to your device udid\n$ DATA=\"{\\\"status\\\": \\\"online\\\", \\\"udid\\\": \\\"$DEVICE_UDID\\\"}\"\n$ curl -X POST \\\n    -F id=$PROVIDER_ID \\\n    -F port=11000 \\\n    -F data=\"$DATA\"  $SERVER_URL/provider/heartbeat\n```\n\n## Comminication between atx-agent and atx-server\nIt is complicated. Hard to write.\n\n# Docker\n`atx-server` is dockerized (based on `golang` image) and depends on the official `rethinkdb` container. To build and run all services, use:\n```bash\ndocker-compuse up --build\n```\n`atx-server` can be accessed from `localhost:8000` and `rethinkdb` web console is available at `localhost:8001`, both specified in the compose file.\n`rethinkdb` data is stored at `$PWD/data` (host volume). \n\n## References and some good resources\n- Golang library for rethinkdb [gorethink](https://github.com/GoRethink/gorethink)\n- [美团点评云真机平台实践](https://tech.meituan.com/cloud_phone.html)\n- [腾讯TMQ-远程移动测试平台对比分析](https://blog.csdn.net/TMQ1225/article/details/52369171)\n- [藏经阁-iOS多机远程控制技术](http://www.sohu.com/a/240584209_744135)\n\n# LICENSE\n[MIT](LICENSE)\n"
  },
  {
    "path": "assets/bootstrap-tabs.css",
    "content": "/**\n  Make better bootstrap tabs\n  Thanks to https://bootsnipp.com/snippets/featured/material-design-tab-style\n*/\n\n.nav-tabs {\n  border-bottom: 2px solid #DDD;\n}\n\n.nav-tabs>li.active>a,\n.nav-tabs>li.active>a:focus,\n.nav-tabs>li.active>a:hover {\n  border-width: 0;\n}\n\n.nav-tabs>li>a {\n  border: none;\n  color: #666;\n}\n\n.nav-tabs>li.active>a,\n.nav-tabs>li>a:hover {\n  border: none;\n  color: #4285F4 !important;\n  background: transparent;\n}\n\n.nav-tabs>li>a::after {\n  content: \"\";\n  background: #4285F4;\n  height: 2px;\n  position: absolute;\n  width: 100%;\n  left: 0px;\n  bottom: -1px;\n  transition: all 250ms ease 0s;\n  transform: scale(0);\n}\n\n.nav-tabs>li.active>a::after,\n.nav-tabs>li:hover>a::after {\n  transform: scale(1);\n}\n\n.tab-nav>li>a::after {\n  background: #21527d none repeat scroll 0% 0%;\n  color: #fff;\n}\n\n.tab-pane {\n  padding: 15px 0;\n}\n\n.tab-content {\n  padding: 20px;\n  padding-bottom: 0px;\n  overflow: auto;\n}\n\n.card {\n  background: #FFF none repeat scroll 0% 0%;\n  box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.3);\n  /* margin-bottom: 30px; */\n  /* add */\n  display: flex;\n  flex: 1;\n  flex-direction: column;\n}"
  },
  {
    "path": "assets/common.js",
    "content": "// Copies a string to the clipboard. Must be called from within an \n// event handler such as click. May return false if it failed, but\n// this is not always possible. Browser support for Chrome 43+, \n// Firefox 42+, Safari 10+, Edge and IE 10+.\n// IE: The clipboard feature may be disabled by an administrator. By\n// default a prompt is shown the first time the clipboard is \n// used (per session).\nfunction copyToClipboard(text) {\n  if (window.clipboardData && window.clipboardData.setData) {\n    // IE specific code path to prevent textarea being shown while dialog is visible.\n    return clipboardData.setData(\"Text\", text);\n\n  } else if (document.queryCommandSupported && document.queryCommandSupported(\"copy\")) {\n    var textarea = document.createElement(\"textarea\");\n    textarea.textContent = text;\n    textarea.style.position = \"fixed\"; // Prevent scrolling to bottom of page in MS Edge.\n    document.body.appendChild(textarea);\n    textarea.select();\n    try {\n      return document.execCommand(\"copy\"); // Security exception may be thrown by some browsers.\n    } catch (ex) {\n      console.warn(\"Copy to clipboard failed.\", ex);\n      return false;\n    } finally {\n      document.body.removeChild(textarea);\n    }\n  }\n}\n\n/* Image Pool */\nfunction ImagePool(size) {\n  this.size = size\n  this.images = []\n  this.counter = 0\n}\n\nImagePool.prototype.next = function() {\n  if (this.images.length < this.size) {\n    var image = new Image()\n    this.images.push(image)\n    return image\n  } else {\n    if (this.counter >= this.size) {\n      // Reset for unlikely but theoretically possible overflow.\n      this.counter = 0\n    }\n  }\n\n  return this.images[this.counter++ % this.size]\n}\n\n// convert to blob data\nfunction b64toBlob(b64Data, contentType, sliceSize) {\n  contentType = contentType || '';\n  sliceSize = sliceSize || 512;\n\n  var byteCharacters = atob(b64Data);\n  var byteArrays = [];\n\n  for (var offset = 0; offset < byteCharacters.length; offset += sliceSize) {\n    var slice = byteCharacters.slice(offset, offset + sliceSize);\n\n    var byteNumbers = new Array(slice.length);\n    for (var i = 0; i < slice.length; i++) {\n      byteNumbers[i] = slice.charCodeAt(i);\n    }\n\n    var byteArray = new Uint8Array(byteNumbers);\n    byteArrays.push(byteArray);\n  }\n\n  return new Blob(byteArrays, {\n    type: contentType\n  });\n}\n\nvar MiniTouch = {\n  createNew: function(ws) {\n    // ws: Websocket connection communication with minitouch\n    var control = {}\n\n    function sendJSON(obj) {\n      ws.send(JSON.stringify(obj))\n    }\n\n    // control.coords = function(w, h, x, y, rotation) {\n    //   console.log(w, h, x, y, rotation)\n    //   return {\n    //     xP: x / w,\n    //     yP: y / h,\n    //   }\n    // };\n\n    control.touchDown = function(index, xP, yP, pressure) {\n      sendJSON({\n        operation: 'd',\n        index: index,\n        pressure: pressure,\n        xP: xP,\n        yP: yP,\n      })\n    };\n\n    control.touchWait = function(mseconds) {\n      sendJSON({ operation: 'w', milliseconds: mseconds })\n    }\n\n    control.touchCommit = function() {\n      sendJSON({ operation: 'c' })\n    };\n\n    control.touchMove = function(index, xP, yP, pressure) {\n      sendJSON({\n        operation: 'm',\n        index: index,\n        pressure: pressure,\n        xP: xP,\n        yP: yP,\n      })\n    };\n\n    control.touchUp = function(index) {\n      sendJSON({ operation: 'u', index: index })\n    };\n\n    return control;\n  }\n}\n\n/**\n * Rotation affects the screen as follows:\n *\n *                   0deg\n *                 |------|\n *                 | MENU |\n *                 |------|\n *            -->  |      |  --|\n *            |    |      |    v\n *                 |      |\n *                 |      |\n *                 |------|\n *        |----|-|          |-|----|\n *        |    |M|          | |    |\n *        |    |E|          | |    |\n *  90deg |    |N|          |U|    | 270deg\n *        |    |U|          |N|    |\n *        |    | |          |E|    |\n *        |    | |          |M|    |\n *        |----|-|          |-|----|\n *                 |------|\n *            ^    |      |    |\n *            |--  |      |  <--\n *                 |      |\n *                 |      |\n *                 |------|\n *                 | UNEM |\n *                 |------|\n *                  180deg\n *\n * Which leads to the following mapping:\n *\n * |--------------|------|---------|---------|---------|\n * |              | 0deg |  90deg  |  180deg |  270deg |\n * |--------------|------|---------|---------|---------|\n * | CSS rotate() | 0deg | -90deg  | -180deg |  90deg  |\n * | bounding w   |  w   |    h    |    w    |    h    |\n * | bounding h   |  h   |    w    |    h    |    w    |\n * | pos x        |  x   |   h-y   |   w-x   |    y    |\n * | pos y        |  y   |    x    |   h-y   |   h-x   |\n * |--------------|------|---------|---------|---------|\n */\nfunction coords(boundingW, boundingH, relX, relY, rotation) {\n  var w, h, x, y;\n\n  switch (rotation) {\n    case 0:\n      w = boundingW\n      h = boundingH\n      x = relX\n      y = relY\n      break\n    case 90:\n      w = boundingH\n      h = boundingW\n      x = boundingH - relY\n      y = relX\n      break\n    case 180:\n      w = boundingW\n      h = boundingH\n      x = boundingW - relX\n      y = boundingH - relY\n      break\n    case 270:\n      w = boundingH\n      h = boundingW\n      x = relY\n      y = boundingW - relX\n      break\n  }\n\n  return {\n    xP: x / w,\n    yP: y / h,\n  }\n}\n\n/* accepts parameters\n * h  Object = {h:x, s:y, v:z}\n * OR \n * h, s, v\n * This code expects 0 <= h, s, v <= 1\n * The returned 0 <= r, g, b <= 255 are rounded to the nearest Integer\n */\nfunction HSVtoRGB(h, s, v) {\n  var r, g, b, i, f, p, q, t;\n  if (arguments.length === 1) {\n    s = h.s, v = h.v, h = h.h;\n  }\n  i = Math.floor(h * 6);\n  f = h * 6 - i;\n  p = v * (1 - s);\n  q = v * (1 - f * s);\n  t = v * (1 - (1 - f) * s);\n  switch (i % 6) {\n    case 0:\n      r = v, g = t, b = p;\n      break;\n    case 1:\n      r = q, g = v, b = p;\n      break;\n    case 2:\n      r = p, g = v, b = t;\n      break;\n    case 3:\n      r = p, g = q, b = v;\n      break;\n    case 4:\n      r = t, g = p, b = v;\n      break;\n    case 5:\n      r = v, g = p, b = q;\n      break;\n  }\n  return [\n    Math.round(r * 255),\n    Math.round(g * 255),\n    Math.round(b * 255)\n  ]\n}\n\nfunction getRandomRgb(brightness) {\n  var rgb = HSVtoRGB(Math.random(), Math.random(), 0.8);\n  return 'rgb(' + rgb.join(\",\") + \")\";\n}"
  },
  {
    "path": "assets/libs/jquery-tiny-pubsub.js",
    "content": "/*! Tiny Pub/Sub - v0.7.0 - 2013-01-29\n* https://github.com/cowboy/jquery-tiny-pubsub\n* Copyright (c) 2013 \"Cowboy\" Ben Alman; Licensed MIT \n*/\n(function ($) {\n    var o = $({});\n\n    $.subscribe = function () {\n        o.on.apply(o, arguments);\n    };\n\n    $.unsubscribe = function () {\n        o.off.apply(o, arguments);\n    };\n\n    $.publish = function () {\n        o.trigger.apply(o, arguments);\n    };\n}(jQuery));"
  },
  {
    "path": "assets/libs/notify.js",
    "content": "/* Notify.js - http://notifyjs.com/ Copyright (c) 2015 MIT */\n(function (factory) {\n\t// UMD start\n\t// https://github.com/umdjs/umd/blob/master/jqueryPluginCommonjs.js\n\tif (typeof define === 'function' && define.amd) {\n\t\t// AMD. Register as an anonymous module.\n\t\tdefine(['jquery'], factory);\n\t} else if (typeof module === 'object' && module.exports) {\n\t\t// Node/CommonJS\n\t\tmodule.exports = function( root, jQuery ) {\n\t\t\tif ( jQuery === undefined ) {\n\t\t\t\t// require('jQuery') returns a factory that requires window to\n\t\t\t\t// build a jQuery instance, we normalize how we use modules\n\t\t\t\t// that require this pattern but the window provided is a noop\n\t\t\t\t// if it's defined (how jquery works)\n\t\t\t\tif ( typeof window !== 'undefined' ) {\n\t\t\t\t\tjQuery = require('jquery');\n\t\t\t\t}\n\t\t\t\telse {\n\t\t\t\t\tjQuery = require('jquery')(root);\n\t\t\t\t}\n\t\t\t}\n\t\t\tfactory(jQuery);\n\t\t\treturn jQuery;\n\t\t};\n\t} else {\n\t\t// Browser globals\n\t\tfactory(jQuery);\n\t}\n}(function ($) {\n\t//IE8 indexOf polyfill\n\tvar indexOf = [].indexOf || function(item) {\n\t\tfor (var i = 0, l = this.length; i < l; i++) {\n\t\t\tif (i in this && this[i] === item) {\n\t\t\t\treturn i;\n\t\t\t}\n\t\t}\n\t\treturn -1;\n\t};\n\n\tvar pluginName = \"notify\";\n\tvar pluginClassName = pluginName + \"js\";\n\tvar blankFieldName = pluginName + \"!blank\";\n\n\tvar positions = {\n\t\tt: \"top\",\n\t\tm: \"middle\",\n\t\tb: \"bottom\",\n\t\tl: \"left\",\n\t\tc: \"center\",\n\t\tr: \"right\"\n\t};\n\tvar hAligns = [\"l\", \"c\", \"r\"];\n\tvar vAligns = [\"t\", \"m\", \"b\"];\n\tvar mainPositions = [\"t\", \"b\", \"l\", \"r\"];\n\tvar opposites = {\n\t\tt: \"b\",\n\t\tm: null,\n\t\tb: \"t\",\n\t\tl: \"r\",\n\t\tc: null,\n\t\tr: \"l\"\n\t};\n\n\tvar parsePosition = function(str) {\n\t\tvar pos;\n\t\tpos = [];\n\t\t$.each(str.split(/\\W+/), function(i, word) {\n\t\t\tvar w;\n\t\t\tw = word.toLowerCase().charAt(0);\n\t\t\tif (positions[w]) {\n\t\t\t\treturn pos.push(w);\n\t\t\t}\n\t\t});\n\t\treturn pos;\n\t};\n\n\tvar styles = {};\n\n\tvar coreStyle = {\n\t\tname: \"core\",\n\t\thtml: \"<div class=\\\"\" + pluginClassName + \"-wrapper\\\">\\n\t<div class=\\\"\" + pluginClassName + \"-arrow\\\"></div>\\n\t<div class=\\\"\" + pluginClassName + \"-container\\\"></div>\\n</div>\",\n\t\tcss: \".\" + pluginClassName + \"-corner {\\n\tposition: fixed;\\n\tmargin: 5px;\\n\tz-index: 1050;\\n}\\n\\n.\" + pluginClassName + \"-corner .\" + pluginClassName + \"-wrapper,\\n.\" + pluginClassName + \"-corner .\" + pluginClassName + \"-container {\\n\tposition: relative;\\n\tdisplay: block;\\n\theight: inherit;\\n\twidth: inherit;\\n\tmargin: 3px;\\n}\\n\\n.\" + pluginClassName + \"-wrapper {\\n\tz-index: 1;\\n\tposition: absolute;\\n\tdisplay: inline-block;\\n\theight: 0;\\n\twidth: 0;\\n}\\n\\n.\" + pluginClassName + \"-container {\\n\tdisplay: none;\\n\tz-index: 1;\\n\tposition: absolute;\\n}\\n\\n.\" + pluginClassName + \"-hidable {\\n\tcursor: pointer;\\n}\\n\\n[data-notify-text],[data-notify-html] {\\n\tposition: relative;\\n}\\n\\n.\" + pluginClassName + \"-arrow {\\n\tposition: absolute;\\n\tz-index: 2;\\n\twidth: 0;\\n\theight: 0;\\n}\"\n\t};\n\n\tvar stylePrefixes = {\n\t\t\"border-radius\": [\"-webkit-\", \"-moz-\"]\n\t};\n\n\tvar getStyle = function(name) {\n\t\treturn styles[name];\n\t};\n\n\tvar removeStyle = function(name) {\n\t\tif (!name) {\n\t\t\tthrow \"Missing Style name\";\n\t\t}\n\t\tif (styles[name]) {\n\t\t\tdelete styles[name];\n\t\t}\n\t};\n\n\tvar addStyle = function(name, def) {\n\t\tif (!name) {\n\t\t\tthrow \"Missing Style name\";\n\t\t}\n\t\tif (!def) {\n\t\t\tthrow \"Missing Style definition\";\n\t\t}\n\t\tif (!def.html) {\n\t\t\tthrow \"Missing Style HTML\";\n\t\t}\n\t\t//remove existing style\n\t\tvar existing = styles[name];\n\t\tif (existing && existing.cssElem) {\n\t\t\tif (window.console) {\n\t\t\t\tconsole.warn(pluginName + \": overwriting style '\" + name + \"'\");\n\t\t\t}\n\t\t\tstyles[name].cssElem.remove();\n\t\t}\n\t\tdef.name = name;\n\t\tstyles[name] = def;\n\t\tvar cssText = \"\";\n\t\tif (def.classes) {\n\t\t\t$.each(def.classes, function(className, props) {\n\t\t\t\tcssText += \".\" + pluginClassName + \"-\" + def.name + \"-\" + className + \" {\\n\";\n\t\t\t\t$.each(props, function(name, val) {\n\t\t\t\t\tif (stylePrefixes[name]) {\n\t\t\t\t\t\t$.each(stylePrefixes[name], function(i, prefix) {\n\t\t\t\t\t\t\treturn cssText += \"\t\" + prefix + name + \": \" + val + \";\\n\";\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t\treturn cssText += \"\t\" + name + \": \" + val + \";\\n\";\n\t\t\t\t});\n\t\t\t\treturn cssText += \"}\\n\";\n\t\t\t});\n\t\t}\n\t\tif (def.css) {\n\t\t\tcssText += \"/* styles for \" + def.name + \" */\\n\" + def.css;\n\t\t}\n\t\tif (cssText) {\n\t\t\tdef.cssElem = insertCSS(cssText);\n\t\t\tdef.cssElem.attr(\"id\", \"notify-\" + def.name);\n\t\t}\n\t\tvar fields = {};\n\t\tvar elem = $(def.html);\n\t\tfindFields(\"html\", elem, fields);\n\t\tfindFields(\"text\", elem, fields);\n\t\tdef.fields = fields;\n\t};\n\n\tvar insertCSS = function(cssText) {\n\t\tvar e, elem, error;\n\t\telem = createElem(\"style\");\n\t\telem.attr(\"type\", 'text/css');\n\t\t$(\"head\").append(elem);\n\t\ttry {\n\t\t\telem.html(cssText);\n\t\t} catch (_) {\n\t\t\telem[0].styleSheet.cssText = cssText;\n\t\t}\n\t\treturn elem;\n\t};\n\n\tvar findFields = function(type, elem, fields) {\n\t\tvar attr;\n\t\tif (type !== \"html\") {\n\t\t\ttype = \"text\";\n\t\t}\n\t\tattr = \"data-notify-\" + type;\n\t\treturn find(elem, \"[\" + attr + \"]\").each(function() {\n\t\t\tvar name;\n\t\t\tname = $(this).attr(attr);\n\t\t\tif (!name) {\n\t\t\t\tname = blankFieldName;\n\t\t\t}\n\t\t\tfields[name] = type;\n\t\t});\n\t};\n\n\tvar find = function(elem, selector) {\n\t\tif (elem.is(selector)) {\n\t\t\treturn elem;\n\t\t} else {\n\t\t\treturn elem.find(selector);\n\t\t}\n\t};\n\n\tvar pluginOptions = {\n\t\tclickToHide: true,\n\t\tautoHide: true,\n\t\tautoHideDelay: 5000,\n\t\tarrowShow: true,\n\t\tarrowSize: 5,\n\t\tbreakNewLines: true,\n\t\telementPosition: \"bottom\",\n\t\tglobalPosition: \"top right\",\n\t\tstyle: \"bootstrap\",\n\t\tclassName: \"error\",\n\t\tshowAnimation: \"slideDown\",\n\t\tshowDuration: 400,\n\t\thideAnimation: \"slideUp\",\n\t\thideDuration: 200,\n\t\tgap: 5\n\t};\n\n\tvar inherit = function(a, b) {\n\t\tvar F;\n\t\tF = function() {};\n\t\tF.prototype = a;\n\t\treturn $.extend(true, new F(), b);\n\t};\n\n\tvar defaults = function(opts) {\n\t\treturn $.extend(pluginOptions, opts);\n\t};\n\n\tvar createElem = function(tag) {\n\t\treturn $(\"<\" + tag + \"></\" + tag + \">\");\n\t};\n\n\tvar globalAnchors = {};\n\n\tvar getAnchorElement = function(element) {\n\t\tvar radios;\n\t\tif (element.is('[type=radio]')) {\n\t\t\tradios = element.parents('form:first').find('[type=radio]').filter(function(i, e) {\n\t\t\t\treturn $(e).attr(\"name\") === element.attr(\"name\");\n\t\t\t});\n\t\t\telement = radios.first();\n\t\t}\n\t\treturn element;\n\t};\n\n\tvar incr = function(obj, pos, val) {\n\t\tvar opp, temp;\n\t\tif (typeof val === \"string\") {\n\t\t\tval = parseInt(val, 10);\n\t\t} else if (typeof val !== \"number\") {\n\t\t\treturn;\n\t\t}\n\t\tif (isNaN(val)) {\n\t\t\treturn;\n\t\t}\n\t\topp = positions[opposites[pos.charAt(0)]];\n\t\ttemp = pos;\n\t\tif (obj[opp] !== undefined) {\n\t\t\tpos = positions[opp.charAt(0)];\n\t\t\tval = -val;\n\t\t}\n\t\tif (obj[pos] === undefined) {\n\t\t\tobj[pos] = val;\n\t\t} else {\n\t\t\tobj[pos] += val;\n\t\t}\n\t\treturn null;\n\t};\n\n\tvar realign = function(alignment, inner, outer) {\n\t\tif (alignment === \"l\" || alignment === \"t\") {\n\t\t\treturn 0;\n\t\t} else if (alignment === \"c\" || alignment === \"m\") {\n\t\t\treturn outer / 2 - inner / 2;\n\t\t} else if (alignment === \"r\" || alignment === \"b\") {\n\t\t\treturn outer - inner;\n\t\t}\n\t\tthrow \"Invalid alignment\";\n\t};\n\n\tvar encode = function(text) {\n\t\tencode.e = encode.e || createElem(\"div\");\n\t\treturn encode.e.text(text).html();\n\t};\n\n\tfunction Notification(elem, data, options) {\n\t\tif (typeof options === \"string\") {\n\t\t\toptions = {\n\t\t\t\tclassName: options\n\t\t\t};\n\t\t}\n\t\tthis.options = inherit(pluginOptions, $.isPlainObject(options) ? options : {});\n\t\tthis.loadHTML();\n\t\tthis.wrapper = $(coreStyle.html);\n\t\tif (this.options.clickToHide) {\n\t\t\tthis.wrapper.addClass(pluginClassName + \"-hidable\");\n\t\t}\n\t\tthis.wrapper.data(pluginClassName, this);\n\t\tthis.arrow = this.wrapper.find(\".\" + pluginClassName + \"-arrow\");\n\t\tthis.container = this.wrapper.find(\".\" + pluginClassName + \"-container\");\n\t\tthis.container.append(this.userContainer);\n\t\tif (elem && elem.length) {\n\t\t\tthis.elementType = elem.attr(\"type\");\n\t\t\tthis.originalElement = elem;\n\t\t\tthis.elem = getAnchorElement(elem);\n\t\t\tthis.elem.data(pluginClassName, this);\n\t\t\tthis.elem.before(this.wrapper);\n\t\t}\n\t\tthis.container.hide();\n\t\tthis.run(data);\n\t}\n\n\tNotification.prototype.loadHTML = function() {\n\t\tvar style;\n\t\tstyle = this.getStyle();\n\t\tthis.userContainer = $(style.html);\n\t\tthis.userFields = style.fields;\n\t};\n\n\tNotification.prototype.show = function(show, userCallback) {\n\t\tvar args, callback, elems, fn, hidden;\n\t\tcallback = (function(_this) {\n\t\t\treturn function() {\n\t\t\t\tif (!show && !_this.elem) {\n\t\t\t\t\t_this.destroy();\n\t\t\t\t}\n\t\t\t\tif (userCallback) {\n\t\t\t\t\treturn userCallback();\n\t\t\t\t}\n\t\t\t};\n\t\t})(this);\n\t\thidden = this.container.parent().parents(':hidden').length > 0;\n\t\telems = this.container.add(this.arrow);\n\t\targs = [];\n\t\tif (hidden && show) {\n\t\t\tfn = \"show\";\n\t\t} else if (hidden && !show) {\n\t\t\tfn = \"hide\";\n\t\t} else if (!hidden && show) {\n\t\t\tfn = this.options.showAnimation;\n\t\t\targs.push(this.options.showDuration);\n\t\t} else if (!hidden && !show) {\n\t\t\tfn = this.options.hideAnimation;\n\t\t\targs.push(this.options.hideDuration);\n\t\t} else {\n\t\t\treturn callback();\n\t\t}\n\t\targs.push(callback);\n\t\treturn elems[fn].apply(elems, args);\n\t};\n\n\tNotification.prototype.setGlobalPosition = function() {\n\t\tvar p = this.getPosition();\n\t\tvar pMain = p[0];\n\t\tvar pAlign = p[1];\n\t\tvar main = positions[pMain];\n\t\tvar align = positions[pAlign];\n\t\tvar key = pMain + \"|\" + pAlign;\n\t\tvar anchor = globalAnchors[key];\n\t\tif (!anchor || !document.body.contains(anchor[0])) {\n\t\t\tanchor = globalAnchors[key] = createElem(\"div\");\n\t\t\tvar css = {};\n\t\t\tcss[main] = 0;\n\t\t\tif (align === \"middle\") {\n\t\t\t\tcss.top = '45%';\n\t\t\t} else if (align === \"center\") {\n\t\t\t\tcss.left = '45%';\n\t\t\t} else {\n\t\t\t\tcss[align] = 0;\n\t\t\t}\n\t\t\tanchor.css(css).addClass(pluginClassName + \"-corner\");\n\t\t\t$(\"body\").append(anchor);\n\t\t}\n\t\treturn anchor.prepend(this.wrapper);\n\t};\n\n\tNotification.prototype.setElementPosition = function() {\n\t\tvar 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;\n\t\tposition = this.getPosition();\n\t\tpMain = position[0];\n\t\tpAlign = position[1];\n\t\tpArrow = position[2];\n\t\telemPos = this.elem.position();\n\t\telemH = this.elem.outerHeight();\n\t\telemW = this.elem.outerWidth();\n\t\telemIH = this.elem.innerHeight();\n\t\telemIW = this.elem.innerWidth();\n\t\twrapPos = this.wrapper.position();\n\t\tcontH = this.container.height();\n\t\tcontW = this.container.width();\n\t\tmainFull = positions[pMain];\n\t\topp = opposites[pMain];\n\t\toppFull = positions[opp];\n\t\tcss = {};\n\t\tcss[oppFull] = pMain === \"b\" ? elemH : pMain === \"r\" ? elemW : 0;\n\t\tincr(css, \"top\", elemPos.top - wrapPos.top);\n\t\tincr(css, \"left\", elemPos.left - wrapPos.left);\n\t\tref = [\"top\", \"left\"];\n\t\tfor (j = 0, len = ref.length; j < len; j++) {\n\t\t\tpos = ref[j];\n\t\t\tmargin = parseInt(this.elem.css(\"margin-\" + pos), 10);\n\t\t\tif (margin) {\n\t\t\t\tincr(css, pos, margin);\n\t\t\t}\n\t\t}\n\t\tgap = Math.max(0, this.options.gap - (this.options.arrowShow ? arrowSize : 0));\n\t\tincr(css, oppFull, gap);\n\t\tif (!this.options.arrowShow) {\n\t\t\tthis.arrow.hide();\n\t\t} else {\n\t\t\tarrowSize = this.options.arrowSize;\n\t\t\tarrowCss = $.extend({}, css);\n\t\t\tarrowColor = this.userContainer.css(\"border-color\") || this.userContainer.css(\"border-top-color\") || this.userContainer.css(\"background-color\") || \"white\";\n\t\t\tfor (k = 0, len1 = mainPositions.length; k < len1; k++) {\n\t\t\t\tpos = mainPositions[k];\n\t\t\t\tposFull = positions[pos];\n\t\t\t\tif (pos === opp) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tcolor = posFull === mainFull ? arrowColor : \"transparent\";\n\t\t\t\tarrowCss[\"border-\" + posFull] = arrowSize + \"px solid \" + color;\n\t\t\t}\n\t\t\tincr(css, positions[opp], arrowSize);\n\t\t\tif (indexOf.call(mainPositions, pAlign) >= 0) {\n\t\t\t\tincr(arrowCss, positions[pAlign], arrowSize * 2);\n\t\t\t}\n\t\t}\n\t\tif (indexOf.call(vAligns, pMain) >= 0) {\n\t\t\tincr(css, \"left\", realign(pAlign, contW, elemW));\n\t\t\tif (arrowCss) {\n\t\t\t\tincr(arrowCss, \"left\", realign(pAlign, arrowSize, elemIW));\n\t\t\t}\n\t\t} else if (indexOf.call(hAligns, pMain) >= 0) {\n\t\t\tincr(css, \"top\", realign(pAlign, contH, elemH));\n\t\t\tif (arrowCss) {\n\t\t\t\tincr(arrowCss, \"top\", realign(pAlign, arrowSize, elemIH));\n\t\t\t}\n\t\t}\n\t\tif (this.container.is(\":visible\")) {\n\t\t\tcss.display = \"block\";\n\t\t}\n\t\tthis.container.removeAttr(\"style\").css(css);\n\t\tif (arrowCss) {\n\t\t\treturn this.arrow.removeAttr(\"style\").css(arrowCss);\n\t\t}\n\t};\n\n\tNotification.prototype.getPosition = function() {\n\t\tvar pos, ref, ref1, ref2, ref3, ref4, ref5, text;\n\t\ttext = this.options.position || (this.elem ? this.options.elementPosition : this.options.globalPosition);\n\t\tpos = parsePosition(text);\n\t\tif (pos.length === 0) {\n\t\t\tpos[0] = \"b\";\n\t\t}\n\t\tif (ref = pos[0], indexOf.call(mainPositions, ref) < 0) {\n\t\t\tthrow \"Must be one of [\" + mainPositions + \"]\";\n\t\t}\n\t\tif (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))) {\n\t\t\tpos[1] = (ref5 = pos[0], indexOf.call(hAligns, ref5) >= 0) ? \"m\" : \"l\";\n\t\t}\n\t\tif (pos.length === 2) {\n\t\t\tpos[2] = pos[1];\n\t\t}\n\t\treturn pos;\n\t};\n\n\tNotification.prototype.getStyle = function(name) {\n\t\tvar style;\n\t\tif (!name) {\n\t\t\tname = this.options.style;\n\t\t}\n\t\tif (!name) {\n\t\t\tname = \"default\";\n\t\t}\n\t\tstyle = styles[name];\n\t\tif (!style) {\n\t\t\tthrow \"Missing style: \" + name;\n\t\t}\n\t\treturn style;\n\t};\n\n\tNotification.prototype.updateClasses = function() {\n\t\tvar classes, style;\n\t\tclasses = [\"base\"];\n\t\tif ($.isArray(this.options.className)) {\n\t\t\tclasses = classes.concat(this.options.className);\n\t\t} else if (this.options.className) {\n\t\t\tclasses.push(this.options.className);\n\t\t}\n\t\tstyle = this.getStyle();\n\t\tclasses = $.map(classes, function(n) {\n\t\t\treturn pluginClassName + \"-\" + style.name + \"-\" + n;\n\t\t}).join(\" \");\n\t\treturn this.userContainer.attr(\"class\", classes);\n\t};\n\n\tNotification.prototype.run = function(data, options) {\n\t\tvar d, datas, name, type, value;\n\t\tif ($.isPlainObject(options)) {\n\t\t\t$.extend(this.options, options);\n\t\t} else if ($.type(options) === \"string\") {\n\t\t\tthis.options.className = options;\n\t\t}\n\t\tif (this.container && !data) {\n\t\t\tthis.show(false);\n\t\t\treturn;\n\t\t} else if (!this.container && !data) {\n\t\t\treturn;\n\t\t}\n\t\tdatas = {};\n\t\tif ($.isPlainObject(data)) {\n\t\t\tdatas = data;\n\t\t} else {\n\t\t\tdatas[blankFieldName] = data;\n\t\t}\n\t\tfor (name in datas) {\n\t\t\td = datas[name];\n\t\t\ttype = this.userFields[name];\n\t\t\tif (!type) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (type === \"text\") {\n\t\t\t\td = encode(d);\n\t\t\t\tif (this.options.breakNewLines) {\n\t\t\t\t\td = d.replace(/\\n/g, '<br/>');\n\t\t\t\t}\n\t\t\t}\n\t\t\tvalue = name === blankFieldName ? '' : '=' + name;\n\t\t\tfind(this.userContainer, \"[data-notify-\" + type + value + \"]\").html(d);\n\t\t}\n\t\tthis.updateClasses();\n\t\tif (this.elem) {\n\t\t\tthis.setElementPosition();\n\t\t} else {\n\t\t\tthis.setGlobalPosition();\n\t\t}\n\t\tthis.show(true);\n\t\tif (this.options.autoHide) {\n\t\t\tclearTimeout(this.autohideTimer);\n\t\t\tthis.autohideTimer = setTimeout(this.show.bind(this, false), this.options.autoHideDelay);\n\t\t}\n\t};\n\n\tNotification.prototype.destroy = function() {\n\t\tthis.wrapper.data(pluginClassName, null);\n\t\tthis.wrapper.remove();\n\t};\n\n\t$[pluginName] = function(elem, data, options) {\n\t\tif ((elem && elem.nodeName) || elem.jquery) {\n\t\t\t$(elem)[pluginName](data, options);\n\t\t} else {\n\t\t\toptions = data;\n\t\t\tdata = elem;\n\t\t\tnew Notification(null, data, options);\n\t\t}\n\t\treturn elem;\n\t};\n\n\t$.fn[pluginName] = function(data, options) {\n\t\t$(this).each(function() {\n\t\t\tvar prev = getAnchorElement($(this)).data(pluginClassName);\n\t\t\tif (prev) {\n\t\t\t\tprev.destroy();\n\t\t\t}\n\t\t\tvar curr = new Notification($(this), data, options);\n\t\t});\n\t\treturn this;\n\t};\n\n\t$.extend($[pluginName], {\n\t\tdefaults: defaults,\n\t\taddStyle: addStyle,\n\t\tremoveStyle: removeStyle,\n\t\tpluginOptions: pluginOptions,\n\t\tgetStyle: getStyle,\n\t\tinsertCSS: insertCSS\n\t});\n\n\t//always include the default bootstrap style\n\taddStyle(\"bootstrap\", {\n\t\thtml: \"<div>\\n<span data-notify-text></span>\\n</div>\",\n\t\tclasses: {\n\t\t\tbase: {\n\t\t\t\t\"font-weight\": \"bold\",\n\t\t\t\t\"padding\": \"8px 15px 8px 14px\",\n\t\t\t\t\"text-shadow\": \"0 1px 0 rgba(255, 255, 255, 0.5)\",\n\t\t\t\t\"background-color\": \"#fcf8e3\",\n\t\t\t\t\"border\": \"1px solid #fbeed5\",\n\t\t\t\t\"border-radius\": \"4px\",\n\t\t\t\t\"white-space\": \"nowrap\",\n\t\t\t\t\"padding-left\": \"25px\",\n\t\t\t\t\"background-repeat\": \"no-repeat\",\n\t\t\t\t\"background-position\": \"3px 7px\"\n\t\t\t},\n\t\t\terror: {\n\t\t\t\t\"color\": \"#B94A48\",\n\t\t\t\t\"background-color\": \"#F2DEDE\",\n\t\t\t\t\"border-color\": \"#EED3D7\",\n\t\t\t\t\"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=)\"\n\t\t\t},\n\t\t\tsuccess: {\n\t\t\t\t\"color\": \"#468847\",\n\t\t\t\t\"background-color\": \"#DFF0D8\",\n\t\t\t\t\"border-color\": \"#D6E9C6\",\n\t\t\t\t\"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==)\"\n\t\t\t},\n\t\t\tinfo: {\n\t\t\t\t\"color\": \"#3A87AD\",\n\t\t\t\t\"background-color\": \"#D9EDF7\",\n\t\t\t\t\"border-color\": \"#BCE8F1\",\n\t\t\t\t\"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=)\"\n\t\t\t},\n\t\t\twarn: {\n\t\t\t\t\"color\": \"#C09853\",\n\t\t\t\t\"background-color\": \"#FCF8E3\",\n\t\t\t\t\"border-color\": \"#FBEED5\",\n\t\t\t\t\"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)\"\n\t\t\t}\n\t\t}\n\t});\n\n\t$(function() {\n\t\tinsertCSS(coreStyle.css).attr(\"id\", \"core-notify\");\n\t\t$(document).on(\"click\", \".\" + pluginClassName + \"-hidable\", function(e) {\n\t\t\t$(this).trigger(\"notify-hide\");\n\t\t});\n\t\t$(document).on(\"notify-hide\", \".\" + pluginClassName + \"-wrapper\", function(e) {\n\t\t\tvar elem = $(this).data(pluginClassName);\n\t\t\tif(elem) {\n\t\t\t\telem.show(false);\n\t\t\t}\n\t\t});\n\t});\n\n}));\n"
  },
  {
    "path": "assets/logcat.css",
    "content": ".logcat {\n  font-family: \"Courier New\", Courier, monospace;\n  border-collapse: separate;\n  border-spacing: 4px 0px;\n  background-color: black;\n  color: white;\n  width: 100%;\n  border-radius: 5px;\n  font-size: 10px;\n}\n\n.logcat-lineno {\n  vertical-align: text-top;\n}\n\n.logcat-tag {\n  color: red;\n  text-align: right;\n  vertical-align: text-top;\n  width: 10em;\n  font-weight: 500;\n  font-family: Consolas;\n}\n\n.logcat-level {\n  text-align: center;\n  vertical-align: text-top;\n  color: white;\n  background-color: green;\n  width: 1.5em;\n}\n\n.logcat-content {\n  word-break: normal;\n  white-space: pre-line;\n}\n\n.nav-tabs>li.follow-log {\n  display: block;\n  border-color: green;\n  border-width: 1.5mm;\n  border-style: solid;\n  border-radius: 50%;\n  width: 6mm;\n  height: 6mm;\n  opacity: 0.7;\n  background: white;\n  position: relative;\n  float: right;\n  top: 60px;\n  right: 50px;\n  cursor: pointer;\n  /* pointer-events: none; */\n  /* left: 100mm; */\n}\n\n.follow-log .hover-content {\n  display: none;\n  background: gray;\n  color: white;\n  width: 12em;\n  margin-left: -10.5em;\n  margin-top: -0.5em;\n  padding: 2px;\n  border-radius: 2px;\n  text-align: center;\n}\n\n.follow-log:hover .hover-content {\n  display: block;\n}"
  },
  {
    "path": "assets/remote.css",
    "content": "* {\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  color: blue;\n}\n\n.description {\n  margin-top: 10px;\n  margin-left: 5px;\n  font-size: 0.8em;\n  color: gray;\n}\n\nhtml,\nbody {\n  height: 100%;\n  width: 100%;\n  font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\"\n  /*display: flex;*/\n}\n\n.finger {\n  position: absolute;\n  border-style: solid;\n  border-radius: 50%;\n  border-color: white;\n  border-width: 0mm;\n  width: 6mm;\n  height: 6mm;\n  top: -3mm;\n  left: -3mm;\n  opacity: 0.7;\n  pointer-events: none;\n  background: red;\n  /*#464646;*/\n  /*background: red;*/\n  display: none;\n}\n\n.finger.active {\n  display: block;\n  border-color: #464646;\n  border-width: 1mm;\n}\n\n#app {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n}\n\n#left {\n  /*height: 100%;*/\n  display: flex;\n  flex-direction: column;\n  width: 700px;\n}\n\n.editor-container {\n  position: relative;\n  min-height: 100px;\n  padding: 0px;\n  margin: 0px;\n  flex: 1;\n}\n\n#right {\n  display: flex;\n  flex-direction: column;\n  flex: 1;\n  overflow-y: auto;\n  overflow-x: hidden;\n  /*border: 1px solid red;*/\n  /*position: relative;*/\n}\n\nsection {\n  /*border: 1px solid red;*/\n}\n\n#screen {\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  justify-content: center;\n  flex: 1;\n  background-color: gray;\n  border-top: 1px solid darkgray;\n  border-bottom: 1px solid darkgray;\n}\n\n#footer {\n  height: 50px;\n  display: flex;\n  justify-content: space-around;\n}\n\n#footer>button {\n  flex: 1;\n}\n\n.box {\n  position: relative;\n  display: flex;\n  flex: 1;\n}\n\n#editor {\n  position: absolute;\n  top: 0;\n  right: 0;\n  bottom: 0;\n  left: 0;\n}\n\n#console {\n  height: 150px;\n  /*border: 1px solid green;*/\n  background-color: #eee;\n  word-break: break-all;\n  padding: 0px;\n  position: relative;\n  background-color: #f5f5f5;\n  overflow: auto;\n}\n\n#console>pre {\n  font-family: \"Courier New\";\n  font-size: 13px;\n  padding: 2px 5px;\n  /*height: 100%;*/\n  border: 0px;\n}\n\n#console>img {\n  position: absolute;\n  left: 0px;\n  top: 0px;\n}\n\n#upper {\n  width: 100%;\n  display: flex;\n  flex: 1;\n  border-top: 1px solid black;\n}\n\n#toolbar {\n  border: 1px solid green;\n}\n\n.vertical-gap {\n  width: 5px;\n  background-color: #444444;\n}\n\n.vertical-gap:hover {\n  cursor: col-resize;\n  background-color: black;\n}\n\n.horizon-gap {\n  height: 5px;\n  background-color: #444444;\n}\n\n.horizon-gap:hover {\n  cursor: row-resize;\n  background-color: black;\n}\n\n.canvas-fg {\n  z-index: 1;\n  position: absolute;\n}\n\n.canvas-bg {\n  z-index: 0;\n  position: absolute;\n}\n\n.table-weditor {\n  word-wrap: break-word;\n  table-layout: fixed;\n}\n\ni.inactive {\n  opacity: 0.5;\n}\n\n.cursor-pointer {\n  cursor: pointer;\n}"
  },
  {
    "path": "assets/remote.js",
    "content": "/* Javascript */\n$(function () {\n  $('.btn-copy')\n    .mouseleave(function () {\n      var $element = $(this);\n      $element.tooltip('hide').tooltip('disable');\n    })\n\n  var clipboard = new Clipboard('.btn-copy');\n  clipboard.on('success', function (e) {\n    $(e.trigger)\n      .attr('title', 'Copied')\n      .tooltip('fixTitle')\n      .tooltip('enable')\n      .tooltip('show');\n  })\n\n  $('[data-toggle=tooltip]').tooltip({\n    trigger: 'hover',\n  });\n})\n\n\nwindow.app = new Vue({\n  el: '#app',\n  data: {\n    deviceUdid: deviceUdid,\n    device: {\n      ip: deviceIp,\n      port: 7912,\n    },\n    deviceInfo: {},\n    fixConsole: '', // log for fix minicap and rotation\n    navtabs: {\n      active: location.hash.slice(1) || 'home',\n      tabs: [],\n    },\n    error: '',\n    control: null,\n    loading: false,\n    canvas: {\n      bg: null,\n      fg: null,\n    },\n    canvasStyle: {\n      opacity: 1,\n      width: 'inherit',\n      height: 'inherit'\n    },\n    lastScreenSize: {\n      screen: {},\n      canvas: {\n        width: 1,\n        height: 1\n      }\n    },\n    screenWS: null,\n    browserURL: \"\",\n    logcat: {\n      follow: true,\n      tagColors: {},\n      lineNumber: 0,\n      maxKeep: 1500,\n      cachedScrollTop: 0,\n      logs: [{\n        lineno: 1,\n        tag: \"EsService2\",\n        level: \"W\",\n        content: \"loaded /system/lib/egl/libEGL_adreno200.so\",\n      }]\n    },\n    imageBlobBuffer: [],\n    videoUrl: '',\n    videoReceiver: null, // sub function to receive image\n    inputText: '',\n    inputWS: null,\n  },\n  watch: {},\n  computed: {\n    deviceUrl: function () {\n      return \"http://\" + this.device.ip + \":\" + this.device.port;\n    }\n  },\n  mounted: function () {\n    var URL = window.URL || window.webkitURL;\n    var currentSize = null;\n    var self = this;\n    $.notify.defaults({ className: \"success\" });\n\n    this.canvas.bg = document.getElementById('bgCanvas')\n    this.canvas.fg = document.getElementById('fgCanvas')\n    // this.canvas = c;\n    window.c = this.canvas.bg;\n    var ctx = c.getContext('2d')\n\n    $(window).resize(function () {\n      self.resizeScreen();\n    })\n\n    this.initDragDealer();\n\n    // get device info\n    $.ajax({\n      url: this.deviceUrl + \"/info\", // \"/devices/\" + this.deviceUdid + \"/info\",\n      dateType: \"json\"\n    }).then(function (ret) {\n      this.deviceInfo = ret;\n      document.title = ret.model;\n    }.bind(this))\n\n    this.reserveDevice()\n      .then(function () {\n        this.enableTouch();\n        this.openScreenStream();\n      }.bind(this))\n\n    // wakeup device on connect\n    setTimeout(function () {\n      this.keyevent(\"WAKEUP\");\n    }.bind(this), 1)\n\n    window.k = setTimeout(function () {\n      var lineno = (this.logcat.lineNumber += 1);\n      this.logcat.logs.push({\n        lineno: lineno,\n        tag: \"EsService2\",\n        level: \"W\",\n        content: \"loaded /system/lib/egl/libEGL_adreno200.so\",\n      });\n      if (this.logcat.follow) {\n        // only keep maxKeep lines\n        var maxKeep = Math.max(20, this.logcat.maxKeep);\n        var size = this.logcat.logs.length;\n        this.logcat.logs = this.logcat.logs.slice(size - maxKeep, size);\n\n        // scroll to end\n        var el = this.$refs.tab_content;\n        var logcat = this.logcat;\n        if (el.scrollTop < logcat.cachedScrollTop) {\n          this.logcat.follow = false;\n        } else {\n          setTimeout(function () {\n            logcat.cachedScrollTop = el.scrollTop = el.scrollHeight - el.clientHeight;\n          }, 2);\n        }\n      }\n    }.bind(this), 200)\n\n    this.inputWS = new WebSocket(\"ws://\" + this.device.ip + \":\" + this.device.port + \"/whatsinput\");\n    this.inputWS.onmessage = function (message) {\n      // console.log(message)\n      var data = JSON.parse(message.data)\n      if (data.type == \"InputStart\") {\n        this.inputText = data.text;\n      } else {\n        console.log(data)\n      }\n    }.bind(this);\n\n  },\n  watch: {\n    inputText: function (newText) {\n      console.log(newText);\n      this.inputWS.send(JSON.stringify({ type: \"InputEdit\", text: newText }))\n    }\n  },\n  methods: {\n    reserveDevice: function () {\n      var dtd = $.Deferred();\n      var ws = new WebSocket(\"ws://\" + location.host + \"/devices/\" + this.deviceUdid + \"/reserved\")\n      ws.onmessage = function (message) {\n        console.log(\"WebSocket receive\", message)\n      }\n      var key = setInterval(function () {\n        ws.send(\"ping\")\n      }, 5000);\n      ws.onopen = function () {\n        dtd.resolve();\n      }\n      ws.onerror = function (err) {\n        console.log(\"WebSocket Error \" + err)\n      }\n      ws.onclose = function () {\n        dtd.reject();\n        clearInterval(key);\n        console.log(\"websocket reserved closed\");\n      }\n      return dtd.promise();\n    },\n    connectImage2VideoWebSocket: function (fps) {\n      var protocol = location.protocol == \"http:\" ? \"ws:\" : \"wss:\";\n      var wsURL = protocol + location.host + \"/video/convert\"\n      var wsQueries = encodeURI(\"fps=\" + fps) + \"&\" + encodeURI(\"udid=\" + this.deviceUdid) + \"&\" + encodeURI(\"name=\" + this.deviceInfo.model)\n      var ws = new WebSocket(wsURL + \"?\" + wsQueries)\n      var def = $.Deferred()\n      ws.onopen = function () {\n        def.resolve(this)\n      }\n      ws.onclose = function (ev) {\n        def.reject(\"Somehow ws disconnected\")\n      }\n      return def.promise();\n    },\n    startLowQualityScreenRecord: function (event) {\n      $(event.target).notify(\"初始化中 ...\");\n      this.connectImage2VideoWebSocket(2)\n        .done(function (ws) {\n          $(event.target).notify(\"视频录制中, 再次点击停止\");\n          var key = setInterval(function () {\n            $.ajax({\n              url: this.deviceUrl + \"/screenshot/0?thumbnail=800x800\",\n              method: \"get\",\n              processData: false,\n              cache: false,\n              xhr: function () {\n                var xhr = new XMLHttpRequest();\n                xhr.responseType = \"blob\"\n                return xhr;\n              },\n              success: function (data) {\n                ws.send(data)\n                console.log(\"screenshot\")\n              }\n            })\n          }.bind(this), 1000)\n          this.videoReceiver = {\n            ws: ws,\n            key: key,\n          }\n        }.bind(this))\n        .fail(function (err) {\n          $(event.target).notify(\"录制启动失败, 请点击【关于我们】，联系网站管理员\", \"error\");\n        })\n    },\n    startVideoRecord: function (event) {\n      $(event.target).notify(\"初始化中 ...\");\n      this.connectImage2VideoWebSocket(10)\n        .done(function (ws) {\n          $(event.target).notify(\"视频录制中, 再次点击停止\");\n          var cache = {}\n          function receiver(_, data) {\n            cache.last = data;\n          }\n          var key = setInterval(function () {\n            var lastData = cache.last;\n            cache.last = null;\n            if (lastData) {\n              ws.send(lastData)\n            }\n          }, 1000 / 6) // fps: 6\n          receiver.ws = ws;\n          receiver.key = key;\n\n          $.subscribe('imagedata', receiver)\n          this.videoReceiver = receiver;\n        }.bind(this))\n        .fail(function (err) {\n          $(event.target).notify(\"录制启动失败, 请点击【关于我们】，联系网站管理员\", \"error\");\n        })\n    },\n    stopVideoRecord: function () {\n      if (this.videoReceiver) {\n        $.unsubscribe(\"imagedata\", this.videoReceiver);\n        this.videoReceiver.ws.close()\n        clearInterval(this.videoReceiver.key);\n        this.videoReceiver = null;\n        $(event.target).notify(\"视频录制成功\");\n      }\n    },\n    toggleScreen: function () {\n      if (this.screenWS) {\n        this.screenWS.close();\n        this.canvasStyle.opacity = 0;\n        this.screenWS = null;\n      } else {\n        this.openScreenStream();\n        this.canvasStyle.opacity = 1;\n      }\n    },\n    saveShortVideo: function (event) {\n      var fd = new FormData();\n      this.imageBlobBuffer.forEach(function (blob) {\n        fd.append('file', blob);\n      });\n      $(event.target).notify(\"视频后台合成中，请稍候 ...\");\n      console.log(\"upload\")\n      $.ajax({\n        type: \"post\",\n        url: \"http://10.246.46.160:7000/img2video\", // TODO: 临时地址，需要后期更换\n        processData: false,\n        contentType: false,\n        data: fd,\n        dateType: 'json',\n      }).done(function (data) {\n        console.log(data.url);\n        this.videoUrl = data.url;\n        $(event.target).notify(\"合成完毕\");\n      }.bind(this))\n    },\n    saveScreenshot: function () {\n      $.ajax({\n        url: this.deviceUrl + \"/screenshot\",\n        cache: false,\n        xhrFields: {\n          responseType: 'blob'\n        },\n      }).then(function (blob) {\n        saveAs(blob, \"screenshot.jpg\") // saveAs require FileSaver.js\n      })\n    },\n    openBrowser: function (url) {\n      if (!/^https?:\\/\\//.test(url)) {\n        url = \"http://\" + url;\n      }\n      return this.shell(\"am start -a android.intent.action.VIEW -d \" + url);\n    },\n    uploadFile: function (event) {\n      var formData = new FormData(event.target);\n      $(event.target).notify(\"Uploading ...\");\n      $.ajax({\n        method: \"post\",\n        url: this.deviceUrl + \"/upload/sdcard/tmp/\",\n        data: formData,\n        processData: false,\n        contentType: false,\n        enctype: 'multipart/form-data',\n      })\n        .then(function (ret) {\n          $(event.target).notify(\"Upload success\");\n        }, function (err) {\n          $(event.target).notify(\"Upload failed:\" + err.responseText, \"error\")\n          console.error(err)\n        })\n    },\n    addTabItem: function (item) {\n      this.navtabs.tabs.push(item);\n    },\n    changeTab: function (tabId) {\n      location.hash = tabId;\n    },\n    fixRotation: function () {\n      $.ajax({\n        url: this.deviceUrl + \"/info/rotation\",\n        method: \"post\",\n      }).then(function (ret) {\n        console.log(\"rotation fixed\")\n      })\n    },\n    fixMinicap: function () {\n      this.fixConsole = \"remove old minicap\";\n      $.ajax({\n        method: \"post\",\n        url: this.deviceUrl + \"/shell\",\n        data: {\n          command: \"rm -f /data/local/tmp/minicap /data/local/tmp/minicap.so\"\n        }\n      })\n        .then(function () {\n          this.fixConsole = \"download mincap to device ...\"\n          return $.ajax({\n            url: this.deviceUrl + \"/minicap\",\n            method: \"put\",\n          })\n        }.bind(this))\n        .then(function () {\n          this.fixConsole = \"minicap fixed\"\n        }.bind(this), function () {\n          this.fixConsole = \"minicap can not be fixed, open Browser Console for more detail\"\n        }.bind(this))\n    },\n    tabScroll: function (ev) {\n      // var el = ev.target;\n      // var el = this.$refs.tab_content;\n      // var bottom = (el.scrollTop == (el.scrollHeight - el.clientHeight));\n      // console.log(\"Bottom\", bottom, el.scrollTop, el.scrollHeight, el.clientHeight, el.scrollHeight - el.clientHeight)\n      // console.log(ev.target.scrollTop, ev.target.scrollHeight, ev.target.clientHeight);\n      this.logcat.follow = false;\n    },\n    followLog: function () {\n      this.logcat.follow = !this.logcat.follow;\n      if (this.logcat.follow) {\n        var el = this.$refs.tab_content;\n        el.scrollTop = el.scrollHeight - el.clientHeight;\n      }\n    },\n    logcatTag2Color: function (tag) {\n      var color = this.logcat.tagColors[tag];\n      if (!color) {\n        color = this.logcat.tagColors[tag] = getRandomRgb(5);\n      }\n      return color;\n    },\n    logcatLevel2Color: function (level) {\n      switch (level) {\n        case \"W\":\n          return \"goldenrod\";\n        case \"I\":\n          return \"darkgreen\";\n        case \"D\":\n          return \"gray\";\n        default:\n          return \"gray\";\n      }\n    },\n    hold: function (msecs) {\n      this.control.touchDown(0, 0.5, 0.5, 5, 0.5)\n      this.control.touchCommit();\n      this.control.touchWait(msecs);\n      this.control.touchUp(0)\n      this.control.touchCommit();\n    },\n    keyevent: function (meta) {\n      console.log(\"keyevent\", meta)\n      return this.shell(\"input keyevent \" + meta.toUpperCase());\n    },\n    shell: function (command) {\n      return $.ajax({\n        url: this.deviceUrl + \"/shell\",\n        method: \"post\",\n        data: {\n          command: command,\n        },\n        success: function (ret) {\n          console.log(ret);\n        },\n        error: function (ret) {\n          console.log(ret)\n        }\n      })\n    },\n    showError: function (error) {\n      this.loading = false;\n      this.error = error;\n      $('.modal').modal('show');\n    },\n    showAjaxError: function (ret) {\n      if (ret.responseJSON && ret.responseJSON.description) {\n        this.showError(ret.responseJSON.description);\n      } else {\n        this.showError(\"<p>Local server not started, start with</p><pre>$ python -m weditor</pre>\");\n      }\n    },\n    initDragDealer: function () {\n      var self = this;\n      var updateFunc = null;\n\n      function dragMoveListener(evt) {\n        evt.preventDefault();\n        updateFunc(evt);\n        self.resizeScreen();\n      }\n\n      function dragStopListener(evt) {\n        document.removeEventListener('mousemove', dragMoveListener);\n        document.removeEventListener('mouseup', dragStopListener);\n        document.removeEventListener('mouseleave', dragStopListener);\n      }\n\n      $('#vertical-gap1').mousedown(function (e) {\n        e.preventDefault();\n        updateFunc = function (evt) {\n          $(\"#left\").width(evt.clientX);\n        }\n        document.addEventListener('mousemove', dragMoveListener);\n        document.addEventListener('mouseup', dragStopListener);\n        document.addEventListener('mouseleave', dragStopListener)\n      });\n    },\n    resizeScreen: function (img) {\n      // check if need update\n      if (img) {\n        if (this.lastScreenSize.canvas.width == img.width &&\n          this.lastScreenSize.canvas.height == img.height) {\n          return;\n        }\n      } else {\n        img = this.lastScreenSize.canvas;\n        if (!img) {\n          return;\n        }\n      }\n      var screenDiv = document.getElementById('screen');\n      this.lastScreenSize = {\n        canvas: {\n          width: img.width,\n          height: img.height\n        },\n        screen: {\n          width: screenDiv.clientWidth,\n          height: screenDiv.clientHeight,\n        }\n      }\n\n      var canvasAspect = img.width / img.height;\n      var screenAspect = screenDiv.clientWidth / screenDiv.clientHeight;\n      if (canvasAspect > screenAspect) {\n        Object.assign(this.canvasStyle, {\n          width: Math.floor(screenDiv.clientWidth) + 'px', //'100%',\n          height: Math.floor(screenDiv.clientWidth / canvasAspect) + 'px', // 'inherit',\n        })\n      } else if (canvasAspect < screenAspect) {\n        Object.assign(this.canvasStyle, {\n          width: Math.floor(screenDiv.clientHeight * canvasAspect) + 'px', //'inherit',\n          height: Math.floor(screenDiv.clientHeight) + 'px', //'100%',\n        })\n      }\n    },\n    delayReload: function (msec) {\n      setTimeout(this.screenDumpUI, msec || 1000);\n    },\n    drawBlobImageToScreen: function (blob) {\n      // Support jQuery Promise\n      var dtd = $.Deferred();\n      var bgcanvas = this.canvas.bg,\n        fgcanvas = this.canvas.fg,\n        ctx = bgcanvas.getContext('2d'),\n        self = this,\n        URL = window.URL || window.webkitURL,\n        BLANK_IMG = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',\n        img = this.imagePool.next();\n\n      img.onload = function () {\n        console.log(\"image\")\n        fgcanvas.width = bgcanvas.width = img.width\n        fgcanvas.height = bgcanvas.height = img.height\n\n\n        ctx.drawImage(img, 0, 0, img.width, img.height);\n        self.resizeScreen(img);\n\n        // Try to forcefully clean everything to get rid of memory\n        // leaks. Note self despite this effort, Chrome will still\n        // leak huge amounts of memory when the developer tools are\n        // open, probably to save the resources for inspection. When\n        // the developer tools are closed no memory is leaked.\n        img.onload = img.onerror = null\n        img.src = BLANK_IMG\n        img = null\n        blob = null\n\n        URL.revokeObjectURL(url)\n        url = null\n        dtd.resolve();\n      }\n\n      img.onerror = function () {\n        // Happily ignore. I suppose this shouldn't happen, but\n        // sometimes it does, presumably when we're loading images\n        // too quickly.\n\n        // Do the same cleanup here as in onload.\n        img.onload = img.onerror = null\n        img.src = BLANK_IMG\n        img = null\n        blob = null\n\n        URL.revokeObjectURL(url)\n        url = null\n        dtd.reject();\n      }\n      var url = URL.createObjectURL(blob)\n      img.src = url;\n      return dtd;\n    },\n    openScreenStream: function () {\n      var self = this;\n      var BLANK_IMG =\n        'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='\n      var protocol = location.protocol == \"http:\" ? \"ws://\" : \"wss://\"\n      var ws = new WebSocket(this.deviceUrl.replace(\"http:\", \"ws:\") + '/minicap');\n      var canvas = document.getElementById('bgCanvas')\n      var ctx = canvas.getContext('2d');\n      var lastScreenSize = {\n        screen: {},\n        canvas: {}\n      };\n\n      this.screenWS = ws;\n      var imagePool = new ImagePool(100);\n\n      ws.onopen = function (ev) {\n        console.log('screen websocket connected')\n      };\n\n      // FIXME(ssx): use pubsub is better\n      var imageBlobBuffer = self.imageBlobBuffer;\n      var imageBlobMaxLength = 300;\n\n      ws.onmessage = function (message) {\n        if (message.data instanceof Blob) {\n          console.log(\"image received\");\n          $.publish(\"imagedata\", message.data);\n\n          var blob = new Blob([message.data], {\n            type: 'image/jpeg'\n          })\n\n          imageBlobBuffer.push(blob);\n\n          if (imageBlobBuffer.length > imageBlobMaxLength) {\n            imageBlobBuffer.shift();\n          }\n\n          var img = imagePool.next();\n          img.onload = function () {\n            canvas.width = img.width\n            canvas.height = img.height\n            ctx.drawImage(img, 0, 0, img.width, img.height);\n            self.resizeScreen(img);\n\n\n            // Try to forcefully clean everything to get rid of memory\n            // leaks. Note self despite this effort, Chrome will still\n            // leak huge amounts of memory when the developer tools are\n            // open, probably to save the resources for inspection. When\n            // the developer tools are closed no memory is leaked.\n            img.onload = img.onerror = null\n            img.src = BLANK_IMG\n            img = null\n            blob = null\n\n            URL.revokeObjectURL(url)\n            url = null\n          }\n\n          img.onerror = function () {\n            // Happily ignore. I suppose this shouldn't happen, but\n            // sometimes it does, presumably when we're loading images\n            // too quickly.\n\n            // Do the same cleanup here as in onload.\n            img.onload = img.onerror = null\n            img.src = BLANK_IMG\n            img = null\n            blob = null\n\n            URL.revokeObjectURL(url)\n            url = null\n          }\n\n          var url = URL.createObjectURL(blob)\n          img.src = url;\n        } else if (/^data size:/.test(message.data)) {\n          // console.log(\"receive message:\", message.data)\n        } else if (/^rotation/.test(message.data)) {\n          self.rotation = parseInt(message.data.substr('rotation '.length), 10);\n          console.log(self.rotation)\n        } else {\n          console.log(\"receive message:\", message.data)\n        }\n      }\n\n      ws.onclose = function (ev) {\n        console.log(\"screen websocket closed\", ev.code)\n      }.bind(this)\n\n      ws.onerror = function (ev) {\n        console.log(\"screen websocket error\")\n      }\n    },\n    enableTouch: function () {\n      /**\n       * TOUCH HANDLING\n       */\n      var self = this;\n      var element = this.canvas.fg;\n\n      var screen = {\n        bounds: {}\n      }\n\n      var ws = new WebSocket(this.deviceUrl.replace(\"http:\", \"ws:\") + \"/minitouch\")\n      ws.onerror = function (ev) {\n        console.log(\"minitouch websocket error:\", ev)\n      }\n      ws.onmessage = function (ev) {\n        console.log(\"minitouch websocket receive message:\", ev.data);\n      }\n      ws.onclose = function () {\n        console.log(\"minitouch websocket closed\");\n      }\n      var control = this.control = MiniTouch.createNew(ws);\n\n      function calculateBounds() {\n        var el = element;\n        screen.bounds.w = el.offsetWidth\n        screen.bounds.h = el.offsetHeight\n        screen.bounds.x = 0\n        screen.bounds.y = 0\n\n        while (el.offsetParent) {\n          screen.bounds.x += el.offsetLeft\n          screen.bounds.y += el.offsetTop\n          el = el.offsetParent\n        }\n      }\n\n      function activeFinger(index, x, y, pressure) {\n        var scale = 0.5 + pressure\n        $(\".finger-\" + index)\n          .addClass(\"active\")\n          .css(\"transform\", 'translate3d(' + x + 'px,' + y + 'px,0)')\n      }\n\n      function deactiveFinger(index) {\n        $(\".finger-\" + index).removeClass(\"active\")\n      }\n\n      function mouseDownListener(event) {\n        var e = event;\n        if (e.originalEvent) {\n          e = e.originalEvent\n        }\n        // Skip secondary click\n        if (e.which === 3) {\n          return\n        }\n        e.preventDefault()\n\n        fakePinch = e.altKey\n        calculateBounds()\n\n        var x = e.pageX - screen.bounds.x\n        var y = e.pageY - screen.bounds.y\n        var pressure = 0.5\n        activeFinger(0, e.pageX, e.pageY, pressure);\n\n        var scaled = coords(screen.bounds.w, screen.bounds.h, x, y, self.rotation);\n        control.touchDown(0, scaled.xP, scaled.yP, pressure);\n        control.touchCommit();\n\n        element.removeEventListener('mousemove', mouseHoverListener);\n        element.addEventListener('mousemove', mouseMoveListener);\n        document.addEventListener('mouseup', mouseUpListener);\n      }\n\n      function mouseMoveListener(event) {\n        var e = event\n        if (e.originalEvent) {\n          e = e.originalEvent\n        }\n        // Skip secondary click\n        if (e.which === 3) {\n          return\n        }\n        e.preventDefault()\n\n        var pressure = 0.5\n        activeFinger(0, e.pageX, e.pageY, pressure);\n        var x = e.pageX - screen.bounds.x\n        var y = e.pageY - screen.bounds.y\n        var scaled = coords(screen.bounds.w, screen.bounds.h, x, y, self.rotation);\n        control.touchMove(0, scaled.xP, scaled.yP, pressure);\n        control.touchCommit();\n      }\n\n      function mouseUpListener(event) {\n        var e = event\n        if (e.originalEvent) {\n          e = e.originalEvent\n        }\n        // Skip secondary click\n        if (e.which === 3) {\n          return\n        }\n        e.preventDefault()\n\n        control.touchUp(0)\n        control.touchCommit();\n        stopMousing()\n      }\n\n      function stopMousing() {\n        element.removeEventListener('mousemove', mouseMoveListener);\n        // element.addEventListener('mousemove', mouseHoverListener);\n        document.removeEventListener('mouseup', mouseUpListener);\n        deactiveFinger(0);\n      }\n\n      function mouseHoverListener(event) {\n        var e = event;\n        if (e.originalEvent) {\n          e = e.originalEvent\n        }\n        // Skip secondary click\n        if (e.which === 3) {\n          return\n        }\n        e.preventDefault()\n\n        var x = e.pageX - screen.bounds.x\n        var y = e.pageY - screen.bounds.y\n      }\n\n      function markPosition(pos) {\n        var ctx = self.canvas.fg.getContext(\"2d\");\n        ctx.fillStyle = '#ff0000'; // red\n        ctx.beginPath()\n        ctx.arc(pos.x, pos.y, 12, 0, 2 * Math.PI)\n        ctx.closePath()\n        ctx.fill()\n\n        ctx.fillStyle = \"#fff\"; // white\n        ctx.beginPath()\n        ctx.arc(pos.x, pos.y, 8, 0, 2 * Math.PI)\n        ctx.closePath()\n        ctx.fill();\n      }\n\n      var wheelTimer, fromYP;\n\n      function mouseWheelDelayTouchUp() {\n        clearTimeout(wheelTimer);\n        wheelTimer = setTimeout(function () {\n          fromYP = null;\n          control.touchUp(1)\n          control.touchCommit();\n          // deactiveFinger(0);\n          // deactiveFinger(1);\n        }, 100)\n      }\n\n      function mouseWheelListener(event) {\n        var e = event;\n        if (e.originalEvent) {\n          e = e.originalEvent\n        }\n        e.preventDefault()\n        calculateBounds()\n\n        var x = e.pageX - screen.bounds.x\n        var y = e.pageY - screen.bounds.y\n        var pressure = 0.5;\n        var scaled;\n\n        if (!fromYP) {\n          fromYP = y / screen.bounds.h; // display Y percent\n          // touch down when first detect mousewheel\n          scaled = coords(screen.bounds.w, screen.bounds.h, x, y, self.rotation);\n          control.touchDown(1, scaled.xP, scaled.yP, pressure);\n          control.touchCommit();\n          // activeFinger(0, e.pageX, e.pageY, pressure);\n        }\n        // caculate position after scroll\n        var toYP = fromYP + (event.wheelDeltaY < 0 ? -0.05 : 0.05);\n        toYP = Math.max(0, Math.min(1, toYP));\n\n        var step = Math.max((toYP - fromYP) / 5, 0.01) * (event.wheelDeltaY < 0 ? -1 : 1);\n        for (var yP = fromYP; yP < 1 && yP > 0 && Math.abs(yP - toYP) > 0.0001; yP += step) {\n          y = screen.bounds.h * yP;\n          var pageY = y + screen.bounds.y;\n          scaled = coords(screen.bounds.w, screen.bounds.h, x, y, self.rotation);\n          // activeFinger(1, e.pageX, pageY, pressure);\n          control.touchMove(1, scaled.xP, scaled.yP, pressure);\n          control.touchWait(10);\n          control.touchCommit();\n        }\n        fromYP = toYP;\n        mouseWheelDelayTouchUp()\n      }\n\n      /* bind listeners */\n      element.addEventListener('mousedown', mouseDownListener);\n      // element.addEventListener('mousemove', mouseHoverListener);\n      element.addEventListener('mousewheel', mouseWheelListener);\n    }\n  }\n})"
  },
  {
    "path": "assets/style.css",
    "content": "body {\n  font-family: \"Segoe UI\", Arial, \"Microsoft Yahei\", sans-serif;\n}\n\n.color-inverse {\n  color: white;\n  background-color: black;\n}\n\n.color-green {\n  color: green;\n}\n\n.color-red {\n  color: red;\n}\n\n.color-rest {\n  color: rgb(74, 201, 89);\n}\n\n.color-busy {\n  color: tomato;\n}\n\n.color-yellow {\n  color: yellowgreen;\n}\n\n.fa-fix-height {\n  line-height: 24px;\n}\n\n.fa-battery-4 {\n  color: green;\n}\n\n.fa-battery-3 {\n  color: yellowgreen;\n}\n\n.fa-battery-2 {\n  color: yellow;\n}\n\n.fa-battery-1 {\n  color: red;\n}\n\n.fa-battery-0 {\n  color: red;\n}\n\ntr.offline {\n  /* background-color: red; */\n  color: gray;\n}"
  },
  {
    "path": "assets/vue-components.js",
    "content": "/* require fontawesome\n*/\n\n/*\nExample:\n<editable-span :content=\"content\" @change=\"content=$event\"/>\n*/\nVue.component('editable-span', {\n    template: `<div>\n    <template v-if=\"!editMode\">\n        <span @dblclick=\"editContent\" v-text=\"content\"></span>\n        <i class=\"fa fa-edit\" @click=\"editContent\"></i>\n    </template>\n    <div v-show=\"editMode\">\n        <input ref=\"ipt\" v-model=\"newContent\" @keyup.enter=\"saveContent\"/>\n        <i @click=\"saveContent\" class=\"fa fa-save\"></i>\n        <i class=\"fa fa-undo\" @click=\"editMode=false\"></i>\n    </div>\n    </div>\n    `,\n    props: ['content'],\n    data: function () {\n        return {\n            editMode: false,\n            newContent: \"\",\n        }\n    },\n    methods: {\n        editContent: function () {\n            this.newContent = this.content;\n            this.editMode = true;\n            this.$nextTick(function () {\n                this.$refs.ipt.focus()\n            }.bind(this))\n        },\n        saveContent: function () {\n            this.editMode = false;\n            this.$emit(\"change\", this.newContent);\n        }\n    }\n})"
  },
  {
    "path": "database.go",
    "content": "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.com/qiniu/log\"\n\tr \"gopkg.in/gorethink/gorethink.v4\"\n)\n\nvar (\n\tdb *RdbUtils\n)\n\nfunc initDB(address, dbName string) {\n\tr.SetTags(\"gorethink\", \"json\")\n\tr.SetVerbose(true)\n\tsession, err := r.Connect(r.ConnectOpts{\n\t\tAddress:    address,\n\t\tDatabase:   dbName,\n\t\tInitialCap: 1,\n\t\tMaxOpen:    10,\n\t})\n\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tdb = &RdbUtils{session}\n\n\t// initial state\n\tif err := db.DBCreateAnyway(dbName); err != nil {\n\t\tpanic(err)\n\t}\n\tlog.Println(\"create tables\")\n\tdb.TableMustCreate(\"devices\", r.TableCreateOpts{\n\t\tPrimaryKey: \"udid\",\n\t})\n\tdb.TableMustCreate(\"products\")\n\tdb.TableMustCreate(\"providers\")\n\n\tr.Table(\"devices\").Update(map[string]interface{}{\n\t\t\"present\":     false,\n\t\t\"using\":       false,\n\t\t\"provider_id\": 0,\n\t}).Exec(session)\n\n\tif err := r.Table(\"devices\").IndexCreate(\"provider_id\", r.IndexCreateOpts{}).Exec(session); err != nil {\n\t\tlog.Println(\"create index\", err)\n\t}\n\n\tr.Table(\"providers\").Update(proto.Provider{\n\t\tPresent: newBool(false),\n\t}).Exec(session)\n}\n\ntype RdbUtils struct {\n\tsession *r.Session\n}\n\nfunc (db *RdbUtils) DBCreateAnyway(name string) error {\n\tres, err := r.DBList().Run(db.session)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer res.Close()\n\tvar dbNames []string\n\tif err := res.All(&dbNames); err != nil {\n\t\treturn err\n\t}\n\tfor _, dbName := range dbNames {\n\t\tlog.Println(\"found db:\", dbName)\n\t\tif dbName == name {\n\t\t\tlog.Println(\"db exists atxserver\")\n\t\t\treturn nil\n\t\t}\n\t}\n\terr = r.DBCreate(name).Exec(db.session)\n\treturn err\n}\n\nfunc (db *RdbUtils) TableMustCreate(name string, optArgs ...r.TableCreateOpts) {\n\tif err := db.TableCreateAnyway(name, optArgs...); err != nil {\n\t\tpanic(err)\n\t}\n}\n\nfunc (db *RdbUtils) TableCreateAnyway(name string, optArgs ...r.TableCreateOpts) error {\n\terr := r.TableCreate(name, optArgs...).Exec(db.session)\n\tif err != nil && strings.Contains(err.Error(), \"already exists\") {\n\t\treturn nil\n\t}\n\treturn err\n}\n\n// DeviceUpdateOrInsert called when device plugin\nfunc (db *RdbUtils) DeviceUpdateOrInsert(dev proto.DeviceInfo) error {\n\tdev.Present = newBool(true)\n\tdev.PresenceChangedAt = time.Now()\n\t// only update when create\n\tdev.Ready = newBool(false)\n\tdev.Using = newBool(false)\n\tdev.CreatedAt = time.Now()\n\t_, err := r.Table(\"devices\").Insert(dev, r.InsertOpts{\n\t\tConflict: func(id, oldDoc, newDoc r.Term) interface{} {\n\t\t\treturn oldDoc.Merge(newDoc.Without(\"createdAt\", \"ready\", \"using\")).Merge(map[string]interface{}{\n\t\t\t\t\"createdAt\": oldDoc.Field(\"createdAt\").Default(time.Now()),\n\t\t\t\t\"ready\":     oldDoc.Field(\"ready\").Default(false),\n\t\t\t\t\"using\":     oldDoc.Field(\"using\").Default(false),\n\t\t\t})\n\t\t},\n\t}).RunWrite(db.session)\n\treturn err\n}\n\nfunc (db *RdbUtils) DeviceUpdate(udid string, arg interface{}) error {\n\t_, err := r.Table(\"devices\").Get(udid).Update(arg).RunWrite(db.session)\n\treturn err\n}\n\nfunc (db *RdbUtils) DeviceList() (devices []proto.DeviceInfo, err error) {\n\tres, err := r.Table(\"devices\").\n\t\tOrderBy(r.Desc(\"present\"), r.Desc(\"ready\"), r.Desc(\"using\"), r.Desc(\"presenceChangedAt\")).\n\t\tMerge(func(p r.Term) interface{} {\n\t\t\treturn map[string]interface{}{\n\t\t\t\t\"product_id\":  r.Table(\"products\").Get(p.Field(\"product_id\").Default(0)),\n\t\t\t\t\"provider_id\": r.Table(\"providers\").Get(p.Field(\"provider_id\").Default(0)),\n\t\t\t}\n\t\t}).Run(db.session)\n\tif err != nil {\n\t\tlog.Error(err)\n\t\treturn\n\t}\n\tdefer res.Close()\n\terr = res.All(&devices)\n\treturn\n}\n\nfunc (db *RdbUtils) DeviceGet(udid string) (info proto.DeviceInfo, err error) {\n\tres, err := r.Table(\"devices\").Get(udid).\n\t\tMerge(func(p r.Term) interface{} {\n\t\t\treturn map[string]interface{}{\n\t\t\t\t\"product_id\":  r.Table(\"products\").Get(p.Field(\"product_id\").Default(0)),\n\t\t\t\t\"provider_id\": r.Table(\"providers\").Get(p.Field(\"provider_id\").Default(0)),\n\t\t\t}\n\t\t}).Run(db.session)\n\tif err != nil {\n\t\treturn\n\t}\n\tdefer res.Close()\n\terr = res.One(&info)\n\treturn\n}\n\nfunc (db *RdbUtils) DeviceFindAll(info proto.DeviceInfo) (infos []proto.DeviceInfo) {\n\tinfojson, _ := json.Marshal(info)\n\tlog.Debugf(\"query %s\", string(infojson))\n\tres, err := r.Table(\"devices\").Filter(info).\n\t\tMerge(func(p r.Term) interface{} {\n\t\t\treturn map[string]interface{}{\n\t\t\t\t\"product_id\":  r.Table(\"products\").Get(p.Field(\"product_id\").Default(0)),\n\t\t\t\t\"provider_id\": r.Table(\"providers\").Get(p.Field(\"provider_id\").Default(0)),\n\t\t\t}\n\t\t}).Run(db.session)\n\tif err != nil {\n\t\tlog.Error(err)\n\t\treturn nil\n\t}\n\tdefer res.Close()\n\tif err := res.All(&infos); err != nil {\n\t\tlog.Error(err)\n\t}\n\treturn\n}\n\n// ProviderFindAll get all providers\nfunc (db *RdbUtils) ProvidersAll() (providers []proto.Provider, err error) {\n\tres, err := r.Table(\"providers\").OrderBy(r.Desc(\"present\"), \"id\").\n\t\tMerge(func(p r.Term) interface{} {\n\t\t\treturn map[string]interface{}{\n\t\t\t\t\"devices\": r.Table(\"devices\").\n\t\t\t\t\tGetAllByIndex(\"provider_id\", p.Field(\"id\")).\n\t\t\t\t\tWithout(\"product_id\", \"provider_id\", \"battery\").CoerceTo(\"array\"),\n\t\t\t}\n\t\t}).Run(db.session)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer res.Close()\n\terr = res.All(&providers)\n\treturn\n}\n\n// SetDevicePresent change present status\nfunc (db *RdbUtils) SetDeviceAbsent(udid string) error {\n\tlog.Debugf(\"device absent: %s\", udid)\n\treturn db.DeviceUpdate(udid, proto.DeviceInfo{\n\t\tPresent:           newBool(false),\n\t\tPresenceChangedAt: time.Now(),\n\t})\n}\n\nfunc (db *RdbUtils) WatchDeviceChanges() (feeds chan r.ChangeResponse, cancel func(), err error) {\n\tctx, cancel := context.WithCancel(context.Background())\n\tres, err := r.Table(\"devices\").Changes().Run(db.session, r.RunOpts{\n\t\tContext: ctx,\n\t})\n\tif err != nil {\n\t\treturn\n\t}\n\tfeeds = make(chan r.ChangeResponse)\n\tvar change r.ChangeResponse\n\tgo func() {\n\t\tfor res.Next(&change) {\n\t\t\tfeeds <- change\n\t\t}\n\t\tclose(feeds)\n\t}()\n\treturn\n}\n\nfunc (db *RdbUtils) ProductsFindAll(brand, model string) (products []proto.Product, err error) {\n\tres, err := r.Table(\"products\").Filter(proto.Product{Brand: brand, Model: model}).Run(db.session)\n\tif err != nil {\n\t\treturn\n\t}\n\tif err = res.All(&products); err != nil {\n\t\treturn\n\t}\n\tif len(products) > 0 {\n\t\treturn\n\t}\n\tresp, err := r.Table(\"products\").Insert(proto.Product{Brand: brand, Model: model}).RunWrite(db.session)\n\tif err != nil {\n\t\treturn\n\t}\n\tif len(resp.GeneratedKeys) != 1 {\n\t\tpanic(\"generatedKeys must be one\")\n\t}\n\treturn db.ProductsFindAll(brand, model)\n}\n\nfunc (db *RdbUtils) ProductUpdate(id string, product proto.Product) error {\n\tproduct.Id = \"\"\n\t_, err := r.Table(\"products\").Get(id).Update(product).RunWrite(db.session)\n\treturn err\n}\n\n// ProviderUpdateOrInsert will create a record if not exists\nfunc (db *RdbUtils) ProviderUpdateOrInsert(machineId string, ip string, port int) error {\n\tp := proto.Provider{\n\t\tId:                machineId,\n\t\tIP:                ip,\n\t\tPort:              port,\n\t\tPresent:           newBool(true),\n\t\tCreatedAt:         time.Now(),\n\t\tPresenceChangedAt: time.Now(),\n\t}\n\t_, err := r.Table(\"providers\").Insert(p, r.InsertOpts{\n\t\tConflict: func(id, oldDoc, newDoc r.Term) interface{} {\n\t\t\treturn oldDoc.Merge(newDoc.Without(\"createdAt\")).Merge(map[string]interface{}{\n\t\t\t\t\"createdAt\": oldDoc.Field(\"createdAt\").Default(time.Now()),\n\t\t\t})\n\t\t},\n\t}).RunWrite(db.session)\n\treturn err\n}\n\nfunc (db *RdbUtils) ProviderUpdate(id string, provider proto.Provider) error {\n\tprovider.Id = id\n\t_, err := r.Table(\"providers\").Get(id).Update(provider).RunWrite(db.session)\n\treturn err\n}\n\nfunc (db *RdbUtils) ProviderOffline(id string) error {\n\t_, err := r.Table(\"providers\").Get(id).Update(proto.Provider{\n\t\tPresent: newBool(false),\n\t}).RunWrite(db.session)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = r.Table(\"devices\").Filter(r.Row.Field(\"provider_id\").Eq(id)).Update(map[string]interface{}{\n\t\t\"provider_id\": 0,\n\t}).RunWrite(db.session)\n\treturn err\n}\n\nfunc (db *RdbUtils) ProviderGet(id string) (provider proto.Provider, err error) {\n\tres, err := r.Table(\"providers\").Get(id).Run(db.session)\n\tif err != nil {\n\t\treturn\n\t}\n\tdefer res.Close()\n\terr = res.One(&provider)\n\treturn\n}\n"
  },
  {
    "path": "database_test.go",
    "content": "package main\n\nimport (\n\t\"testing\"\n\n\tr \"gopkg.in/gorethink/gorethink.v4\"\n)\n\nfunc TestInsertOrUpdateDevice(t *testing.T) {\n\tmock := r.NewMock()\n\t// mock.On(r.Table(\"devices\")).\n\t_ = mock\n}\n\n// func TestTableProduct(t *testing.T) {\n\n// \tdevice, err := db.DeviceGet(\"6EB0217607005249-c4:86:e9:53:c2:e4-DUK-AL20\")\n// \tif err != nil {\n// \t\tt.Fatal(err)\n// \t}\n// \tt.Logf(\"%#v\", device)\n// }\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: '3'\nservices:\n  atxserver:\n    build: .\n    container_name: atxserver\n    ports:\n      - \"8000:8000\"\n    depends_on:\n      - rethinkdb\n    entrypoint: ./atx-server --rdbaddr rethinkdb:28015 --port 8000\n  rethinkdb:\n    image: rethinkdb:2.3.6\n    container_name: rethinkdb\n    ports:\n      - \"8001:8080\" # expose rethinkdb web console\n    volumes:\n      - \"$PWD/data:/data\"\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/openatx/atx-server\n\nrequire (\n\tgithub.com/alecthomas/kingpin v2.2.6+incompatible\n\tgithub.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc\n\tgithub.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf\n\tgithub.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 // indirect\n\tgithub.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect\n\tgithub.com/codeskyblue/dingrobot v0.0.0-20171214074958-18a295f1ddd8\n\tgithub.com/codeskyblue/heartbeat v0.0.0-20180510083815-41c9a36c9169\n\tgithub.com/codeskyblue/realip v0.0.0-20180509031353-57e9cd075d0e\n\tgithub.com/golang/protobuf v1.2.0\n\tgithub.com/gorilla/context v1.1.1\n\tgithub.com/gorilla/mux v1.6.2\n\tgithub.com/gorilla/websocket v1.4.0\n\tgithub.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed\n\tgithub.com/koding/websocketproxy v0.0.0-20180716164433-0fa3f994f6e7\n\tgithub.com/mash/go-accesslog v0.0.0-20180522074327-610c2be04217\n\tgithub.com/mattn/go-isatty v0.0.4\n\tgithub.com/openatx/androidutils v1.0.0\n\tgithub.com/opentracing/opentracing-go v1.0.2\n\tgithub.com/pkg/errors v0.8.0 // indirect\n\tgithub.com/qiniu/log v0.0.0-20140728010919-a304a74568d6\n\tgithub.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce\n\tgolang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869\n\tgolang.org/x/net v0.0.0-20181114220301-adae6a3d119a\n\tgolang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect\n\tgolang.org/x/sys v0.0.0-20181121002834-0cf1ed9e522b\n\tgopkg.in/gorethink/gorethink.v4 v4.1.0\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/alecthomas/kingpin v2.2.6+incompatible h1:5svnBTFgJjZvGKyYBtMB0+m5wvrbUHiqye8wRJMlnYI=\ngithub.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE=\ngithub.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU=\ngithub.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=\ngithub.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=\ngithub.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=\ngithub.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=\ngithub.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=\ngithub.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=\ngithub.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=\ngithub.com/cenkalti/backoff v2.0.0+incompatible h1:5IIPUHhlnUZbcHQsQou5k1Tn58nJkeJL9U+ig5CHJbY=\ngithub.com/cenkalti/backoff v2.0.0+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=\ngithub.com/codeskyblue/dingrobot v0.0.0-20171214074958-18a295f1ddd8 h1:+Jo1zBcaPTAj7hYZh4eJhuqzOpnsuX8dgtR2YC2k2Ws=\ngithub.com/codeskyblue/dingrobot v0.0.0-20171214074958-18a295f1ddd8/go.mod h1:vQjYKkK66U6tqIBdXHZotqxqYXLfYLJsQFNSVuwLoNM=\ngithub.com/codeskyblue/heartbeat v0.0.0-20180510083815-41c9a36c9169 h1:P+SqriwlWNS+MKgPC6JTs7Lipgk+yScuxzqFAvtVXNY=\ngithub.com/codeskyblue/heartbeat v0.0.0-20180510083815-41c9a36c9169/go.mod h1:L/Vc8wjkglTA5/s1l9/4YOa8e7sOzt4WerxU1AEC0LM=\ngithub.com/codeskyblue/realip v0.0.0-20180509031353-57e9cd075d0e h1:ImKTOlij69pQ3ftNIpBCgTpz52NLJmn3NyHITlgOwVM=\ngithub.com/codeskyblue/realip v0.0.0-20180509031353-57e9cd075d0e/go.mod h1:S09jNhPIOrprdcNbJtRvu8SxrMPp61FQ2GEOm1So8u0=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=\ngithub.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=\ngithub.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=\ngithub.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=\ngithub.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=\ngithub.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=\ngithub.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=\ngithub.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=\ngithub.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=\ngithub.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=\ngithub.com/koding/websocketproxy v0.0.0-20180716164433-0fa3f994f6e7 h1:UPc4az2SLy5Usu+JKfOV4KtfzuRQXXUxY6QOWf9QBJU=\ngithub.com/koding/websocketproxy v0.0.0-20180716164433-0fa3f994f6e7/go.mod h1:Nn5wlyECw3iJrzi0AhIWg+AJUb4PlRQVW4/3XHH1LZA=\ngithub.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/mash/go-accesslog v0.0.0-20180522074327-610c2be04217 h1:oWyemD7bnPAGRGGPE22W1Z+kspkC7Uclz5rdzgxxiwk=\ngithub.com/mash/go-accesslog v0.0.0-20180522074327-610c2be04217/go.mod h1:5JLTyA+23fYz/BfD5Hn736mGEZopzWtEx1pdNfnTp8k=\ngithub.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=\ngithub.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=\ngithub.com/openatx/androidutils v1.0.0 h1:gYKFX/LqOf4LxyO7dZrNfGtPNaCaSNrniUHL06MPATQ=\ngithub.com/openatx/androidutils v1.0.0/go.mod h1:Pbja6rsE71OHQMhrK/tZm86fqB9Go8sXToi9CylrXEU=\ngithub.com/opentracing/opentracing-go v1.0.2 h1:3jA2P6O1F9UOrWVpwrIo17pu01KWvNWg4X946/Y5Zwg=\ngithub.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=\ngithub.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=\ngithub.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/qiniu/log v0.0.0-20140728010919-a304a74568d6 h1:pMot4yzjv8CtGQiGhRvVhwhQl3g4D0Hwkmyd0CVCiyk=\ngithub.com/qiniu/log v0.0.0-20140728010919-a304a74568d6/go.mod h1:WSWulkCEBvfLKNNmweUmJjQGNaYzdHqpTRITXdUNQiQ=\ngithub.com/sirupsen/logrus v1.0.6 h1:hcP1GmhGigz/O7h1WVUM5KklBp1JoNS9FggWKdj/j3s=\ngithub.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=\ngithub.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=\ngithub.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=\ngolang.org/x/crypto v0.0.0-20180820150726-614d502a4dac/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869 h1:kkXA53yGe04D0adEYJwEVQjeBppL01Exg+fnMjfUraU=\ngolang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20181114220301-adae6a3d119a h1:gOpx8G595UYyvj8UK4+OFyY4rx037g3fmfhe5SasG3U=\ngolang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ=\ngolang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sys v0.0.0-20180828065106-d99a578cf41b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20181121002834-0cf1ed9e522b h1:fpg9kqwtLzitbbnpLJATV5Ty8sDv8sJ2ii9+e6fG89A=\ngolang.org/x/sys v0.0.0-20181121002834-0cf1ed9e522b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/fatih/pool.v2 v2.0.0 h1:xIFeWtxifuQJGk/IEPKsTduEKcKvPmhoiVDGpC40nKg=\ngopkg.in/fatih/pool.v2 v2.0.0/go.mod h1:8xVGeu1/2jr2wm5V9SPuMht2H5AEmf5aFMGSQixtjTY=\ngopkg.in/gorethink/gorethink.v4 v4.1.0 h1:xoE9qJ9Ae9KdKEsiQGCF44u2JdnjyohrMBRDtts3Gjw=\ngopkg.in/gorethink/gorethink.v4 v4.1.0/go.mod h1:M7JgwrUAmshJ3iUbEK0Pt049MPyPK+CYDGGaEjdZb/c=\n"
  },
  {
    "path": "heartbeat/heartbeat.go",
    "content": "/*\nFormValue id and port is required\n\nClient send request example\n\n$ curl -X POST -F id=cfa124af -F port=8000\n*/\npackage heartbeat\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/tomasen/realip\"\n)\n\ntype Server struct {\n\tsessions map[string]*Session\n\tmu       sync.RWMutex\n\treceiver Receiver\n}\n\n// NewServer return http.Handler\nfunc NewServer(receiver Receiver) *Server {\n\treturn &Server{\n\t\tsessions: make(map[string]*Session),\n\t\treceiver: receiver,\n\t}\n}\n\nfunc (h *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tid := r.FormValue(\"id\")\n\tif id == \"\" {\n\t\thttp.Error(w, \"param id is required\", 400)\n\t\treturn\n\t}\n\tport, _ := strconv.Atoi(r.FormValue(\"port\"))\n\tif port == 0 {\n\t\thttp.Error(w, \"param port is required\", 400)\n\t\treturn\n\t}\n\tip := r.FormValue(\"ip\")\n\tif ip == \"\" {\n\t\tip = realip.FromRequest(r)\n\t}\n\n\th.mu.Lock()\n\tdefer h.mu.Unlock()\n\tctx := Context{\n\t\tIP:      ip,\n\t\tID:      id,\n\t\tRequest: r,\n\t}\n\tsess, exists := h.sessions[id]\n\tif !exists {\n\t\tif err := h.receiver.OnConnect(ctx); err != nil {\n\t\t\thttp.Error(w, err.Error(), 400)\n\t\t\treturn\n\t\t}\n\t\th.sessions[id] = &Session{\n\t\t\tTimeout:    time.Second * 15,\n\t\t\tsigC:       make(chan bool),\n\t\t\tremoteIP:   ip,\n\t\t\tremotePort: port,\n\t\t}\n\t\tgo func() {\n\t\t\th.sessions[id].drain()\n\t\t\th.receiver.OnDisconnect(id)\n\t\t\tdelete(h.sessions, id)\n\t\t}()\n\t} else {\n\t\tif ip != sess.remoteIP || port != sess.remotePort {\n\t\t\tsess.remoteIP = ip\n\t\t\tsess.remotePort = port\n\t\t\tif err := h.receiver.OnConnect(ctx); err != nil {\n\t\t\t\thttp.Error(w, err.Error(), 400)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tsess.Update()\n\t}\n\tif r.FormValue(\"data\") == \"\" || r.FormValue(\"data\") == \"null\" {\n\t\tio.WriteString(w, \"success ping\\n\")\n\t\treturn\n\t}\n\tif err := h.receiver.OnRequest(ctx); err != nil {\n\t\thttp.Error(w, err.Error(), 400)\n\t} else {\n\t\tio.WriteString(w, \"success request\\n\")\n\t}\n}\n\n// Receiver defines on request\ntype Receiver interface {\n\tOnConnect(ctx Context) error\n\tOnDisconnect(id string)\n\tOnRequest(ctx Context) error\n}\n\ntype Session struct {\n\tid         string\n\tremoteIP   string\n\tremotePort int\n\tTimeout    time.Duration\n\tsigC       chan bool\n}\n\nfunc (hs *Session) Update() {\n\tselect {\n\tcase hs.sigC <- true:\n\tcase <-time.After(100 * time.Millisecond):\n\t}\n}\n\nfunc (hs *Session) drain() {\n\tfor {\n\t\tselect {\n\t\tcase <-time.After(hs.Timeout):\n\t\t\treturn\n\t\tcase <-hs.sigC:\n\t\t}\n\t}\n}\n\ntype Context struct {\n\tRequest *http.Request\n\tIP      string\n\tID      string\n}\n"
  },
  {
    "path": "hostsmanager.go",
    "content": "package main\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\n\t\"github.com/openatx/atx-server/proto\"\n)\n\nfunc deviceQueryToUdid(query string) (udid string, err error) {\n\tif strings.HasPrefix(query, \"ip:\") {\n\t\tinfos := db.DeviceFindAll(proto.DeviceInfo{IP: query[3:], Present: newBool(true)})\n\t\treturn extractUdidFromInfos(infos)\n\t}\n\treturn query, nil\n}\n\nfunc extractUdidFromInfos(infos []proto.DeviceInfo) (udid string, err error) {\n\tif len(infos) == 0 {\n\t\treturn \"\", errors.New(\"not found\")\n\t}\n\tif len(infos) > 1 {\n\t\treturn \"\", errors.New(\"too many matches\")\n\t}\n\treturn infos[0].Udid, nil\n}\n\n// // TODO: need to delete bellow\n// type HostsManager struct {\n// \tmaps map[string]*proto.DeviceInfo\n// \tmu   sync.RWMutex\n// }\n\n// func NewHostsManager() *HostsManager {\n// \treturn &HostsManager{\n// \t\tmaps: make(map[string]*proto.DeviceInfo),\n// \t}\n// }\n\n// func (t *HostsManager) Lookup(query string) *proto.DeviceInfo {\n// \tif strings.HasPrefix(query, \"ip:\") {\n// \t\treturn t.FromIP(query[3:])\n// \t}\n// \treturn t.FromUdid(query)\n// }\n\n// // A return value of nil indicates not found\n// func (t *HostsManager) FromIP(ip string) *proto.DeviceInfo {\n// \tt.mu.Lock()\n// \tdefer t.mu.Unlock()\n// \tfor _, info := range t.maps {\n// \t\tif info.IP == ip {\n// \t\t\treturn info\n// \t\t}\n// \t}\n// \treturn nil\n// }\n\n// // A return value of nil indicates not found\n// func (t *HostsManager) FromUdid(udid string) *proto.DeviceInfo {\n// \tt.mu.Lock()\n// \tdefer t.mu.Unlock()\n// \treturn t.maps[udid]\n// }\n\n// func (t *HostsManager) AddFromDeviceInfo(devInfo *proto.DeviceInfo) {\n// \tt.mu.Lock()\n// \tdefer t.mu.Unlock()\n// \tudid := devInfo.Udid\n// \tif info, ok := t.maps[udid]; ok {\n// \t\tinfo.IP = devInfo.IP\n// \t\tinfo.ConnectionCount++\n// \t} else {\n// \t\tdevInfo.ConnectionCount = 1\n// \t\tt.maps[udid] = devInfo\n// \t}\n// }\n\n// func (t *HostsManager) Remove(udid string) {\n// \tt.mu.Lock()\n// \tdefer t.mu.Unlock()\n// \tif info, ok := t.maps[udid]; ok {\n// \t\tinfo.ConnectionCount--\n// \t\tif info.ConnectionCount <= 0 {\n// \t\t\tdelete(t.maps, udid)\n// \t\t}\n// \t}\n// }\n\n// func (t *HostsManager) Acquire(query string) error {\n// \tinfo := t.Lookup(query)\n// \tif info == nil {\n// \t\treturn errors.New(\"device not found\")\n// \t}\n// \tif info.Reserved != \"\" {\n// \t\treturn errors.New(\"device already reserved\")\n// \t}\n// \tinfo.Reserved = \"hzsunshx\"\n// \treturn nil\n// }\n\n// func (t *HostsManager) Release(query string) error {\n// \tinfo := t.Lookup(query)\n// \tif info == nil {\n// \t\treturn errors.New(\"device not found\")\n// \t}\n// \tinfo.Reserved = \"\"\n// \treturn nil\n// }\n\n// func (t *HostsManager) Random() (devInfo *proto.DeviceInfo, err error) {\n// \tt.mu.Lock()\n// \tdefer t.mu.Unlock()\n// \tfor _, info := range t.maps {\n// \t\tif info.Ready != nil && *info.Ready == true && info.Reserved == \"\" {\n// \t\t\tinfo.Reserved = \"random\"\n// \t\t\treturn info, nil\n// \t\t}\n// \t}\n// \treturn nil, errors.New(\"no devices avaliable\")\n// }\n"
  },
  {
    "path": "httplog.go",
    "content": "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\tisatty \"github.com/mattn/go-isatty\"\n)\n\nvar logger *log.Logger\nvar isaTTY = isatty.IsTerminal(os.Stdout.Fd())\n\nfunc init() {\n\tif runtime.GOOS == \"windows\" {\n\t\tlogger = log.New(os.Stdout, \"\", log.Ltime)\n\t} else {\n\t\tlogger = log.New(os.Stdout, \"\\033[0;32m[\", log.Ltime)\n\t}\n}\n\ntype HTTPLogger struct {\n}\n\n// Example\n// [I 170227 14:47:16 web:1946] 200 GET /api/v1/devices (10.240.185.65) 28.00ms\nfunc (l HTTPLogger) Log(record accesslog.LogRecord) {\n\t// update info too many just ignore\n\tif record.Method == \"POST\" && regexp.MustCompile(`/devices/[^/]+/info`).MatchString(record.Uri) {\n\t\treturn\n\t}\n\tif strings.HasSuffix(record.Uri, \"/heartbeat\") {\n\t\treturn\n\t}\n\n\tif isaTTY {\n\t\tlogger.Println(fmt.Sprintf(\"\\b] \\033[0;m%d %s %s (%s) %.2fms\", record.Status, record.Method, record.Uri, record.Ip,\n\t\t\tfloat64(record.ElapsedTime.Nanoseconds()/1000)/1000.0))\n\t} else {\n\t\tlogger.Println(fmt.Sprintf(\"%d %s %s (%s) %.2fms\", record.Status, record.Method, record.Uri, record.Ip,\n\t\t\tfloat64(record.ElapsedTime.Nanoseconds()/1000)/1000.0))\n\t}\n}\n"
  },
  {
    "path": "httpserver.go",
    "content": "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/http/httputil\"\n\t\"net/url\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/codeskyblue/heartbeat\"\n\t\"github.com/codeskyblue/realip\"\n\t\"github.com/gorilla/mux\"\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/koding/websocketproxy\"\n\thb2 \"github.com/openatx/atx-server/heartbeat\"\n\t\"github.com/openatx/atx-server/proto\"\n\t\"github.com/qiniu/log\"\n)\n\nvar (\n\tupgrader = websocket.Upgrader{\n\t\tCheckOrigin: func(r *http.Request) bool {\n\t\t\treturn true\n\t\t},\n\t}\n\n\t// Time allowed to write message to the client\n\twsWriteWait = 10 * time.Second\n\n\t// Send pings to client with this period. Must be less than pongWait.\n\twsPingPeriod = 10 * time.Second\n\n\t// Time allowed to read the next pong message from client\n\twsPongWait = wsPingPeriod * 3\n\n\tfuncMap template.FuncMap\n)\n\nfunc init() {\n\tfuncMap = template.FuncMap{\n\t\t\"title\": strings.Title,\n\t\t\"urlhash\": func(s string) string {\n\t\t\tpath := strings.TrimPrefix(s, \"/\")\n\t\t\tinfo, err := os.Stat(path)\n\t\t\tif err != nil {\n\t\t\t\treturn s + \"#no-such-file\"\n\t\t\t}\n\t\t\treturn fmt.Sprintf(\"%s?t=%d\", s, info.ModTime().Unix())\n\t\t},\n\t}\n}\n\nfunc renderHTML(w http.ResponseWriter, filename string, value interface{}) {\n\ttmpl := template.Must(template.New(\"\").Funcs(funcMap).Delims(\"[[\", \"]]\").ParseGlob(\"templates/*.html\"))\n\ttmpl.ExecuteTemplate(w, filename, value)\n\t// content, _ := ioutil.ReadFile(\"templates/\" + filename)\n\t// template.Must(template.New(filename).Parse(string(content))).Execute(w, nil)\n}\n\nfunc renderJSON(w http.ResponseWriter, data interface{}) {\n\tjs, err := json.Marshal(data)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\tw.Header().Set(\"Content-Type\", \"application/json; charset=UTF-8\")\n\tw.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", len(js)))\n\tw.Write(js)\n}\n\nfunc newHandler() http.Handler {\n\tr := mux.NewRouter()\n\tr.HandleFunc(\"/version\", func(w http.ResponseWriter, r *http.Request) {\n\t\trenderJSON(w, map[string]string{\n\t\t\t\"server\":    version,\n\t\t\t\"atx-agent\": atxAgentVersion,\n\t\t})\n\t})\n\n\t// 设备远程控制\n\tr.HandleFunc(\"/devices/ip:{ip}/remote\", func(w http.ResponseWriter, r *http.Request) {\n\t\tip := mux.Vars(r)[\"ip\"]\n\t\trenderHTML(w, \"remote.html\", ip)\n\t}).Methods(\"GET\")\n\n\tr.HandleFunc(\"/devices/{udid}/remote\", func(w http.ResponseWriter, r *http.Request) {\n\t\tudid := mux.Vars(r)[\"udid\"]\n\t\tinfo, err := db.DeviceGet(udid)\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), 404)\n\t\t\treturn\n\t\t}\n\t\trenderHTML(w, \"remote.html\", map[string]interface{}{\n\t\t\t\"IP\":   info.IP,\n\t\t\t\"Port\": info.Port,\n\t\t\t\"Udid\": udid})\n\t}).Methods(\"GET\")\n\n\t// 设备信息修改\n\tr.HandleFunc(\"/devices/{udid}/edit\", func(w http.ResponseWriter, r *http.Request) {\n\t\tudid := mux.Vars(r)[\"udid\"]\n\t\trenderHTML(w, \"edit.html\", udid)\n\t}).Methods(\"GET\")\n\n\t// Video-backend starts\n\tvideoProxyURL, _ := url.Parse(*videoBackend)\n\twsProxyURL, _ := url.Parse(*videoBackend)\n\twsProxyURL.Scheme = \"ws\"\n\n\tvideoProxy := httputil.NewSingleHostReverseProxy(videoProxyURL)\n\twsVideoProxy := websocketproxy.NewProxy(wsProxyURL)\n\n\tr.PathPrefix(\"/videos\").Handler(videoProxy).Methods(\"GET\", \"DELETE\")\n\tr.Handle(\"/video/images2video\", videoProxy) // not working with POST proxy\n\tr.PathPrefix(\"/static/videos/\").Handler(videoProxy)\n\tr.Handle(\"/video/convert\", wsVideoProxy)\n\t// End of video-backend\n\n\tr.HandleFunc(\"/products/{brand}/{model}\", func(w http.ResponseWriter, r *http.Request) {\n\t\tvars := mux.Vars(r)\n\t\tbrand, model := vars[\"brand\"], vars[\"model\"]\n\t\tproducts, err := db.ProductsFindAll(brand, model)\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), 500)\n\t\t\treturn\n\t\t}\n\t\trenderJSON(w, products)\n\t})\n\n\tr.HandleFunc(\"/devices/{udid}/product\", func(w http.ResponseWriter, r *http.Request) {\n\t\tvar product proto.Product\n\t\terr := json.NewDecoder(r.Body).Decode(&product)\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusForbidden)\n\t\t\treturn\n\t\t}\n\t\tif product.Id == \"\" {\n\t\t\thttp.Error(w, \"product id is required\", http.StatusForbidden)\n\t\t\treturn\n\t\t}\n\t\tif err := db.ProductUpdate(product.Id, product); err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusForbidden)\n\t\t\treturn\n\t\t}\n\t\terr = db.DeviceUpdate(mux.Vars(r)[\"udid\"], proto.DeviceInfo{\n\t\t\tProduct: &proto.Product{\n\t\t\t\tId: product.Id,\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusForbidden)\n\t\t\treturn\n\t\t}\n\t\trenderJSON(w, map[string]interface{}{\n\t\t\t\"success\": true,\n\t\t})\n\t}).Methods(\"PUT\")\n\n\tr.HandleFunc(\"/echo\", echo)\n\n\tr.HandleFunc(\"/feeds\", func(w http.ResponseWriter, r *http.Request) {\n\t\tws, err := upgrader.Upgrade(w, r, nil)\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), 500)\n\t\t\treturn\n\t\t}\n\t\tdefer ws.Close()\n\t\tfeeds, cancel, err := db.WatchDeviceChanges()\n\t\tif err != nil {\n\t\t\tws.WriteJSON(map[string]string{\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tgo func() {\n\t\t\tdefer cancel()\n\t\t\tfor {\n\t\t\t\t_, _, err := ws.ReadMessage()\n\t\t\t\tif err != nil {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tlog.Debug(\"ws read closed\")\n\t\t}()\n\t\tfor change := range feeds {\n\t\t\tbuf := bytes.NewBuffer(nil)\n\t\t\tjson.NewEncoder(buf).Encode(map[string]interface{}{\n\t\t\t\t\"new\": change.NewValue,\n\t\t\t\t\"old\": change.OldValue,\n\t\t\t})\n\t\t\terr := ws.WriteMessage(websocket.TextMessage, buf.Bytes()) // []byte(`{\"new\": \"haha\", \"old\": \"wowo\"}`))\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tlog.Debug(\"ws write closed\")\n\t})\n\n\tr.HandleFunc(\"/providers\", func(w http.ResponseWriter, r *http.Request) {\n\t\tvalues := r.URL.Query()\n\t\tif _, ok := values[\"json\"]; ok {\n\t\t\tproviders, err := db.ProvidersAll()\n\t\t\tif err != nil {\n\t\t\t\trenderJSON(w, map[string]interface{}{\n\t\t\t\t\t\"success\":     false,\n\t\t\t\t\t\"description\": err.Error(),\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\trenderJSON(w, providers)\n\t\t\treturn\n\t\t}\n\t\trenderHTML(w, \"providers.html\", nil)\n\t})\n\n\tr.HandleFunc(\"/providers/{id}\", func(w http.ResponseWriter, r *http.Request) {\n\t\tvar p proto.Provider\n\t\tdata, _ := ioutil.ReadAll(r.Body)\n\t\tif err := json.Unmarshal(data, &p); err != nil {\n\t\t\trenderJSON(w, map[string]interface{}{\n\t\t\t\t\"success\":     false,\n\t\t\t\t\"description\": err.Error(),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tid := mux.Vars(r)[\"id\"]\n\t\tdb.ProviderUpdate(id, p)\n\t\trenderJSON(w, map[string]interface{}{\n\t\t\t\"success\": true,\n\t\t})\n\t}).Methods(\"PUT\")\n\n\tr.HandleFunc(\"/api/v1/batch/unlock\", func(w http.ResponseWriter, r *http.Request) {\n\t\tbatchRunCommand(\"am start -W --user 0 -a com.github.uiautomator.ACTION_IDENTIFY; input keyevent HOME\")\n\t\tio.WriteString(w, \"Success\")\n\t})\n\n\tr.HandleFunc(\"/api/v1/batch/lock\", func(w http.ResponseWriter, r *http.Request) {\n\t\tbatchRunCommand(\"input keyevent POWER\")\n\t\tio.WriteString(w, \"Success\")\n\t})\n\n\tr.HandleFunc(\"/api/v1/batch/shell\", func(w http.ResponseWriter, r *http.Request) {\n\t\tcommand := r.FormValue(\"command\")\n\t\tbatchRunCommand(command)\n\t\tio.WriteString(w, \"Success\")\n\t})\n\n\t// r.HandleFunc(\"/api/v1/phones/identify\")\n\tr.HandleFunc(\"/\", func(w http.ResponseWriter, r *http.Request) {\n\t\trenderHTML(w, \"index.html\", nil)\n\t})\n\n\tr.PathPrefix(\"/assets\").Handler(http.StripPrefix(\"/assets/\", http.FileServer(http.Dir(\"./assets\"))))\n\tr.HandleFunc(\"/favicon.ico\", func(w http.ResponseWriter, r *http.Request) {\n\t\thttp.ServeFile(w, r, \"assets/favicon.ico\")\n\t})\n\n\tr.HandleFunc(\"/list\", func(w http.ResponseWriter, r *http.Request) {\n\t\tdevices, err := db.DeviceList()\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), 500)\n\t\t\treturn\n\t\t}\n\t\trenderJSON(w, devices)\n\t})\n\n\tr.HandleFunc(\"/devices/{query}/info\", func(w http.ResponseWriter, r *http.Request) {\n\t\tquery := mux.Vars(r)[\"query\"]\n\t\tudid, err := deviceQueryToUdid(query)\n\t\tif err != nil {\n\t\t\tio.WriteString(w, \"Failure, device \"+query+\" not found\")\n\t\t\treturn\n\t\t}\n\t\tif r.Method == \"GET\" {\n\t\t\tinfo, _ := db.DeviceGet(udid)\n\t\t\trenderJSON(w, info)\n\t\t\treturn\n\t\t}\n\t\t// POST\n\t\tvar info proto.DeviceInfo\n\t\tif err := json.NewDecoder(r.Body).Decode(&info); err != nil {\n\t\t\tio.WriteString(w, err.Error())\n\t\t\treturn\n\t\t}\n\t\tdb.DeviceUpdate(udid, info)\n\t\tio.WriteString(w, \"Success\")\n\t}).Methods(\"GET\", \"POST\")\n\n\tr.HandleFunc(\"/property\", func(w http.ResponseWriter, r *http.Request) {\n\t\tclientIp := realip.FromRequest(r)\n\t\tudid, err := deviceQueryToUdid(\"ip:\" + clientIp)\n\t\tif err != nil {\n\t\t\tio.WriteString(w, \"init with uiautomator2\")\n\t\t\treturn\n\t\t}\n\t\tinfo, err := db.DeviceGet(udid)\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), 500)\n\t\t\treturn\n\t\t}\n\t\tif r.Method == \"POST\" {\n\t\t\tvar id string = r.FormValue(\"id\")\n\t\t\tif id == \"\" && r.FormValue(\"id_number\") != \"\" {\n\t\t\t\tid = \"HIH-PHO-\" + r.FormValue(\"id_number\")\n\t\t\t}\n\t\t\tdb.DeviceUpdate(info.Udid, proto.DeviceInfo{\n\t\t\t\tPropertyId: id,\n\t\t\t})\n\t\t\tinfo.PropertyId = id\n\t\t\tio.WriteString(w, \"<h1>Updated to \"+id+\"</h1>\")\n\t\t\treturn\n\t\t}\n\t\trenderHTML(w, \"property.html\", info.PropertyId)\n\t}).Methods(\"GET\", \"POST\")\n\n\tr.HandleFunc(\"/devices/{query}/reserved\", func(w http.ResponseWriter, r *http.Request) {\n\t\tquery := mux.Vars(r)[\"query\"]\n\t\tudid, err := deviceQueryToUdid(query)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"Device not found \"+err.Error(), http.StatusGone)\n\t\t\treturn\n\t\t}\n\t\tinfo, err := db.DeviceGet(udid)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"Device get error \"+err.Error(), http.StatusGone)\n\t\t\treturn\n\t\t}\n\t\t// create websocket connection\n\t\tws, err := upgrader.Upgrade(w, r, nil)\n\t\tif err != nil {\n\t\t\tlog.Println(err)\n\t\t\treturn\n\t\t}\n\t\tdefer ws.Close()\n\t\tif toBool(info.Using) {\n\t\t\tlog.Printf(\"Device %s is using\", udid)\n\t\t\treturn\n\t\t}\n\t\tdb.DeviceUpdate(info.Udid, proto.DeviceInfo{\n\t\t\tUsing:        newBool(true),\n\t\t\tUsingBeganAt: time.Now(),\n\t\t\tOwner: &proto.OwnerInfo{\n\t\t\t\tIP: realip.FromRequest(r),\n\t\t\t},\n\t\t})\n\t\tdefer func() {\n\t\t\tdb.DeviceUpdate(udid, proto.DeviceInfo{\n\t\t\t\tUsing: newBool(false),\n\t\t\t})\n\t\t\tgo func() {\n\t\t\t\tport := info.Port\n\t\t\t\tif port == 0 {\n\t\t\t\t\tport = 7912\n\t\t\t\t}\n\t\t\t\treqURL := \"http://\" + info.IP + \":\" + strconv.Itoa(port) + \"/shell\"\n\t\t\t\treq, _ := http.NewRequest(\"GET\", reqURL, nil)\n\t\t\t\tq := req.URL.Query()\n\t\t\t\tq.Add(\"command\", \"am start -n com.github.uiautomator/.IdentifyActivity\")\n\t\t\t\treq.URL.RawQuery = q.Encode()\n\n\t\t\t\tresp, err := http.DefaultClient.Do(req)\n\t\t\t\tif err == nil {\n\t\t\t\t\tresp.Body.Close()\n\t\t\t\t}\n\t\t\t}()\n\t\t}()\n\t\t// wait until ws disconnected\n\t\tfor {\n\t\t\tif _, _, err := ws.ReadMessage(); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}).Methods(\"GET\")\n\n\tr.HandleFunc(\"/devices/{query}/reserved\", func(w http.ResponseWriter, r *http.Request) {\n\t\tquery := mux.Vars(r)[\"query\"]\n\t\tudid, err := deviceQueryToUdid(query)\n\t\t// info := hostsManager.Lookup(query)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"Device not found \"+err.Error(), http.StatusGone)\n\t\t\treturn\n\t\t}\n\t\tif r.Method == \"POST\" {\n\t\t\tinfo, err := db.DeviceGet(udid)\n\t\t\tif err != nil {\n\t\t\t\thttp.Error(w, \"Device get error \"+err.Error(), http.StatusGone)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !toBool(info.Present) {\n\t\t\t\thttp.Error(w, \"Device offline\", http.StatusGone)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif toBool(info.Using) {\n\t\t\t\thttp.Error(w, \"Device is using\", http.StatusForbidden)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdb.DeviceUpdate(info.Udid, proto.DeviceInfo{\n\t\t\t\tUsing:        newBool(true),\n\t\t\t\tUsingBeganAt: time.Now(),\n\t\t\t\tOwner: &proto.OwnerInfo{\n\t\t\t\t\tIP: realip.FromRequest(r),\n\t\t\t\t},\n\t\t\t})\n\t\t\tio.WriteString(w, \"Success\")\n\t\t\treturn\n\t\t}\n\t\t// DELETE\n\t\tdb.DeviceUpdate(udid, proto.DeviceInfo{\n\t\t\tUsing: newBool(false),\n\t\t})\n\t\tio.WriteString(w, \"Release success\")\n\t}).Methods(\"POST\", \"DELETE\")\n\n\tr.HandleFunc(\"/devices/{query}/shell\", func(w http.ResponseWriter, r *http.Request) {\n\t\tquery := mux.Vars(r)[\"query\"]\n\t\tudid, err := deviceQueryToUdid(query)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"Device not found\", 410)\n\t\t\treturn\n\t\t}\n\t\tinfo, err := db.DeviceGet(udid)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"Device get error \"+err.Error(), 500)\n\t\t\treturn\n\t\t}\n\n\t\tcommand := r.FormValue(\"command\")\n\t\toutput, err := runAndroidShell(info.IP, command)\n\t\tif err != nil {\n\t\t\trenderJSON(w, map[string]string{\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t} else {\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json; charset=UTF-8\")\n\t\t\tio.WriteString(w, output) // the output is already json\n\t\t}\n\t}).Methods(\"POST\")\n\n\t// heartbeat for reverse proxies (adb forward device 7912 port)\n\thbs := heartbeat.NewServer(\"hello kitty\", 15*time.Second)\n\thbs.OnConnect = func(identifier string, r *http.Request) {\n\t\thost := realip.FromRequest(r)\n\t\tdb.DeviceUpdateOrInsert(proto.DeviceInfo{\n\t\t\tUdid: identifier,\n\t\t\tIP:   host,\n\t\t})\n\t\tlog.Println(identifier, \"is online\")\n\t}\n\n\t// Called when client ip changes\n\thbs.OnReconnect = func(identifier string, r *http.Request) {\n\t\thost := realip.FromRequest(r)\n\t\tdb.DeviceUpdateOrInsert(proto.DeviceInfo{\n\t\t\tUdid: identifier,\n\t\t\tIP:   host,\n\t\t})\n\t\tlog.Println(identifier, \"is reconnected\")\n\t}\n\n\thbs.OnDisconnect = func(identifier string) {\n\t\tdb.SetDeviceAbsent(identifier)\n\t\tlog.Println(identifier, \"is offline\")\n\t}\n\tr.Handle(\"/heartbeat\", hbs)\n\n\tproviderhbs := hb2.NewServer(&ProviderReceiver{})\n\tr.Handle(\"/provider/heartbeat\", providerhbs)\n\n\treturn r\n}\n\ntype ProviderReceiver struct{}\n\nfunc (p *ProviderReceiver) OnConnect(ctx hb2.Context) error {\n\tport, _ := strconv.Atoi(ctx.Request.FormValue(\"port\"))\n\tif port == 0 {\n\t\treturn errors.New(\"Request port is required\")\n\t}\n\tlog.Printf(\"Provider id:%s ip:%s port:%d is connected\", ctx.ID, ctx.IP, port)\n\treturn db.ProviderUpdateOrInsert(ctx.ID, ctx.IP, port)\n}\n\nfunc (p *ProviderReceiver) OnDisconnect(id string) {\n\tlog.Printf(\"Provider %s disconnected\", id)\n\tdb.ProviderOffline(id)\n}\n\n/*\nPOST udid, status=<online|offline>\n*/\nfunc (p *ProviderReceiver) OnRequest(ctx hb2.Context) error {\n\tid, req := ctx.ID, ctx.Request\n\tdata := req.FormValue(\"data\")\n\tlog.Println(\"Receive data:\", data)\n\tvar v struct {\n\t\tStatus                string `json:\"status\"`\n\t\tUdid                  string `json:\"udid\"`\n\t\tProviderForwardedPort int    `json:\"providerForwardedPort\"`\n\t}\n\tif err := json.Unmarshal([]byte(data), &v); err != nil {\n\t\treturn err\n\t}\n\tstatus, udid := v.Status, v.Udid\n\tif status == \"\" || udid == \"\" {\n\t\treturn errors.New(\"status or udid is empty\")\n\t}\n\tprovider, err := db.ProviderGet(id)\n\tif err != nil {\n\t\tlog.Println(\"Unexpect err:\", err)\n\t\treturn err\n\t}\n\tvar providerId = &provider.Id\n\tif status == \"online\" {\n\t\tlog.Printf(\"Device: %s is plugged-in\", udid)\n\t} else if status == \"offline\" {\n\t\tlog.Printf(\"Device: %s is plugged-off\", udid)\n\t\tproviderId = nil\n\t} else {\n\t\tlog.Printf(\"Invalid status: %s, only <offline|online> is allowed.\", status)\n\t\treturn errors.New(\"status is required\")\n\t}\n\n\treturn db.DeviceUpdate(udid, map[string]interface{}{\n\t\t\"provider_id\":           providerId,\n\t\t\"providerForwardedPort\": v.ProviderForwardedPort,\n\t})\n}\n"
  },
  {
    "path": "main.go",
    "content": "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\"time\"\n\n\t\"github.com/alecthomas/kingpin\"\n\t\"github.com/codeskyblue/dingrobot\"\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/openatx/atx-server/proto\"\n\t\"github.com/qiniu/log\"\n)\n\nconst (\n\tversion                = \"dev\"\n\tdefaultATXAgentVersion = \"0.4.3\"\n)\n\nvar (\n\tport            = kingpin.Flag(\"port\", \"http server listen port\").Short('p').Default(\"8000\").Int()\n\taddr            = kingpin.Flag(\"addr\", \"http server listen address\").Default(\":8000\").String()\n\trdbAddr         = kingpin.Flag(\"rdbaddr\", \"rethinkdb address\").Default(\"localhost:28015\").String()\n\trdbName         = kingpin.Flag(\"rdbname\", \"rethinkdb database name\").Default(\"atxserver\").String()\n\tvideoBackend    = kingpin.Flag(\"video-backend\", \"backend service for encoding images to video\").Default(\"http://localhost:7000\").String()\n\tatxAgentVersion string\n\tdingtalkToken   string\n)\n\nfunc handleWebsocketMessage(host string, message []byte) {\n\treturn\n}\n\nfunc echo(w http.ResponseWriter, r *http.Request) {\n\thost, _, _ := net.SplitHostPort(r.RemoteAddr)\n\tws, err := upgrader.Upgrade(w, r, nil)\n\tif err != nil {\n\t\tlog.Print(\"upgrade:\", err)\n\t\treturn\n\t}\n\tlog.Debugf(\"new connection: %s\", host)\n\n\tdefer func() {\n\t\tlog.Debugf(\"connection lost: %s\", host)\n\t\tws.Close()\n\t}()\n\n\tws.SetReadDeadline(time.Now().Add(wsPongWait))\n\tws.SetPongHandler(func(string) error {\n\t\tws.SetReadDeadline(time.Now().Add(wsPongWait))\n\t\treturn nil\n\t})\n\n\t// Read device info\n\tmessage := &proto.CommonMessage{}\n\tif err := ws.ReadJSON(message); err != nil {\n\t\tlog.Warn(\"error: read json message\")\n\t\treturn\n\t}\n\tif message.Type != proto.DeviceInfoMessage {\n\t\tlog.Warnf(\"error: first message must be device info, but got %v\", message.Type)\n\t\treturn\n\t}\n\tdevInfo := new(proto.DeviceInfo)\n\tjsonData, _ := json.Marshal(message.Data)\n\tjson.NewDecoder(bytes.NewReader(jsonData)).Decode(devInfo)\n\tif devInfo.Udid == \"\" {\n\t\tlog.Warnf(\"error: udid is empty\")\n\t\treturn\n\t}\n\tdevInfo.IP = host\n\tlog.Debugf(\"client ip:%s product:%s brand:%s\", devInfo.IP, devInfo.Model, devInfo.Brand)\n\n\tif devInfo.Memory != nil {\n\t\taround := int(math.Ceil(float64(devInfo.Memory.Total-512*1024) / 1024.0 / 1024.0)) // around\n\t\tdevInfo.Memory.Around = fmt.Sprintf(\"%d GB\", around)\n\t}\n\n\tdb.DeviceUpdateOrInsert(*devInfo)\n\tdefer func() {\n\t\tdb.SetDeviceAbsent(devInfo.Udid)\n\t\t// TODO(ssx): global var, not very function programing\n\t\tif info, err := db.DeviceGet(devInfo.Udid); err == nil && dingtalkToken != \"\" {\n\t\t\trobot := dingrobot.New(dingtalkToken)\n\t\t\tif err := robot.Text(info.PropertyId + \" \" + info.Serial + \"\\n\" + info.Brand + \" \" + info.Model + \" \" + info.IP + \" offline\"); err != nil {\n\t\t\t\tlog.Println(\"dingding send text err:\", err)\n\t\t\t}\n\t\t}\n\t}()\n\n\t// ping ticker\n\tgo func() {\n\t\tpingTicker := time.NewTicker(wsPingPeriod)\n\t\tdefer pingTicker.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-pingTicker.C:\n\t\t\t\tws.SetWriteDeadline(time.Now().Add(wsWriteWait))\n\t\t\t\t// here, writeMessage is not thread safe\n\t\t\t\tif err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\n\t// Listen device info update\n\tfor {\n\t\tmt, message, err := ws.ReadMessage()\n\t\tif err != nil {\n\t\t\tlog.Println(host, \"websocket connection closed\")\n\t\t\tbreak\n\t\t}\n\t\tif mt == websocket.TextMessage {\n\t\t\thandleWebsocketMessage(host, message)\n\t\t}\n\t}\n}\n\nfunc runAndroidShell(ip string, command string) (output string, err error) {\n\tu, _ := url.Parse(\"http://\" + ip + \":7912/shell\")\n\tparams := url.Values{}\n\tparams.Add(\"command\", command)\n\tu.RawQuery = params.Encode()\n\tresp, err := http.Get(u.String())\n\tif err != nil {\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\tjsondata, err := ioutil.ReadAll(resp.Body)\n\treturn string(jsondata), err\n}\n\nfunc batchRunCommand(command string) {\n\twg := sync.WaitGroup{}\n\n\tdevices, _ := db.DeviceList()\n\tfor _, devInfo := range devices {\n\t\tif devInfo.Present == nil || !*devInfo.Present {\n\t\t\tcontinue\n\t\t}\n\n\t\twg.Add(1)\n\t\tgo func(ip string) {\n\t\t\trunAndroidShell(ip, command)\n\t\t\twg.Done()\n\t\t}(devInfo.IP)\n\t}\n\twg.Wait()\n}\n\nfunc main() {\n\t// Refs: atx-agent version https://github.com/openatx/atx-agent/releases\n\tkingpin.Flag(\"agent\", \"atx-agent version\").Default(defaultATXAgentVersion).StringVar(&atxAgentVersion)\n\t// FIXME(ssx): Ding talk is disabled because of too many boring messages\n\tkingpin.Flag(\"ding-token\", \"DingDing robot token (env: DING_TOKEN)\").OverrideDefaultFromEnvar(\"DING_TOKEN\").StringVar(&dingtalkToken)\n\tkingpin.Version(version)\n\tkingpin.HelpFlag.Short('h')\n\tkingpin.Parse()\n\n\t// log.SetFlags(log.Lshortfile | log.LstdFlags)\n\t// log.SetLevel(log.DebugLevel)\n\t// log.SetFormatter(&log.TextFormatter{})\n\t// inforus.AddHookDefault()\n\n\tif *port != 8000 {\n\t\t*addr = fmt.Sprintf(\":%d\", *port)\n\t}\n\n\tif dingtalkToken != \"\" {\n\t\tlog.Println(\"dingtalk notification enabled\")\n\t\tif err := dingrobot.New(dingtalkToken).Text(\"atx-server started\"); err != nil {\n\t\t\tlog.Println(\"dingtalk test notification err:\", err)\n\t\t}\n\t}\n\n\tlog.Info(\"initial database\")\n\tinitDB(*rdbAddr, *rdbName)\n\tlog.Info(\"listen address\", *addr)\n\tlog.Fatal(http.ListenAndServe(*addr, newHandler()))\n}\n"
  },
  {
    "path": "proto/message.go",
    "content": "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\nconst (\n\tDeviceInfoMessage = MessageType(0)\n\tPingMessage       = MessageType(1)\n)\n\ntype CommonMessage struct {\n\tType MessageType\n\tData interface{}\n}\n\nfunc (m *CommonMessage) MarshalJSON() []byte {\n\tdata, _ := json.Marshal(m)\n\treturn data\n}\n\ntype CpuInfo struct {\n\tCores    int    `json:\"cores\"`\n\tHardware string `json:\"hardware\"`\n}\n\ntype MemoryInfo struct {\n\tTotal  int    `json:\"total\"` // unit kB\n\tAround string `json:\"around,omitempty\"`\n}\n\ntype OwnerInfo struct {\n\tIP string `json:\"ip\"`\n}\n\ntype DeviceInfo struct {\n\tUdid                   string                `json:\"udid,omitempty\"`       // Unique device identifier\n\tPropertyId             string                `json:\"propertyId,omitempty\"` // For device managerment, eg: HIH-PHO-1122\n\tVersion                string                `json:\"version,omitempty\"`    // ro.build.version.release\n\tSerial                 string                `json:\"serial,omitempty\"`     // ro.serialno\n\tBrand                  string                `json:\"brand,omitempty\"`      // ro.product.brand\n\tModel                  string                `json:\"model,omitempty\"`      // ro.product.model\n\tHWAddr                 string                `json:\"hwaddr,omitempty\"`     // persist.sys.wifi.mac\n\tNotes                  string                `json:\"notes,omitempty\"`      // device notes\n\tIP                     string                `json:\"ip,omitempty\"`\n\tPort                   int                   `json:\"port,omitempty\"`\n\tReverseProxyAddr       string                `json:\"reverseProxyAddr,omitempty\"`\n\tReverseProxyServerAddr string                `json:\"reverseProxyServerAddr,omitempty\"`\n\tSdk                    int                   `json:\"sdk,omitempty\"`\n\tAgentVersion           string                `json:\"agentVersion,omitempty\"`\n\tDisplay                *androidutils.Display `json:\"display,omitempty\"`\n\tBattery                *androidutils.Battery `json:\"battery,omitempty\"`\n\tMemory                 *MemoryInfo           `json:\"memory,omitempty\"` // proc/meminfo\n\tCpu                    *CpuInfo              `json:\"cpu,omitempty\"`    // proc/cpuinfo\n\n\tOwner    *OwnerInfo `json:\"owner\" gorethink:\"owner,omitempty\"`\n\tReserved string     `json:\"reserved,omitempty\"`\n\n\tConnectionCount   int       `json:\"-\"` // > 1 happended when phone redial server\n\tCreatedAt         time.Time `json:\"-\" gorethink:\"createdAt,omitempty\"`\n\tPresenceChangedAt time.Time `json:\"presenceChangedAt,omitempty\"`\n\tUsingBeganAt      time.Time `json:\"usingBeganAt,omitempty\" gorethink:\"usingBeganAt,omitempty\"`\n\n\tReady   *bool `json:\"ready,omitempty\"`\n\tPresent *bool `json:\"present,omitempty\"`\n\tUsing   *bool `json:\"using,omitempty\"`\n\n\tProduct  *Product  `json:\"product\" gorethink:\"product_id,reference,omitempty\" gorethink_ref:\"id\"`\n\tProvider *Provider `json:\"provider\" gorethink:\"provider_id,reference,omitempty\" gorethink_ref:\"id\"`\n\n\t// only works when there is provider\n\tProviderForwardedPort int `json:\"providerForwardedPort,omitempty\"`\n\n\t// used for provider to known agent server url\n\tServerURL string `json:\"serverUrl,omitempty\"`\n}\n\n// \"Brand Model Memory CPU\" together can define a phone\ntype Product struct {\n\tId     string `json:\"id\" gorethink:\"id,omitempty\"`\n\tName   string `json:\"name\" gorethink:\"name,omitempty\"`\n\tBrand  string `json:\"brand\" gorethink:\"brand,omitempty\"`\n\tModel  string `json:\"model\" gorethink:\"model,omitempty\"`\n\tMemory string `json:\"memory,omitempty\"` // eg: 4GB\n\tCpu    string `json:\"cpu,omitempty\"`\n\n\tCoverage float32 `json:\"coverage\" gorethink:\"coverage,omitempty\"`\n\tGpu      string  `json:\"gpu,omitempty\"`\n\tLink     string  `json:\"link,omitempty\"` // Outside link\n\t// AntutuScore int     `json:\"antutuScore,omitempty\"`\n}\n\n// u2init\ntype Provider struct {\n\tId                string       `json:\"id\" gorethink:\"id,omitempty\"` // machine id\n\tIP                string       `json:\"ip\" gorethink:\"ip,omitempty\"`\n\tPort              int          `json:\"port\" gorethink:\"port,omitempty\"`\n\tPresent           *bool        `json:\"present,omitempty\"`\n\tNotes             string       `json:\"notes\" gorethink:\"notes,omitempty\"`\n\tDevices           []DeviceInfo `json:\"devices\" gorethink:\"devices,omitempty\"`\n\tCreatedAt         time.Time    `json:\"createdAt,omitempty\"`\n\tPresenceChangedAt time.Time    `json:\"presenceChangedAt,omitempty\"`\n}\n\n// Addr combined with ip:port\nfunc (p *Provider) Addr() string {\n\treturn fmt.Sprintf(\"%s:%d\", p.IP, p.Port)\n}\n"
  },
  {
    "path": "rethinkdb-test/main.go",
    "content": "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)\n\ntype RdbUtils struct {\n\tsession *r.Session\n}\n\nfunc (db *RdbUtils) DBCreateAnyway(name string) error {\n\tres, err := r.DBList().Run(db.session)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer res.Close()\n\tvar dbNames []string\n\tif err := res.All(&dbNames); err != nil {\n\t\treturn err\n\t}\n\tfor _, dbName := range dbNames {\n\t\tlog.Println(dbName)\n\t\tif dbName == name {\n\t\t\tlog.Println(\"db exists atxserver\")\n\t\t\treturn nil\n\t\t}\n\t}\n\terr = r.DBCreate(\"atxserver\").Exec(db.session)\n\treturn err\n}\n\nfunc (db *RdbUtils) TableCreateAnyway(name string) error {\n\terr := r.TableCreate(name, r.TableCreateOpts{\n\t\tPrimaryKey: \"udid\",\n\t}).Exec(db.session)\n\tif err != nil && strings.Contains(err.Error(), \"already exists\") {\n\t\treturn nil\n\t}\n\treturn err\n}\n\nfunc (db *RdbUtils) UpdateOrInsertDevice(dev proto.DeviceInfo) error {\n\treturn r.Table(\"devices\").Insert(dev, r.InsertOpts{\n\t\tConflict: func(id, oldDoc, newDoc r.Term) interface{} {\n\t\t\treturn oldDoc.Merge(newDoc)\n\t\t},\n\t}).Exec(db.session)\n}\n\nfunc (db *RdbUtils) DeviceList() (devices []proto.DeviceInfo) {\n\tres, err := r.Table(\"devices\").Run(db.session)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer res.Close()\n\tres.All(&devices)\n\treturn\n}\n\nvar db *RdbUtils\n\nfunc init() {\n\tr.SetTags(\"gorethink\", \"json\")\n\tr.SetVerbose(true)\n\tsession, err := r.Connect(r.ConnectOpts{\n\t\tAddress:    \"localhost:28015\",\n\t\tDatabase:   \"atxserver\",\n\t\tInitialCap: 10,\n\t\tMaxOpen:    10,\n\t})\n\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tdb = &RdbUtils{session}\n}\n\nfunc main() {\n\tlog.Println(\"main\")\n\tif err := db.DBCreateAnyway(\"atxserver\"); err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tif err := db.TableCreateAnyway(\"devices\"); err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tlog.Println(\"table created\")\n\tdb.UpdateOrInsertDevice(proto.DeviceInfo{\n\t\tUdid:   \"aaaabbbbccccdddd1234\",\n\t\tSerial: \"abcd123456\",\n\t\t// Brand: \"Huawei\",\n\t})\n\n\tlog.Println(db.DeviceList())\n\n\tfeeds, err := r.Table(\"devices\").Changes().Run(db.session)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tdefer feeds.Close()\n\tvar change r.ChangeResponse\n\tfor feeds.Next(&change) {\n\t\t// var devInfo proto.DeviceInfo\n\t\t// log.Println(devInfo)\n\t\t// log.Println(change.State)\n\t\tlog.Println(change.NewValue)\n\t\tlog.Println(change.OldValue)\n\t}\n}\n"
  },
  {
    "path": "scripts/img2video/main.py",
    "content": "# coding: utf-8\n#\n# Py3 only\nfrom __future__ import print_function\n\nimport os\nimport uuid\nimport pathlib\nimport shutil\nimport time\nimport io\nimport json\nimport traceback\n\nimport numpy as np\nimport imageio\nimport tornado.ioloop\nimport tornado.web\nimport tornado.websocket\nfrom PIL import Image, ImageOps\nfrom tornado import gen\nfrom tornado.httpclient import AsyncHTTPClient\nfrom tornado.log import enable_pretty_logging\nenable_pretty_logging()\n\n\nclass MainHandler(tornado.web.RequestHandler):\n    @gen.coroutine\n    def get(self):\n        yield gen.sleep(.1)\n        self.render(\"index.html\")\n\n\nclass VideoHandler(tornado.web.RequestHandler):\n    def get(self):\n        content_type = self.request.headers.get('Content-Type')\n        if content_type and 'application/json' in content_type:\n            videopath = pathlib.Path(\"static/videos\")\n            data = []\n            for p in sorted(\n                    videopath.glob('*.mp4'), key=lambda p: p.stat().st_mtime):\n                info = {\n                    'uri': str(p).replace('\\\\', '/'),\n                    'mtime': p.stat().st_mtime,\n                }\n                meta = pathlib.Path(str(p) + \".json\")\n                if meta.exists():\n                    with meta.open('rb') as f:  # read_text not exists on py3.4\n                        metainfo = json.loads(f.read().decode('utf-8'))\n                        info.update(metainfo)\n                data.append(info)\n            self.write({'data': list(reversed(data))})\n            return\n        self.render(\"videos.html\")\n\n    def delete(self, name):\n        mp4file = pathlib.Path(\"static/videos/\" + name)\n        mp4meta = pathlib.Path(\"static/videos/\" + name + \".json\")\n        if mp4meta.exists():\n            mp4meta.unlink()\n        if mp4file.exists():\n            mp4file.unlink()\n            self.write({\"success\": True})\n        else:\n            self.write({\"success\": False})\n\n\ndef resizefit(im, size):  # resize but keep aspect ratio\n    w, h = size\n    oldw, oldh = old_size = im.size\n    old_ratio = oldw / oldh\n    new_ratio = w / h\n    if new_ratio > old_ratio:\n        padw = int(oldh * new_ratio - oldw) // 2\n        im = ImageOps.expand(im, (padw, 0, padw, 0), (0, 0, 255))\n    else:\n        padh = int(oldw / new_ratio - oldh) // 2\n        im = ImageOps.expand(im, (0, padh, 0, padh), (0, 255, 255))\n    return im.resize(size, Image.ANTIALIAS)\n\n\nclass CorsMixin(object):\n    def set_default_headers(self):\n        self.set_header(\"Access-Control-Allow-Origin\", \"*\")\n        self.set_header(\"Access-Control-Allow-Headers\", \"x-requested-with\")\n        self.set_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')\n\n    def options(self):\n        # no body\n        self.set_status(204)\n        self.finish()\n\n\nclass Image2VideoWebsocket(tornado.websocket.WebSocketHandler):\n    def check_origin(self, origin):\n        return True\n\n    def open(self):\n        self.udid = self.get_argument(\n            'udid')  # device udid(unique device identifier)\n        self.name = self.get_argument('name')\n        self.video_path = 'static/videos/%s-%d.mp4' % (self.name,\n                                                       int(time.time() * 1000))\n        self.video_tmp_path = 'tmpdir/ws-%s.mp4' % str(uuid.uuid1())\n        fps = int(self.get_argument('fps', 10))\n        self.writer = imageio.get_writer(self.video_tmp_path, fps=fps)\n        self.size = ()\n        print(\"websocket opened\")\n\n    def on_message(self, message):\n        if isinstance(message, bytes):\n            try:\n                image = Image.open(io.BytesIO(message))\n                if not self.size:  # always horizontal\n                    w, h = self.size = image.size\n                    if w < h:\n                        self.size = (h, w)\n                if self.size != image.size:\n                    image = resizefit(image, self.size)\n                imarray = np.asarray(image)\n                del image\n                self.writer.append_data(imarray)\n            except Exception as e:\n                print(\"Receive image format error\")\n                traceback.print_exc()\n        else:\n            print(\"receive\", message)\n\n    def on_close(self):\n        self.writer.close()\n        if not os.path.exists(self.video_tmp_path):\n            print(\"no video file generated\")\n            return\n        shutil.move(self.video_tmp_path, self.video_path)\n        with open(self.video_path + '.json', 'wb') as f:\n            f.write(\n                json.dumps({\n                    \"udid\": self.udid,\n                    \"name\": self.name\n                }).encode('utf-8'))\n        print(\"websocket closed, video generated\", self.video_path)\n\n\nclass Image2VideoHandler(CorsMixin, tornado.web.RequestHandler):\n    @gen.coroutine\n    def post(self):\n        filemetas = self.request.files['file']\n        tmpdir = pathlib.Path('tmpdir/' + str(uuid.uuid1()))\n        if not tmpdir.is_dir():\n            tmpdir.mkdir(parents=True)\n\n        video_file = 'static/video-%s.mp4' % int(time.time() * 1000)\n        writer = imageio.get_writer(video_file, fps=20)\n        try:\n            size = ()\n            for (i, meta) in enumerate(filemetas):\n                jpgfile = tmpdir / ('%d.jpg' % i)\n                with jpgfile.open('wb') as f:\n                    f.write(meta['body'])\n                imarray = imageio.imread(str(jpgfile))\n                if not size:\n                    size = imarray.shape[1::-1]  # same as reversed(shape[:2])\n                if size != imarray.shape[1::-1]:\n                    im = Image.fromarray(imarray)  # convert to PIL\n                    im = resizefit(im, size)\n                    imarray = np.asarray(im)  # convert to numpy\n                    del im\n                writer.append_data(imarray)\n        finally:\n            shutil.rmtree(str(tmpdir))\n            writer.close()\n\n        self.write({\n            \"success\": True,\n            \"url\": \"http://\" + self.request.host + \"/\" + video_file\n        })\n\n\ndef make_app(**settings):\n    settings['template_path'] = 'templates'\n    settings['static_path'] = 'static'\n    settings['cookie_secret'] = os.environ.get(\"SECRET\", \"SECRET:_\")\n    settings['login_url'] = '/login'\n    return tornado.web.Application([\n        (r\"/\", MainHandler),\n        (r\"/videos\", VideoHandler),\n        (r\"/videos/([^/]+)\", VideoHandler),\n        (r\"/video/convert\", Image2VideoWebsocket),\n        (r\"/img2video\", Image2VideoHandler),\n    ], **settings)\n\n\nif __name__ == \"__main__\":\n    hotreload = bool(os.getenv(\"DEBUG\"))\n    app = make_app(debug=hotreload)\n    app.listen(7000)\n    try:\n        tornado.ioloop.IOLoop.instance().start()\n    except KeyboardInterrupt:\n        tornado.ioloop.IOLoop.instance().stop()"
  },
  {
    "path": "scripts/img2video/requirements.txt",
    "content": "tornado\nimageio\nnumpy\npillow"
  },
  {
    "path": "scripts/img2video/static/.gitkeep",
    "content": ""
  },
  {
    "path": "scripts/img2video/static/videos/.gitkeep",
    "content": ""
  },
  {
    "path": "scripts/img2video/templates/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"ie=edge\">\n    <title>IMG2VIDEO</title>\n</head>\n\n<body>\n    <form method=\"post\" action=\"/img2video\" enctype=\"multipart/form-data\">\n        <input type=\"file\" name=\"file\">\n        <br>\n        <input type=\"file\" name=\"file\">\n        <br>\n        <input type=\"file\" name=\"file\">\n        <br>\n        <input type=\"file\" name=\"file\">\n        <br>\n        <button type=\"submit\">Submit</button>\n    </form>\n</body>\n\n</html>"
  },
  {
    "path": "scripts/img2video/templates/videos.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"ie=edge\">\n    <title>Videos</title>\n    <link rel=\"stylesheet\" href=\"//cdn.jsdelivr.net/bootstrap/3.3.7/css/bootstrap.min.css\">\n    <link rel=\"stylesheet\" href=\"//cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css\">\n</head>\n\n<body>\n    <nav class=\"navbar navbar-default\" style=\"margin-bottom: 0px\">\n        <div class=\"navbar-header\">\n            <button type=\"button\" class=\"navbar-toggle collapsed\" data-toggle=\"collapse\" data-target=\"#bs-example-navbar-collapse-1\"\n                aria-expanded=\"false\">\n                <span class=\"sr-only\">Toggle navigation</span>\n                <span class=\"icon-bar\"></span>\n                <span class=\"icon-bar\"></span>\n                <span class=\"icon-bar\"></span>\n            </button>\n            <a class=\"navbar-brand\" href=\"/\">\n                <i class=\"fa fa-video-camera\"></i>\n                <strong>Videos</strong>\n            </a>\n        </div>\n        <!-- <div class=\"collapse navbar-collapse\" id=\"bs-example-navbar-collapse-1\">\n            <form class=\"navbar-form navbar-left\">\n                <button class=\"btn btn-sm btn-default\" @click.prevent=\"toggleScreen\">\n                    <i v-if=\"screenWS\" class=\"fa fa-eye\"></i>\n                    <i v-else class=\"fa fa-eye-slash\"></i>\n                </button>\n            </form>\n        </div> -->\n    </nav>\n    <div id=\"app\">\n        <div class=\"container-fluid\">\n            <div v-for=\"g in groupedVideos\">\n                <h3 style=\"font-family: 'Courier New'\">{{!g.date}}</h3>\n                <div class=\"row\">\n                    <div class=\"col-md-2\" v-for=\"v in g.videos\">\n                        <videoplayer :src=\"v.uri\" :mtime=\"v.mtime\" :udid=\"v.udid\" :name=\"v.name\"></videoplayer>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</body>\n\n<script src=\"//cdn.jsdelivr.net/npm/vue@2.5.13/dist/vue.js\"></script>\n<script src=\"//cdn.jsdelivr.net/npm/jquery@3.2.1/dist/jquery.min.js\"></script>\n<script src=\"//cdn.jsdelivr.net/bootstrap/3.3.7/js/bootstrap.min.js\"></script>\n<script src=\"//cdn.jsdelivr.net/npm/moment@2.21.0/moment.min.js\"></script>\n<script type=\"text/x-template\" id=\"video-component\">\n    <div class=\"video-container\">\n        <div>\n            <div @dblclick=\"requestFullScreen\" style=\"position: relative\">\n                <video ref=\"video\" style=\"width: 100%; border: 1px solid gray\" :src=\"src\"></video>\n                <span style=\"background: black; position: absolute; right: 10px; bottom: 15px; color: white; font-family: 'Courier New'\">\n                    <span ref=\"currenttime\" style=\"color: yellow\"></span>\n                    <span ref=\"totaltime\"></span>\n                </span>\n            </div>\n            <p>\n                <button @click=\"deleteVideo\" class=\"pull-right btn btn-xs btn-danger\"><i class=\"fa fa-trash\"></i></button>\n                <a target=\"_blank\" :href=\"controlUrl\"><strong v-text=\"nametext\"></strong></a>\n                <br>\n                <small><i v-text=\"mtimetext\"></i></small>\n            </p>\n        </div>\n    </div>\n</script>\n<script>\n    // self defined video component\n    Vue.component('videoplayer', {\n        template: '#video-component',\n        props: [\"src\", \"mtime\", \"udid\", \"name\"],\n        computed: {\n            mtimetext: function () {\n                var m = moment.unix(this.mtime)\n                return m.format(\"GGGG/MM/DD HH:mm:ss\")\n            },\n            nametext: function () {\n                return this.name || \"unknown\";\n            },\n            controlUrl: function () {\n                return location.protocol + \"//\" + location.host + \"/devices/\" + this.udid + \"/remote\"\n            }\n        },\n        data: function () {\n            return {\n                videoElement: null\n            }\n        },\n        mounted: function () {\n            var v = this.$refs.video;\n            var t = this.$refs.totaltime;\n            var c = this.$refs.currenttime;\n            // v.play();\n            // video refs: https://www.w3schools.com/tags/ref_av_dom.asp\n            function formatDuration(duration) {\n                var m = moment.duration(duration, 'seconds')\n                if (duration > 3600) {\n                    return moment.utc(m.asMilliseconds()).format(\"HH:mm:ss\");\n                }\n                return moment.utc(m.asMilliseconds()).format(\"mm:ss\");\n            }\n\n            v.addEventListener('mouseover', function () {\n                this.controls = true;\n                t.hidden = true;\n                c.hidden = true;\n            })\n            v.addEventListener('mouseout', function () {\n                this.controls = false;\n                t.hidden = false;\n                c.hidden = false;\n            })\n            v.addEventListener('timeupdate', function () {\n                c.innerText = formatDuration(this.currentTime);\n            })\n            v.addEventListener('canplay', function () {\n                t.innerText = formatDuration(this.duration)\n            })\n            v.addEventListener('click', function () {\n                if (this.paused) {\n                    this.play();\n                } else {\n                    this.pause();\n                }\n            })\n\n            document.addEventListener('keydown', function (e) {\n                // console.log(e.keyCode)\n                var v = document.webkitFullscreenElement\n                if (v && e.keyCode == 32) { // space key pressed\n                    v.paused ? v.play() : v.pause()\n                }\n            })\n        },\n        methods: {\n            requestFullScreen: function () {\n                var v = this.$refs.video;\n                if (!document.webkitFullscreenElement) {\n                    v.webkitRequestFullscreen()\n                } else {\n                    v.webkitExitFullscreen()\n                }\n            },\n            deleteVideo: function () {\n                if (!confirm(\"Delete?\")) {\n                    return;\n                }\n                this.$el.parentNode.removeChild(this.$el)\n                $.ajax({\n                    url: this.src.replace(/^static/, \"\"), // remove prefix\n                    method: \"delete\",\n                    dataType: \"json\",\n                }).then(function (ret) {\n                    console.log(ret)\n                })\n            }\n        }\n    })\n\n    new Vue({\n        el: \"#app\",\n        data: {\n            videos: [],\n            groupedVideos: [],\n        },\n        mounted: function () {\n            $.ajax({\n                url: \"/videos\",\n                method: \"get\",\n                contentType: \"application/json\",\n                dataType: \"json\"\n            }).then(function (ret) {\n                console.log(ret)\n                this.videos = ret.data;\n\n                var maps = {};\n                var groupedVideos = [];\n                // var currentList;\n                ret.data.forEach(function (v) {\n                    var date = moment.unix(v.mtime).format(\"GGGG/MM/DD\");\n                    if (!(date in maps)) {\n                        groupedVideos.push({\n                            date: date,\n                            videos: maps[date] = []\n                        });\n                    }\n                    maps[date].push(v)\n                })\n                this.groupedVideos = groupedVideos;\n                console.log(this.groupedVideos)\n            }.bind(this))\n        }\n    })\n</script>\n\n</html>"
  },
  {
    "path": "scripts/levenshtein.py",
    "content": "# 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((len(a), len(b)), dtype=np.int)\n    steps = np.zeros((len(a), len(b)), dtype=np.int)\n    array[0] = np.arange(len(b))\n    array[:, 0] = np.arange(len(a))\n    steps[0] = 1\n\n    for i in range(1, len(a)):\n        for j in range(1, len(b)):\n            sub_cost = 0 if a[i] == b[j] else 2\n            minval = array[i-1, j-1]+sub_cost # substitution\n            steps[i, j] = 2\n            \n            del_cost = 1 if a[i] != ' ' else 0\n            ins_cost = 1 if b[j] != ' ' else 0\n            # delete, insertion\n            for step, val in enumerate((array[i-1, j]+del_cost, array[i, j-1]+ins_cost)):\n                if minval > val:\n                    steps[i, j] = step\n                    minval = val\n            array[i, j] = minval\n    \n    # print(array)\n    # print(steps)\n    return array, steps\n\n\ndef backward(steps, a, b):\n    assert len(steps) == len(a)+1\n    assert len(steps[0]) == len(b)+1\n\n    x = len(a)\n    y = len(b)\n\n    ss = []\n    while x > 0 or y > 0:\n        step = steps[x, y]\n        # print(x, y)\n        if step == 0:\n            x, y = x-1, y\n            ss.append(['d', a[x], ' '])\n        elif step == 1:\n            x, y = x, y-1\n            ss.append(['i', ' ', b[y]])\n        elif step == 2:\n            x, y = x-1, y-1\n            if a[x] == b[y]:\n                ss.append(['=', a[x], b[y]])\n            else:\n                ss.append(['r', a[x], b[y]])\n    # for i in range(len(ss)):\n        # print()\n    for line in map(list, zip(*ss)):\n        print(''.join(reversed(line)))\n\n\ndef main():\n    a = \"sitting\"\n    b = \"kitten\"\n    # a, b = \"Monday\", \"Hello world\"\n    array, steps = match_string(a, b)\n    backward(steps, a, b)\n\n\ndef match_distance(a, b):\n    array, steps = match_string(a, b)\n    if False:\n        backward(steps, a, b)\n    return int(array[-1, -1])\n\n\nif __name__ == '__main__':\n    print(match_distance('sitting', 'kitten'))"
  },
  {
    "path": "scripts/update_coverage_umeng.py",
    "content": "# coding: utf-8\n#\n'''\n用途：更新ATX-Server上的覆盖率数据（来源umeng）\n\n{\n    \"equipment\": {\n        \"item22\": {\n            \"android\": {\n                \"2017-10\": {\n                    \"brandRankData\": [\n                        {\n                            \"name\": \"OPPO\",\n                            \"value\": 18.57,\n                            \"children\": [\n                                {\n                                    \"name\": \"OPPO R9\",\n                                    \"value\": 2.15\n                                }\n                            ]\n                        },\n                        {\n                            \"name\": \"vivo\",\n                            \"value\": 16.84,\n                            \"children\": []\n                        }\n                    ],\n                    \"phoneTypeData\": [\n                        {\n                            \"name\": \"OPPO R9\",\n                            \"value\": 2.15,\n                            \"trend\": \"up\"\n                        }\n                    ]\n                },\n                \"2017-09\": {},\n                \"2017-08\": {},\n                \"2017-07\": {},\n                \"2017-06\": {},\n                \"2017-05\": {},\n                \"2017-04\": {},\n                \"2017-03\": {}\n            },\n            \"ios\": {\n                \"2017-10\": {},\n                \"2017-09\": {},\n                \"2017-08\": {},\n                \"2017-07\": {},\n                \"2017-06\": {},\n                \"2017-05\": {},\n                \"2017-04\": {},\n                \"2017-03\": {}\n            }\n        }\n    }\n}\n\n'''\nimport requests\nimport json\nimport levenshtein\n\n\ndef get_umeng_data():\n    umeng_data = requests.get('http://compass.umeng.com/data/equipmentItem2_2.json').json()\n    android_datas = umeng_data['equipment']['item22']['android']\n    keys = sorted(android_datas.keys(), reverse=True)\n    key = keys[0]\n    print(\"Year-month:\", key)\n    rank_data = android_datas[key]['brandRankData']\n    data = {}\n\n    for brand in rank_data:\n        for cov in brand['children']:\n            name, value = cov['name'], cov['value']\n            if name.startswith('畅玩'):\n                name = '荣耀'+name\n            if name.startswith('畅享'):\n                name = '华为'+name\n            data[name] = value\n    \n    rank_data = android_datas[keys[1]]['brandRankData']\n    for brand in rank_data:\n        for cov in brand['children']:\n            name, value = cov['name'], cov['value']\n            if name.startswith('畅玩'):\n                name = '荣耀'+name\n            if name.startswith('畅享'):\n                name = '华为'+name\n            if name not in data:\n                data[name] = value\n    return data\n\n\ndef main():\n    data = get_umeng_data()\n    keys = data.keys()\n\n    for device in requests.get('http://10.246.46.160:8200/list').json():\n        product = device.get('product') or {}\n        name = product.get('name').lower()\n        if not name:\n            continue\n        \n        udid = device.get('udid')\n\n        # 查找最匹配的名字\n        mindist = 1e9\n        bestkey = ''\n        for key in keys:\n            dist = levenshtein.match_distance(name, key.lower())\n            if mindist > dist:\n                mindist = dist\n                bestkey = key\n        \n        # 全部匹配自动更新\n        if mindist == 0:\n            print(name, \"==\", bestkey, \">>\", mindist, data[bestkey])\n            requests.put('http://10.246.46.160:8200/devices/'+udid+'/product', data=json.dumps({\n                'id': product['id'], \n                'coverage': data[bestkey]\n            }))\n        else:\n            print(\"?\", name, \"==\", bestkey, \">>\", mindist, data[bestkey])\n            confirm = input(\"Confirm update [Y/n]\")\n            if confirm == '':\n                # print('update name')\n                # requests.post('http://10.246.46.160:8200/devices/'+udid+'/info', data=json.dumps({'name': bestkey}))\n                print('update coverage')\n                requests.put('http://10.246.46.160:8200/devices/'+udid+'/product', data=json.dumps({\n                    'id': product['id'], \n                    'name': bestkey,\n                    'coverage': data[bestkey]\n                }))\n\n\nif __name__ == '__main__':\n    main()\n    \n"
  },
  {
    "path": "templates/edit.html",
    "content": "<!DOCTYPE html>\n<html lang=\"cn\">\n\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <meta http-equiv=\"X-UA-Compatible\" content=\"ie=edge\">\n  <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css\">\n  <script src=\"https://cdn.jsdelivr.net/npm/vue@2.5.3/dist/vue.js\"></script>\n  <script src=\"https://cdn.jsdelivr.net/npm/jquery@3.2.1/dist/jquery.min.js\"></script>\n  <title>Product</title>\n</head>\n\n<body>\n  <div id=\"app\">\n    <h1>\n      <a href=\"/\"><i class=\"fa fa-arrow-left\"></i></a> 手机信息修改</h1>\n    <div>\n      <p>UDID: {{udid}}</p>\n      <dl>\n        <dt>Serial</dt>\n        <dd>{{device.serial}}</dd>\n        <dt>CPU</dt>\n        <dd>{{device.cpu && device.cpu.hardware}}</dd>\n        <dt>Memory</dt>\n        <dd>{{device.memory && device.memory.around}}</dd>\n      </dl>\n      <h4>Product</h4>\n      <p>\n        <select v-model=\"product_id\" @change=\"productChange(product_id)\">\n          <option v-for=\"v in products\" v-bind:value=\"v.id\">{{v.id}}</option>\n        </select>\n      </p>\n      <p>ID: {{product.id}}</p>\n      <p>Brand: {{product.brand}}</p>\n      <p>\n        Model: {{product.model}}\n        <a target=\"_blank\" :href='\"https://www.baidu.com/s?wd=\"+device.model'>百度</a>\n        <a target=\"_blank\" :href='\"https://www.google.com/search?q=\" + device.model'>Google</a>\n      </p>\n      <p>Name: <input type=\"text\" v-model.trim=\"product.name\"></p>\n      <p>CPU: <input type=\"text\" v-model.trim=\"product.cpu\"></p>\n      <p>GPU: <input type=\"text\" v-model.trim=\"product.gpu\"></p>\n      <p>Link: <input type=\"text\" v-model.trim=\"product.link\"></p>\n      <p>Coverage: <input type=\"number\" v-model.number=\"product.coverage\">%</p>\n      <button class=\"btn btn-default\" @click.prevent=\"update\">更新</button>\n      <button class=\"btn btn-default\" @click.prevent=\"updateAndBack\">更新并返回首页</button> {{message}}\n    </div>\n  </div>\n\n  <script>\n    var udid = \"[[.]]\"; // device udid\n\n    new Vue({\n      el: \"#app\",\n      data: {\n        udid: \"\",\n        device: {},\n        product: {\n          id: \"1242o3iryodjifasdf\",\n          name: \"some-product\"\n        },\n        product_id: '',\n        products: [],\n        message: '',\n      },\n      mounted: function () {\n        this.udid = udid;\n        $.ajax({\n          url: \"/devices/\" + udid + \"/info\",\n        })\n          .then(function (ret) {\n            console.log(ret)\n            this.device = ret;\n            this.product = ret.product || {};\n            this.product_id = this.product.id;\n            return ret;\n          }.bind(this))\n          .then(function (ret) {\n            return $.ajax({\n              url: \"/products/\" + ret.brand + \"/\" + ret.model\n            })\n          })\n          .then(function (ret) {\n            console.log(\"products:\", ret)\n            this.products = ret;\n          }.bind(this))\n          .then(function (ret) {\n            if (!this.product_id && this.products.length == 1) {\n              this.product = this.products[0];\n              this.product_id = this.product.id;\n            }\n            if (!this.product.cpu && this.device.cpu) {\n              this.product.cpu = this.device.cpu.hardware;\n            }\n          }.bind(this))\n\n        console.log(udid)\n      },\n      methods: {\n        updateAndBack: function () {\n          this.update().then(function (ret) {\n            window.location = \"/\";\n          })\n        },\n        update: function () {\n          console.log(\"update\")\n          this.message = \"updating\"\n          return $.ajax({\n            url: \"/devices/\" + udid + \"/product\",\n            method: \"put\",\n            data: JSON.stringify(this.product),\n          })\n            .then(function (ret) {\n              console.log(ret)\n              this.message = \"Update success !!\"\n            }.bind(this))\n        },\n        productChange: function (product_id) {\n          console.log(product_id)\n          this.product = this.products.filter(function (p) {\n            return p.id == product_id\n          })[0]\n        }\n      }\n    })\n  </script>\n</body>\n\n</html>"
  },
  {
    "path": "templates/index.html",
    "content": "<!DOCTYPE html>\n<html>\n\n<head>\n  <meta charset=\"utf-8\">\n  <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\n  <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css\">\n  <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css\">\n  <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css\">\n  <link rel=\"stylesheet\" href=\"/assets/style.css\">\n\n  <script src=\"https://cdn.jsdelivr.net/npm/jquery@3.2.1/dist/jquery.min.js\"></script>\n  <script src=\"https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/js/bootstrap.min.js\"></script>\n  <script src=\"https://cdn.jsdelivr.net/npm/vue@2.5.3/dist/vue.js\"></script>\n  <script src=\"https://cdn.jsdelivr.net/npm/moment@2.20.1/moment.min.js\"></script>\n  <script src=\"https://cdn.jsdelivr.net/npm/clipboard@2.0.1/dist/clipboard.min.js\"></script>\n  <script src=\"https://cdn.jsdelivr.net/npm/notifyjs-browser@0.4.2/dist/notify.min.js\"></script>\n  <style>\n  </style>\n</head>\n\n<body>\n  <nav class=\"navbar navbar-default\">\n    <div class=\"navbar-header\">\n      <button type=\"button\" class=\"navbar-toggle collapsed\" data-toggle=\"collapse\" data-target=\"#bs-example-navbar-collapse-1\"\n        aria-expanded=\"false\">\n        <span class=\"sr-only\">Toggle navigation</span>\n        <span class=\"icon-bar\"></span>\n        <span class=\"icon-bar\"></span>\n        <span class=\"icon-bar\"></span>\n      </button>\n      <a class=\"navbar-brand\" href=\"/\">\n        <b>ATX</b> -\n        <strong>Server</strong>\n      </a>\n    </div>\n    <div class=\"collapse navbar-collapse\" id=\"bs-example-navbar-collapse-1\">\n      <p class=\"navbar-text\"></p>\n      <ul class=\"nav navbar-nav\">\n        <li class=\"active\">\n          <a href=\"/\">\n            <i class=\"fa fa-list-alt\"></i> 设备列表\n          </a>\n        </li>\n        <li>\n          <a href=\"/providers\">\n            <i class=\"fa fa-bandcamp\"></i> 节点列表\n          </a>\n        </li>\n        <li>\n          <a href=\"/videos\">\n            <i class=\"fa fa-film\"></i> 视频列表</a>\n        </li>\n      </ul>\n    </div>\n  </nav>\n  <div class=\"container-fluid\">\n    <div class=\"row\">\n      <div class=\"col-md-12\" v-if=\"errmsg\">\n        <p class=\"color-red\">{{errmsg}}\n          <button class=\"btn btn-warning btn-xs\" @click=\"loadWatches\">点击重连</button>\n        </p>\n      </div>\n    </div>\n    <form class=\"form-inline hidden\" v-on:submit.prevent>\n      <div class=\"form-group\">\n        <strong>批量操作</strong>\n        <button @click.prevent=\"batchUnlock\" class=\"btn btn-sm btn-default\">\n          <i class=\"fa fa-unlock\"></i> 解锁</button>\n        <button @click.prevent=\"batchLock\" class=\"btn btn-sm btn-default\">\n          <i class=\"fa fa-lock\"></i> 锁屏</button>\n        <button @click.prevent=\"batchIdentify('red')\" class=\"btn btn-sm btn-default\">\n          <i class=\"fa fa-location-arrow\"></i> 红色</button>\n        <button @click.prevent=\"batchIdentify('black')\" class=\"btn btn-sm btn-default\">\n          <i class=\"fa fa-location-arrow\"></i> 黑色</button>\n        <!-- <button @click.prevent=\"batchPower\" class=\"btn btn-sm btn-danger\"><i class=\"fa fa-power-off\"></i> 关机</button> -->\n        <button @click.prevent=\"setProperty\" class=\"btn btn-sm btn-info\">资产设置</button>\n      </div>\n    </form>\n    <form class=\"form-inline\" v-on:submit.prevent>\n      <div class=\"form-group\">\n        <div class=\"input-group input-group-sm\">\n          <!-- <div class=\"input-group-addon\">Search</div> -->\n          <!-- autofocus is not working, wired -->\n          <div class=\"input-group-addon\" title=\"在线设备\">\n            <i class=\"fa fa-meh-o\"></i>\n            <strong v-text=\"onlineCount\"></strong>\n          </div>\n          <div class=\"input-group-addon\" title=\"已标记设备\">\n            <i class=\"fa fa-smile-o\"></i>\n            <strong v-text=\"count(true, true, null)\"></strong>\n          </div>\n          <div class=\"input-group-addon\" title=\"未标记设备\">\n            <i class=\"fa fa-frown-o\"></i>\n            <strong v-text=\"count(true, false, null)\"></strong>\n          </div>\n          <div class=\"input-group-addon\" title=\"离线设备\">\n            <i class=\"fa fa-snowflake-o\"></i>\n            <strong v-text=\"count(false, null, null)\"></strong>\n          </div>\n          <input type=\"text\" v-model=\"searchText\" placeholder=\"搜索\" class=\"form-control\" v-focus>\n          <div class=\"input-group-addon\">\n            <i class=\"fa fa-search\"></i>\n          </div>\n        </div>\n      </div>\n    </form>\n    <div class=\"table-responsive\">\n      <table class=\"table table-hover table-condensed\">\n        <thead>\n          <tr>\n            <th class=\"hidden\">#</th>\n            <th>@</th>\n            <th class=\"text-center\">:)</th>\n            <th class=\"hidden-xs\">\n              <u>IP</u>\n            </th>\n            <th class=\"hidden-xs\">\n              <u>Serial</u>\n            </th>\n            <th class=\"hidden-xs\">\n              <u>资产编号</u>\n            </th>\n            <th>\n              <u>名字</u>\n            </th>\n            <th class=\"hidden-xs\">\n              <u>Brand</u>\n            </th>\n            <th class=\"hidden\">Model</th>\n            <th class=\"hidden-xs\">\n              <u>Version</u>(SDK)</th>\n            <th class=\"hidden\">CPU</th>\n            <th class=\"hidden\">MAC</th>\n            <th class=\"hidden\">Memory</th>\n            <th class=\"hidden-xs\">Agent</th>\n            <th>\n              <span class=\"hidden-xs\">电量</span>\n              <i class=\"fa fa-battery\"></i>\n            </th>\n            <th>\n              <span class=\"hidden-xs\">温度</span>\n              <i class=\"fa fa-thermometer\"></i>\n            </th>\n            <th class=\"hidden-xs\">上线时间</th>\n            <th>备注</th>\n            <th class=\"hidden-xs\">更多</th>\n            <!-- <th class=\"hidden-xs\">Other</th> -->\n          </tr>\n        </thead>\n        <tbody>\n          <tr v-for=\"d in filteredDevices\" :key=\"d.udid\" v-bind:class='{\"offline\": !d.present}'>\n            <td class=\"hidden\">\n              <input type=\"checkbox\" disabled=true>\n            </td>\n            <td>\n              <div @click=\"d.present && toggleReady(d)\">\n                <span v-show=\"d.ready\" class=\"fa fa-thumbs-o-up\" v-bind:class='{\"color-green\": d.present}'></span>\n                <span v-show=\"!d.ready\" class=\"fa fa-thumbs-o-down\"></span>\n              </div>\n            </td>\n            <td class=\"text-center\">\n              <span v-if=\"!d.present\" class=\"color-yellow\">Offline</span>\n              <span v-else>\n                <a v-if=\"!d.using\" target=\"_blank\" class=\"hidden-xs btn btn-xs btn-link\" :href='\"/devices/\"+d.udid+\"/remote\"'>\n                  Use\n                </a>\n                <span v-if=\"!d.using\" class=\"visible-xs\" style=\"cursor: pointer\" @click=\"d.present && identify(d)\">\n                  <!-- locate idle device-->\n                  <i class=\"fa fa-location-arrow\" :class=\"{'fa-spin': d.identifying}\"></i>\n                </span>\n                <span class=\"hidden-xs color-red\" v-if=\"d.using\" @dblclick.alt=\"releaseDevice(d.udid)\">{{d.owner && d.owner.ip}}</span>\n                <span class=\"visible-xs color-red\" v-if=\"d.using\">Busy</span>\n              </span>\n            </td>\n            <td class=\"hidden-xs\">\n              <span v-text=\"d.ip\"></span>\n            </td>\n            <td class=\"hidden-xs\" style=\"font-family: 'Courier New', Courier, monospace\">\n              {{d.serial | shortString(10)}}\n              <!-- <span v-text='d.serial && d.serial.slice(4)+\"...\"' class=\"visible-xs\"></span> -->\n            </td>\n            <td class=\"hidden-xs\" v-text=\"d.propertyId\"></td>\n            <td>\n              <span v-text=\"(d.product || {}).name\"></span>\n              <a :href=\"'/devices/'+d.udid+'/edit'\" class=\"fa fa-edit\"></a>\n            </td>\n            <td class=\"hidden-xs\" v-text=\"d.brand\"></td>\n            <td class=\"hidden\" v-text=\"d.model\"></td>\n            <td class=\"hidden-xs\">{{d.version}}({{d.sdk}})</td>\n            <td class=\"hidden\">\n              <span v-if=\"d.cpu\">{{d.cpu.hardware.replace('Qualcomm Technologies, Inc', '骁龙')}}</span>\n            </td>\n            <td class=\"hidden text-uppercase\">\n              <span v-text=\"d.hwaddr\"></span>\n            </td>\n            <td class=\"hidden\">{{d.memory && d.memory.around}}</td>\n            <td class=\"hidden-xs\">\n              <span style=\"cursor: pointer\" @click=\"d.present && identify(d)\">\n                <i class=\"fa fa-location-arrow\" :class=\"{'fa-spin': d.identifying}\"></i>\n                {{d.agentVersion}}\n              </span>\n            </td>\n            <td>\n              <span v-if=\"d.battery\" :class=\"{'color-red': d.battery.level < 30}\">\n                {{d.battery.level}}%\n                <span class=\"hidden-xs\" v-if=\"d.present && d.battery\">\n                  <i class=\"fa fa-usb\" v-if=\"d.provider\"></i>\n                  <!-- 2: charging -->\n                  <i class=\"fa fa-plug color-rest\" v-else-if=\"d.battery.status == 2\"></i>\n                  <i class=\"fa-fix-height fa\" v-else :class='\"fa-battery-\" + Math.floor(d.battery.level/25)'></i>\n                </span>\n              </span>\n            </td>\n            <td>\n              <!-- temperature-->\n              <span v-if=\"d.battery\" :class=\"{'color-red': d.battery.temperature > 400}\">\n                {{d.battery.temperature/10}}℃\n              </span>\n            </td>\n            <td class=\"hidden-xs\">\n              {{d.presenceChangedAt | timeSince}}\n            </td>\n            <td>\n              <editable-span :content=\"d.notes\" @change=\"changeNotes(d, $event)\" />\n            </td>\n            <td class=\"hidden-xs\">\n              <!-- <a target=\"_blank\" :href='\"http://\"+d.ip+\":7912/remote\"'><i class=\"fa fa-television\"></i></a> -->\n              <a class=\"hidden-xs\" target=\"_blank\" :href='\"http://\"+d.ip+\":7912/term\"'>\n                <i class=\"fa fa-terminal\"></i>\n              </a>\n              <button class=\"hidden-xs btn btn-link btn-copy btn-xs\" :data-clipboard-text=\"d.udid\">\n                <i class=\"fa fa-clipboard\" alt=\"Copy to clipboard\"></i>\n              </button>\n              <a target=\"_blank\" :href='\"http://\"+d.ip+\":7912/screenshot\"'>\n                <i class=\"fa fa-camera\" alt=\"Take screenshot\"></i>\n              </a>\n              <a class=\"hidden-xs btn btn-link btn-copy btn-xs\" target=\"_blank\" :href='\"http://\"+d.ip+\":7912/remote\"'>\n                <i class=\"fa fa-eye\" alt=\"Remote control\"></i>\n              </a>\n            </td>\n          </tr>\n        </tbody>\n      </table>\n    </div>\n  </div>\n  <script src='[[\"/assets/vue-components.js\" | urlhash]]'></script>\n  <script>\n    new Vue({\n      el: \".container-fluid\",\n      data: {\n        devices: [],\n        errmsg: \"\",\n        searchText: \"\",\n      },\n      directives: {\n        focus: {\n          inserted: function (el) {\n            el.focus();\n          }\n        }\n      },\n      filters: {\n        timeSince: function (value) {\n          return moment(value).fromNow()\n        },\n        shortString: function (value, length) {\n          length = parseInt(length || \"10\", 10);\n          if (!value || value.length < length) {\n            return value;\n          }\n          var preLen = Math.max(1, Math.floor(length / 2 - 2));\n          var postLen = length - preLen - 2;\n          return value.substr(0, preLen) + '..' + value.substr(-postLen);\n        }\n      },\n      mounted: function () {\n        this.loadWatches()\n\n        var clipboard = new ClipboardJS(\".btn-copy\")\n        clipboard.on('success', function (e) {\n          $(e.trigger).notify(\"Copied!\", {\n            className: \"success\",\n            position: \"right\",\n            autoHideDelay: 800\n          })\n        })\n      },\n      computed: {\n        filteredDevices: function () {\n          var searchText = this.searchText.trim();\n          if (!searchText.length) {\n            return this.onlineDevices;\n          }\n          var keywords = searchText.split(/\\s+/).map(function (key) {\n            return key.toLowerCase()\n          })\n          return this.onlineDevices.filter(function (d) {\n            return keywords.every(function (key) {\n              // searched properties\n              return [d.propertyId, d.ip, d.serial, d.brand, d.version, (d.product || {}).name].some(function (value) {\n                return value && (\"\" + value).toLowerCase().includes(key)\n              })\n            })\n          })\n        },\n        onlineDevices: function () {\n          return this.devices.filter(function (d) {\n            return d.present;\n          })\n        },\n        onlineCount: function () {\n          return this.filteredDevices.filter(function (d) {\n            return d.present\n          }).length;\n        },\n      },\n      methods: {\n        releaseDevice: function (udid) {\n          $.ajax({\n            url: \"/devices/\" + udid + \"/reserved\",\n            method: \"delete\"\n          })\n        },\n        count: function (present, ready, using) {\n          return this.filteredDevices.filter(function (d) {\n            if (present != null && d.present != present) {\n              return false;\n            }\n            if (ready != null && d.ready != ready) {\n              return false;\n            }\n            if (using != null && d.using != using) {\n              return false;\n            }\n            return true;\n          }).length\n        },\n        loadWatches: function () {\n          // init data\n          this.loadDevices()\n          this.errmsg = \"Connecting\"\n\n          var ws = new WebSocket(\"ws://\" + location.host + \"/feeds\")\n          var key = setInterval(function () {\n            ws.send(\"ping\")\n          }, 5000);\n          ws.onopen = function () {\n            console.log(\"websocket connected\")\n            this.errmsg = \"\";\n          }.bind(this)\n          ws.onmessage = function (evt) {\n            var jdata = JSON.parse(evt.data);\n            if (jdata.error) {\n              this.errmsg = jdata.error;\n              return\n            }\n            var dataNew = jdata.new,\n              dataOld = jdata.old;\n            if (dataNew && dataOld) {\n              $.ajax({\n                url: \"/devices/\" + dataNew.udid + \"/info\",\n                dataType: \"json\",\n              }).then(function (ret) {\n                this.devices = this.devices.map(function (d) {\n                  if (d.udid == dataNew.udid) {\n                    return ret;\n                  }\n                  return d;\n                })\n              }.bind(this))\n            } else {\n              this.loadDevices();\n            }\n            // console.log(\"websocket recv:\", evt.data)\n          }.bind(this)\n          ws.onclose = function (evt) {\n            console.log(\"websocket closed\")\n            clearInterval(key)\n            this.errmsg = \"Server connection closed. \" + this.errmsg;\n          }.bind(this)\n        },\n        loadDevices: function () {\n          return $.ajax({\n            url: \"/list\",\n          }).then(function (ret) {\n            this.devices = ret.map(function (d) {\n              d.identifying = false;\n              return d\n            })\n          }.bind(this))\n        },\n        identify: function (d) {\n          d.identifying = true;\n          $.ajax({\n            url: \"/devices/\" + d.udid + \"/shell\",\n            method: \"post\",\n            data: {\n              command: \"input keyevent HOME && am start -W --user 0 -a com.github.uiautomator.ACTION_IDENTIFY -e theme red\"\n            }\n          })\n            .always(function () {\n              d.identifying = false\n            }.bind(this))\n        },\n        release: function (d) {\n          $.ajax({\n            url: \"/devices/\" + d.udid + \"/reserved\",\n            method: \"delete\",\n          }).then(this.loadDevices)\n        },\n        toggleReady: function (d) {\n          d.ready = !d.ready;\n          this.updateDeviceInfo(d.udid, { ready: d.ready });\n        },\n        changeNotes: function (d, notes) {\n          d.notes = notes + \"..\";\n          this.updateDeviceInfo(d.udid, { notes: notes })\n        },\n        updateDeviceInfo: function (udid, info) {\n          return $.ajax({\n            url: \"/devices/\" + udid + \"/info\",\n            method: \"post\",\n            data: JSON.stringify(info),\n          })\n        },\n        batchIdentify: function (theme) {\n          $.ajax({\n            url: \"/api/v1/batch/shell\",\n            method: \"post\",\n            data: {\n              command: \"input keyevent BACK && am start -W -a com.github.uiautomator.ACTION_IDENTIFY -e theme \" + theme,\n            }\n          })\n        },\n        batchLock: function () {\n          $.ajax({\n            url: \"/api/v1/batch/lock\",\n            method: \"post\",\n          })\n        },\n        batchUnlock: function () {\n          $.ajax({\n            url: \"/api/v1/batch/unlock\",\n            method: \"post\",\n          })\n        },\n        batchPower: function () {\n          $.ajax({\n            url: \"/api/v1/batch/shell\",\n            method: \"post\",\n            data: {\n              command: \"reboot -p\"\n            }\n          })\n        },\n        setProperty: function () {\n          $.ajax({\n            url: \"/api/v1/batch/shell\",\n            method: \"post\",\n            data: {\n              command: \"am start -a android.intent.action.VIEW -d http://\" + location.host + \"/property\",\n            }\n          })\n        }\n      }\n    })\n  </script>\n</body>\n\n</html>"
  },
  {
    "path": "templates/property.html",
    "content": "<html>\n\n<head>\n  <meta charset=\"utf-8\">\n  <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\n  <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css\">\n  <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css\">\n  <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css\">\n  <link rel=\"stylesheet\" href=\"/assets/style.css\">\n\n  <script src=\"https://cdn.jsdelivr.net/npm/jquery@3.2.1/dist/jquery.min.js\"></script>\n  <script src=\"https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/js/bootstrap.min.js\"></script>\n  <script src=\"https://cdn.jsdelivr.net/npm/vue@2.5.3/dist/vue.js\"></script>\n  <script src=\"https://cdn.jsdelivr.net/npm/moment@2.20.1/moment.min.js\"></script>\n  <style>\n\n  </style>\n</head>\n\n<body>\n  <div class=\"container-fluid\">\n    <form class=\"form\" method=\"post\" action=\"/property\">\n      <h2>资产编号修改</h2>\n      <div class=\"form-group\">\n        <label>当前编号</label>\n        <input class=\"form-control\" type=\"text\" disabled=true value=\"[[.]]\">\n      </div>\n      <div class=\"form-group\">\n        <label>修改编号</label>\n        <input class=\"form-control\" type=\"text\" name=\"id\" placeholder=\"非HIH-PHO-开头填写在这里\">\n      </div>\n      <div class=\"form-group\">\n        <div class=\"input-group\">\n          <span class=\"input-group-addon\">HIH-PHO-</span>\n          <input class=\"form-control\" type=\"number\" name=\"id_number\" autofocus=\"autofocus\">\n        </div>\n      </div>\n      <button class=\"btn btn-default\">提交</button>\n    </form>\n  </div>\n</body>\n\n</html>"
  },
  {
    "path": "templates/providers.html",
    "content": "<!DOCTYPE html>\n<html>\n\n<head>\n    <meta charset=\"utf-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\n    <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css\">\n    <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css\">\n    <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css\">\n    <link rel=\"stylesheet\" href=\"/assets/style.css\">\n\n    <script src=\"https://cdn.jsdelivr.net/npm/jquery@3.2.1/dist/jquery.min.js\"></script>\n    <script src=\"https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/js/bootstrap.min.js\"></script>\n    <script src=\"https://cdn.jsdelivr.net/npm/vue@2.5.3/dist/vue.js\"></script>\n    <script src=\"https://cdn.jsdelivr.net/npm/moment@2.20.1/moment.min.js\"></script>\n    <script src=\"https://cdn.jsdelivr.net/npm/clipboard@2.0.1/dist/clipboard.min.js\"></script>\n    <script src=\"https://cdn.jsdelivr.net/npm/notifyjs-browser@0.4.2/dist/notify.min.js\"></script>\n    <style>\n        .color-green {\n            color: green;\n        }\n\n        .cursor {\n            cursor: pointer;\n        }\n    </style>\n</head>\n\n<body>\n    <nav class=\"navbar navbar-default\">\n        <div class=\"navbar-header\">\n            <button type=\"button\" class=\"navbar-toggle collapsed\" data-toggle=\"collapse\" data-target=\"#bs-example-navbar-collapse-1\"\n                aria-expanded=\"false\">\n                <span class=\"sr-only\">Toggle navigation</span>\n                <span class=\"icon-bar\"></span>\n                <span class=\"icon-bar\"></span>\n                <span class=\"icon-bar\"></span>\n            </button>\n            <a class=\"navbar-brand\" href=\"/\">\n                <b>ATX</b> -\n                <strong>Server</strong>\n            </a>\n        </div>\n        <div class=\"collapse navbar-collapse\" id=\"bs-example-navbar-collapse-1\">\n            <p class=\"navbar-text\"></p>\n            <ul class=\"nav navbar-nav\">\n                <li>\n                    <a href=\"/\">\n                        <i class=\"fa fa-list-alt\"></i> 设备列表\n                    </a>\n                </li>\n                <li class=\"active\">\n                    <a href=\"/providers\">\n                        <i class=\"fa fa-bandcamp\"></i> 节点列表\n                    </a>\n                </li>\n            </ul>\n        </div>\n    </nav>\n    <div class=\"container-fluid\" id=\"app\">\n        <table class=\"table\">\n            <thead>\n                <tr>\n                    <th>Present ({{presentCount}})</th>\n                    <th>IP</th>\n                    <th>Notes</th>\n                    <th>Uptime</th>\n                    <th>Devices</th>\n                    <th>ID</th>\n                </tr>\n            </thead>\n            <tbody>\n                <tr v-for=\"p in providers\" :key=\"p.id\">\n                    <td>\n                        <i class=\"fa fa-smile-o color-green\" v-if=\"p.present\"></i>\n                    </td>\n                    <td>{{p.ip}}</td>\n                    <td @click=\"updateNotes(p)\">{{p.notes}} <i class=\"cursor fa fa-edit\"></i></td>\n                    <td>\n                        <span v-show=\"p.present\">\n                            {{p.presenceChangedAt | timeSince}}\n                        </span>\n                    </td>\n                    <td>\n                        <i class=\"fa fa-mobile\" v-for=\"d in p.devices\" style=\"padding-right: 5px\"></i>\n                    </td>\n                    <td v-text=\"p.id\"></td>\n                </tr>\n            </tbody>\n        </table>\n    </div>\n    <script>\n        new Vue({\n            el: \"#app\",\n            data: {\n                providers: [],\n            },\n            mounted: function () {\n                $.ajax({\n                    method: \"GET\",\n                    url: \"/providers?json\",\n                }).then(function (ret) {\n                    console.log(ret)\n                    this.providers = ret;\n                }.bind(this))\n            },\n            methods: {\n                updateNotes: function (v) {\n                    var newNotes = window.prompt(\"Notes\", v.notes);\n                    console.log(newNotes, newNotes == null)\n                    if (newNotes === null || newNotes == v.notes) {\n                        return\n                    }\n                    v.notes = newNotes + \"..\";\n                    console.log(\"update notes\", v.notes)\n                    $.ajax({\n                        method: \"PUT\",\n                        url: \"/providers/\" + v.id,\n                        data: JSON.stringify({\n                            \"notes\": newNotes,\n                        })\n                    }).then(function (ret) {\n                        console.log(ret)\n                        v.notes = newNotes;\n                    }.bind(this))\n                }\n            },\n            computed: {\n                presentCount: function () {\n                    return this.providers.filter(function (v) {\n                        return v.present;\n                    }).length;\n                }\n            },\n            filters: {\n                timeSince: function (value) {\n                    return moment(value).fromNow().replace(\" ago\", \"\");\n                }\n            }\n        })\n    </script>\n</body>\n\n</html>"
  },
  {
    "path": "templates/remote.html",
    "content": "<!DOCTYPE html>\n<html>\n\n<head>\n  <meta charset=\"utf-8\" />\n  <title>DRC [[.]]</title>\n  <link rel=\"stylesheet\" href=\"//cdn.jsdelivr.net/bootstrap/3.3.7/css/bootstrap.min.css\">\n  <link rel=\"stylesheet\" href=\"//cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css\">\n  <link rel=\"stylesheet\" href=\"//cdn.jsdelivr.net/bootstrap.select/1.12.2/css/bootstrap-select.min.css\">\n  <link rel=\"stylesheet\" href='[[\"/assets/remote.css\" | urlhash]]'>\n  <link rel=\"stylesheet\" href=\"/assets/bootstrap-tabs.css\">\n  <link rel=\"stylesheet\" href=\"/assets/logcat.css\">\n  <link rel=\"stylesheet\" href=\"/assets/libs/dropzone/dropzone.min.css\">\n  <!-- jstree -->\n  <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/jstree@3.3.4/dist/themes/default/style.min.css\">\n  <style>\n    /* body {\n      background: #EDECEC;\n      padding: 50px\n    } */\n\n    .dropzone {\n      display: block;\n      /*text-align: center;*/\n      border: 2px dashed #666;\n      border-radius: 5px;\n      cursor: pointer;\n      height: 74x;\n      line-height: 70px;\n      font-size: 20px;\n      position: relative;\n    }\n\n    .dropzone.dz-drag-hover {\n      border-style: solid;\n      border-color: #4285F4;\n    }\n  </style>\n</head>\n\n<body>\n  <div id=\"app\">\n    <nav class=\"navbar navbar-default\" style=\"margin-bottom: 0px\">\n      <div class=\"navbar-header\">\n        <button type=\"button\" class=\"navbar-toggle collapsed\" data-toggle=\"collapse\" data-target=\"#bs-example-navbar-collapse-1\"\n          aria-expanded=\"false\">\n          <span class=\"sr-only\">Toggle navigation</span>\n          <span class=\"icon-bar\"></span>\n          <span class=\"icon-bar\"></span>\n          <span class=\"icon-bar\"></span>\n        </button>\n        <a class=\"navbar-brand\" href=\"/\">\n          <b>ATX</b> -\n          <strong>Server</strong>\n        </a>\n      </div>\n      <div class=\"collapse navbar-collapse\" id=\"bs-example-navbar-collapse-1\">\n        <p class=\"navbar-text\">{{deviceInfo.udid}} {{device.ip}}\n          <a target=\"_blank\" v-bind:href='\"http://\"+device.ip+\":\"+device.port+\"/term\"'>\n            终端\n          </a>\n        </p>\n        <form class=\"navbar-form navbar-left\">\n          <button class=\"btn btn-sm btn-default\" @click.prevent=\"toggleScreen\">\n            <i v-if=\"screenWS\" class=\"fa fa-eye\"></i>\n            <i v-else class=\"fa fa-eye-slash\"></i>\n          </button>\n        </form>\n        <p class=\"navbar-text\">\n          <a href=\"javascript:window.close()\">标签页关闭</a>，设备自动释放</p>\n      </div>\n    </nav>\n    <div id=\"upper\">\n      <div id=\"left\">\n        <div class=\"text-center\" style=\"background-color:white\">\n          <!-- notification here -->\n        </div>\n        <section id=\"screen\">\n          <canvas id=\"fgCanvas\" class=\"canvas-fg\" v-bind:style=\"canvasStyle\"></canvas>\n          <canvas id=\"bgCanvas\" class=\"canvas-bg\" v-bind:style=\"canvasStyle\"></canvas>\n          <span class=\"finger finger-0\" style=\"transform: translate3d(200px, 100px, 0px)\"></span>\n          <span class=\"finger finger-1\" style=\"transform: translate3d(200px, 100px, 0px)\"></span>\n          <!-- <img style=\"z-index: 10\" v-if=\"loading\" src=\"/assets/loading.svg\"> -->\n        </section>\n        <section id=\"footer\">\n          <button class=\"btn\" @click=\"keyevent('power')\">\n            <i class=\"fa fa-power-off color-red\"></i> Power</button>\n          <button class=\"btn\" @click=\"keyevent('menu')\">\n            <i class=\"glyphicon glyphicon-menu-hamburger\"></i> Menu</button>\n          <button class=\"btn\" @click=\"keyevent('home')\">\n            <i class=\"fa fa-home\"></i> Home</button>\n          <button class=\"btn\" @click=\"keyevent('back')\">\n            <i class=\"fa fa-chevron-left\"></i> Back</button>\n        </section>\n      </div>\n      <div id=\"vertical-gap1\" class=\"handle vertical-gap\"></div>\n      <div id=\"right\">\n\n        <div class=\"row box\">\n          <div class=\"col-md-12 box\">\n            <!-- Nav tabs -->\n            <div class=\"card\">\n              <ul class=\"nav nav-tabs\" role=\"tablist\">\n                <li role=\"presentation\" @click=\"navtabs.active=v.id; changeTab(v.id)\" :class='{active: v.id == navtabs.active}' v-for=\"v in navtabs.tabs\">\n                  <a :href=\"'#'+v.id\" :aria-controls=\"v.id\" role=\"tab\" data-toggle=\"tab\">{{v.name}}</a>\n                </li>\n                <!-- <li role=\"presentation\"><a href=\"#logcat\" aria-controls=\"logcat\" role=\"tab\" data-toggle=\"tab\">Logcat</a></li> -->\n                <!-- <li role=\"presentation\"><a href=\"#install\" aria-controls=\"install\" role=\"tab\" data-toggle=\"tab\">App install</a></li> -->\n                <!-- <li role=\"presentation\"><a href=\"#settings\" aria-controls=\"settings\" role=\"tab\" data-toggle=\"tab\">Settings</a></li> -->\n                <!-- <li role=\"presentation\"><a href=\"#home\" aria-controls=\"home\" role=\"tab\" data-toggle=\"tab\">TODO</a></li> -->\n                <li v-show='navtabs.active == \"logcat\"' class=\"follow-log\" v-bind:style=\"{'border-color': logcat.follow ? 'green' : 'gray'}\"\n                  @click=\"followLog\">\n                  <span class=\"hover-content\">\n                    <i class=\"glyphicon glyphicon-arrow-down\"></i> Scroll to End of Log</span>\n                </li>\n              </ul>\n\n              <!-- Tab panes -->\n              <div class=\"tab-content\" ref=\"tab_content\" @mousewheel=\"tabScroll\">\n                <tab-item v-on:additem=\"addTabItem\" :active=\"navtabs.active\" tabid=\"home\" name=\"常用\">\n                  <button @click=\"fixRotation\" class=\"btn btn-default btn-sm\">\n                    <span class=\"glyphicon glyphicon-repeat\"></span> 修复旋转</button>\n                  <button @click=\"fixMinicap\" class=\"btn btn-default btn-sm\" disabled=true>\n                    <span class=\"fa fa-legal\"></span> 修复minicap</button>\n                  <div style=\"padding-top: 10px\" v-text=\"fixConsole\"></div>\n                  <p>\n                    安装\n                    <a href=\"https://chrome.google.com/webstore/detail/tab-capturing-sharing/pcnepejfgcmidedoimegcafiabjnodhk\">Chrome 插件 Tap Sharing</a> 让其他人也能看到你的操作\n                  </p>\n\n                  <div class=\"panel panel-default\">\n                    <div class=\"panel-heading\">\n                      <i class=\"fa fa-font\"></i> 快捷输入(开发中)</div>\n                    <div class=\"panel-body\">\n                      <textarea class=\"form-control\" v-model=\"inputText\"></textarea>\n                      <p class=\"description\">\n                        <strong>Shift+Enter</strong> to start a new line,\n                        <strong>Enter</strong> to send</p>\n                    </div>\n                  </div>\n\n                  <div class=\"panel panel-default\">\n                    <div class=\"panel-heading\">\n                      <i class=\"fa fa-android\"></i> 软件安装</div>\n                    <div class=\"panel-body\">\n                      <div>\n                        <div class=\"input-group\">\n                          <span class=\"input-group-addon\" id=\"sizing-addon2\">\n                            <i class=\"fa fa-globe\"></i>\n                          </span>\n                          <input type=\"text\" class=\"form-control\" v-model=\"browserURL\" placeholder=\"输入安装包地址 http://..\" @keyup.enter=\"openBrowser(browserURL)\">\n                          <span class=\"input-group-btn\">\n                            <button type=\"button\" class=\"btn btn-default\" @click='openBrowser(browserURL)'>下载</button>\n                          </span>\n                        </div>\n                        <p class=\"description\">打开浏览器下载，下载完成后需手动安装</p>\n                      </div>\n\n                    </div>\n                  </div>\n\n                  <div class=\"panel panel-default\">\n                    <div class=\"panel-heading\">截图 &amp; 录制</div>\n                    <div class=\"panel-body\">\n                      <button @click=\"saveScreenshot()\" class=\"btn btn-sm btn-default\">\n                        <i class=\"fa fa-image\"></i> 截图</button>\n                      <button v-if=\"!videoReceiver\" type=\"button\" class=\"btn btn-sm btn-default\" @click=\"startVideoRecord\">\n                        高速录制\n                        <i class=\"fa fa-circle color-red\"></i>\n                      </button>\n                      <button v-if=\"!videoReceiver\" type=\"button\" class=\"btn btn-sm btn-default\" @click=\"startLowQualityScreenRecord\">\n                        低速录制\n                        <i class=\"fa fa-circle color-red\"></i>\n                      </button>\n                      <!-- <button v-if=\"!videoReceiver\" -->\n                      <button v-if=\"videoReceiver\" type=\"button\" class=\"btn btn-sm btn-default\" @click=\"stopVideoRecord\">\n                        停止录制\n                        <i class=\"fa fa-stop color-blue\"></i>\n                      </button>\n                      <p class=\"description\">高速录制时需要保持屏幕开启，每秒仅采集6张图片, 播放时约2倍速度快放。低速录制每一秒采集一张图片</p>\n                      <p>\n                        <a target=\"_blank\" href=\"/videos\">\n                          <i class=\"fa fa-external-link\"></i>\n                          查看所有录制</a>\n                      </p>\n                      <div>\n                        <hr>\n                        <div>\n                          <button @click=\"saveShortVideo\" class=\"btn btn-sm btn-default\">\n                            <i class=\"fa fa-video-camera\"></i> 保存最近~10s视频 (Beta)</button>\n                        </div>\n                        <div v-show=\"videoUrl\" class=\"text-center\">\n                          <hr>\n                          <div class=\"input-group\">\n                            <span class=\"input-group-addon\" id=\"sizing-addon2\">\n                              视频地址\n                            </span>\n                            <input type=\"text\" v-model=\"videoUrl\" class=\"form-control\" disabled>\n                            <span class=\"input-group-btn\">\n                              <button class=\"btn btn-default btn-copy\" type=\"button\" v-bind:data-clipboard-text=\"videoUrl\">\n                                Copy\n                              </button>\n                            </span>\n                          </div>\n                          <br>\n                          <video :src=\"videoUrl\" controls=controls style=\"max-height: 400px; border: 1px solid gray\">\n                            您的浏览器不支持 video 标签\n                          </video>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n\n                </tab-item>\n\n                <tab-item v-on:additem=\"addTabItem\" :active=\"navtabs.active\" name=\"Logcat(TODO)\">\n                  <p>最大保存行数:\n                    <input type=\"number\" v-model.number=\"logcat.maxKeep\">\n                  </p>\n                  <div class=\"input-group input-group-sm\" style=\"width: 400px\">\n                    <span class=\"input-group-addon\">Package name</span>\n                    <input type=\"text\" class=\"form-control\" placeholder=\"com.example...\">\n                    <span class=\"input-group-btn\">\n                      <button class=\"btn btn-default\" type=\"button\">\n                        <span class=\"glyphicon glyphicon-refresh\"></span>\n                      </button>\n                    </span>\n                  </div>\n                  <br>\n                  <table class=\"logcat\" ref=\"logcat\">\n                    <tbody>\n                      <tr v-for=\"v in logcat.logs\">\n                        <td class=\"logcat-lineno\" :style=\"{color: logcatTag2Color(v.tag)}\">{{v.lineno}}</td>\n                        <td class=\"logcat-tag\" :style=\"{color: logcatTag2Color(v.tag)}\">{{v.tag}}</td>\n                        <td class=\"logcat-level\" :style=\"{'background-color': logcatLevel2Color(v.level)}\">{{v.level}}</td>\n                        <td class=\"logcat-content\">{{v.content}}</td>\n                      </tr>\n                      <tr>\n                        <td class=\"logcat-lineno\">2000</td>\n                        <td class=\"logcat-tag\" style=\"color: rgb(105, 155, 221)\">EsService</td>\n                        <td class=\"logcat-level\">I</td>\n                        <td>readResults: read results: 32, lastRequestId: 330</td>\n                      </tr>\n                      <tr>\n                        <td class=\"logcat-lineno\">2000</td>\n                        <td></td>\n                        <td class=\"logcat-level\">I</td>\n                        <td class=\"logcat-content\">Kgd.KeyguardUpdateMonitor: mTimeTickBroadcastReceiver current state is mBootCompleted=true mDeviceProvisioned=true\n                          mPhoneState=0 Keyguard.isShowing = false Keyguard.isOccluded = false mScreenOn=true</td>\n                      </tr>\n                      <tr>\n                        <td class=\"logcat-lineno\">2000</td>\n                        <td class=\"logcat-tag\">libEGL</td>\n                        <td class=\"logcat-level\" style=\"background-color: gray\">D</td>\n                        <td class=\"logcat-content\">loaded /system/lib/egl/libEGL_adreno200.so</td>\n                      </tr>\n                    </tbody>\n                  </table>\n                </tab-item>\n\n                <tab-item v-on:additem=\"addTabItem\" :active=\"navtabs.active\" tabid=\"explorer\" name=\"文件(TODO)\">\n                  <!-- <label>从URL安装（todo）</label>\n                  <div style=\"max-width: 500px\" class=\"input-group\">\n                    <input type=\"text\" placeholder=\"https://...\" class=\"form-control\">\n                    <span class=\"input-group-btn\">\n                      <button class=\"btn btn-default\" type=\"button\">Go</button>\n                    </span>\n                  </div> -->\n\n                  <form action=\"/upload/sdcard/tmp/\" method=\"post\" enctype=\"multipart/form-data\" class=\"dropzone\" ref=\"upload\">\n                  </form>\n                  <div class=\"form-group\">\n                    <label>上传到 (还在开发中) </label>\n                    <input type=\"text\" placeholder=\"上传路径\" class=\"form-control\">\n                    <p class=\"help-block\">上传路径为空，会根据上传的文件名自动分析要上传的路径</p>\n                  </div>\n                  <!-- <button class=\"btn btn-default btn-sm\">上传</button> -->\n\n                  <form v-on:submit.prevent=\"uploadFile\">\n                    <div class=\"form-group\">\n                      <label for=\"file\">Choose file to upload</label>\n                      <input type=\"file\" name=\"file\">\n                      <p class=\"help-block\">file will be uploaded to \"/sdcard/tmp/\"</p>\n                    </div>\n                    <div>\n                      <button class=\"btn btn-default btn-sm\">上传</button>\n                    </div>\n                  </form>\n\n                </tab-item>\n                <tab-item v-on:additem=\"addTabItem\" :active=\"navtabs.active\" name=\"Settings\">\n                  Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy\n                  text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type\n                  specimen book. It has survived not only five centuries, but also the leap into electronic typesetting,\n                  remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing\n                  Lorem Ipsum passage..\n                </tab-item>\n                <tab-item v-on:additem=\"addTabItem\" :active=\"navtabs.active\" tabid=\"terminal\" name=\"终端(TODO)\">\n                  TODO\n                </tab-item>\n                <tab-item v-on:additem=\"addTabItem\" :active=\"navtabs.active\" tabid=\"aboutus\" name=\"关于我们\">\n                  远程真机项目，如有问题欢迎联系 hzsunshx (网易内部) 499563266 (QQ群)\n                </tab-item>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- alert and dialogs -->\n    <div class=\"modal\" tabindex=\"-1\" role=\"dialog\">\n      <div class=\"modal-dialog\" role=\"document\">\n        <div class=\"modal-content\">\n          <div class=\"modal-header\">\n            <button type=\"button\" class=\"close\" data-dismiss=\"modal\" aria-label=\"Close\">\n              <span aria-hidden=\"true\">&times;</span>\n            </button>\n            <h4 class=\"modal-title\">\n              <span class=\"color-red\">\n                <i class=\"fa fa-warning\"></i> Error</span>\n            </h4>\n          </div>\n          <div class=\"modal-body\">\n            <pre v-html=\"error\"></pre>\n          </div>\n          <div class=\"modal-footer\">\n            <button type=\"button\" class=\"btn btn-default\" data-dismiss=\"modal\">Close</button>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</body>\n<script src=\"//cdn.jsdelivr.net/npm/vue@2.5.13/dist/vue.js\"></script>\n<script src=\"//cdn.jsdelivr.net/npm/jquery@3.2.1/dist/jquery.min.js\"></script>\n<script src=\"//cdn.jsdelivr.net/bootstrap/3.3.7/js/bootstrap.min.js\"></script>\n<script src=\"//cdn.jsdelivr.net/bootstrap.select/1.12.2/js/bootstrap-select.min.js\"></script>\n<script src=\"//cdn.jsdelivr.net/npm/jstree@3.3.4/dist/jstree.min.js\"></script>\n<script src=\"//cdn.jsdelivr.net/npm/file-saver@1.3.3/FileSaver.min.js\"></script>\n<script src=\"//cdn.jsdelivr.net/npm/clipboard@1.5.12/dist/clipboard.min.js\"></script>\n<script src=\"/assets/libs/notify.js\"></script>\n<script src=\"/assets/libs/jquery-tiny-pubsub.js\"></script>\n<script src='/assets/libs/dropzone/dropzone.min.js'></script>\n<script src='[[\"/assets/common.js\" | urlhash]]'></script>\n<script type=\"text/x-template\" id=\"tab-item-component\">\n  <div role=\"tabpanel\" class=\"tab-pane\" :class='{active: active == id}' :id=\"id\">\n    <slot></slot>\n  </div>\n</script>\n<script>\n  var deviceIp = \"[[.IP]]\"\n  var devicePort = \"[[.Port]]\"\n  var deviceUdid = \"[[.Udid]]\"\n\n  console.log(\"Infos:\", deviceIp, devicePort, deviceUdid)\n\n  Vue.component('tab-item', {\n    template: '#tab-item-component',\n    props: [\"name\", \"active\", \"tabid\"],\n    computed: {\n      id: function () {\n        return this.tabid || this.name.toLowerCase().replace(\" \", \"-\")\n      }\n    },\n    mounted: function () {\n      var item = {\n        name: this.name,\n        id: this.id,\n      }\n      this.$emit(\"additem\", item);\n    }\n  });\n</script>\n<!-- need set var \"deviceIp\" before -->\n<script src='[[\"/assets/remote.js\" | urlhash]]'></script>\n\n</html>"
  },
  {
    "path": "utils.go",
    "content": "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\treturn *v\n}\n"
  }
]