Repository: openatx/atx-server Branch: master Commit: e76ec88e1984 Files: 44 Total size: 184.2 KB Directory structure: gitextract_my193frc/ ├── .fsw.yml ├── .github/ │ └── stale.yml ├── .gitignore ├── .goreleaser.yml ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── assets/ │ ├── bootstrap-tabs.css │ ├── common.js │ ├── libs/ │ │ ├── jquery-tiny-pubsub.js │ │ └── notify.js │ ├── logcat.css │ ├── remote.css │ ├── remote.js │ ├── style.css │ └── vue-components.js ├── database.go ├── database_test.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── heartbeat/ │ └── heartbeat.go ├── hostsmanager.go ├── httplog.go ├── httpserver.go ├── main.go ├── proto/ │ └── message.go ├── rethinkdb-test/ │ └── main.go ├── scripts/ │ ├── img2video/ │ │ ├── main.py │ │ ├── requirements.txt │ │ ├── static/ │ │ │ ├── .gitkeep │ │ │ └── videos/ │ │ │ └── .gitkeep │ │ └── templates/ │ │ ├── index.html │ │ └── videos.html │ ├── levenshtein.py │ └── update_coverage_umeng.py ├── templates/ │ ├── edit.html │ ├── index.html │ ├── property.html │ ├── providers.html │ └── remote.html └── utils.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .fsw.yml ================================================ desc: Auto generated by fswatch [atx-server] triggers: - name: "" pattens: - '**/*.go' - '**/*.c' - '**/*.py' env: DEBUG: "1" cmd: go build && ./atx-server shell: true delay: 100ms stop_timeout: 500ms signal: KILL kill_signal: "" watch_paths: - . watch_depth: 0 ================================================ FILE: .github/stale.yml ================================================ # Number of days of inactivity before an issue becomes stale daysUntilStale: 30 # Number of days of inactivity before a stale issue is closed daysUntilClose: 2 # Issues with these labels will never be considered stale exemptLabels: - pinned - security - feature-request # Label to use when marking an issue as stale staleLabel: wontfix # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false ================================================ FILE: .gitignore ================================================ # Binaries for programs and plugins *.exe *.dll *.so *.dylib *.mp4 # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 .glide/ *.exe __pycache__ # vim *.un~ # vscode settings.json *.mp4.json /data atx-server ================================================ FILE: .goreleaser.yml ================================================ builds: - goos: - linux - windows - darwin goarch: - amd64 - 386 flags: -tags vfs hooks: pre: go generate ================================================ FILE: .travis.yml ================================================ --- language: go sudo: false services: - docker go: - "1.11" env: - GO111MODULE=on install: true script: - go test -v - docker build . after_success: - test -n "$TRAVIS_TAG" && curl -sL https://git.io/goreleaser | bash ================================================ FILE: Dockerfile ================================================ FROM golang:1.11 RUN mkdir /app ADD . /app/ WORKDIR /app RUN go build FROM debian:stretch WORKDIR /root/ COPY --from=0 /app/atx-server . COPY --from=0 /app/templates ./templates ENTRYPOINT ./atx-server --port 8000 ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2017 shengxiang Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ dc_cmd=docker-compose -p atx atx_server_addr=192.168.147.230:8000 # --serial $SERIAL up: $(dc_cmd) up -d --build down: $(dc_cmd) down shell: docker exec -it atxserver bash ps: $(dc_cmd) ps log: $(dc_cmd) logs -f atxserver init: python -m uiautomator2 init --server $(atx_server_addr) ================================================ FILE: README.md ================================================ # Deprecated Please use instead. ----------------- # ATX-SERVER [![GitHub stars](https://img.shields.io/badge/govendor-vendor-blue.svg)](https://github.com/kardianos/govendor) [![Build Status](https://travis-ci.org/openatx/atx-server.svg?branch=master)](https://travis-ci.org/openatx/atx-server) Manage batch of atx-agents # Testerhome上相关文章 - [安卓设备集群管理 atx-server](https://testerhome.com/topics/11546) By [codeskyblue](https://testerhome.com/codeskyblue) - [atx 安卓集群管理 安装运行及自动化的实践](https://testerhome.com/topics/11588) By [cynic]: (https://testerhome.com/cynic) # Install 重要:需要有go语言的基础,知道该如何编译一个go的程序 1. Install and start [rethinkdb](https://rethinkdb.com) 2. Install [go](https://golang.org) Compile with go ```bash $ go get -v github.com/openatx/atx-server $ cd $GOPATH/src/github.com/openatx/atx-server $ go build ``` # Usage launch `rethinkdb` ```bash $ rethinkdb Running rethinkdb 2.3.6 (CLANG 8.1.0 (clang-802.0.42))... Running on Darwin 16.6.0 x86_64 ... ``` launch `atx-server` ``` ./atx-server --port 8000 ``` Install `atx-agent` using [uiautomator2](https://github.com/openatx/uiautomator2) into android phone. your android phone and server running `atx-server` should in the same intranet. Suppose server running `atx-server` got the ip `10.0.1.1`, listen port `8000`. Do the following command ```bash $ pip install -U --pre uiautomator2 $ python -m uiautomator2 init 10.0.1.1:8000 ``` open browser , you should see the device listed on the web. ## Advanced usage ### Set up notification. 1. Usage command flag ``` ./atx-server --ding-token 13gb4db7c276d22e84f788fa693b729d53218b8e07d6ede43de79360c962 --port 8080 ``` 2. Set up env var ``` export DING_TOKEN="13gb4db7c276d22e84f788fa693b729d53218b8e07d6ede43de79360c962" ./atx-server --port 8080 ``` # APIs ## /list 接口 其中udid是通过hwaddr, model, serial组合生成的 ```bash $ curl $SERVER_URL/list [ { "udid": "741AEDR42P6YM-2c:57:31:4b:40:74-M2_E", "ip": "10.240.218.20", "present": true, "ready": true, "using": true, "provider": null, "serial": "741AEDR42P6YM", "brand": "Meizu", "model": "M2 E", "hwaddr": "2c:57:31:4b:40:74", "agentVersion": "0.1.1", "battery": {}, "display": { "width": 1080, "height": 1920 } } ] ``` There are some fields you need pay attention. - `present` means device is online - `ready` is the thumb :thumbsup: you can see and edit in the web - `using` means if device is using by someone `provider` is a special field, if device is plugged into some machine which running [u2init](https://github.com/openatx/u2init), the bellow info can be found in device info. ```json "provider": { "id": "33576428", "ip": "10.0.0.1", "port": 10000, "present": true # provider online of not } ``` if `provider` is `null` it means device is not plugged-in. ## /devices/{query}/info ```bash $ curl $SERVER_URL/devices/ip:10.0.0.1/info # or $ curl $SERVER_URL/devices/$UDID/info ``` 返回值同/list的的单个结果,这里就不写了。 ## /version `atx-agent`通过检测该接口确定是否升级 ```bash $ curl /version { "server": "dev", "atx-agent": "0.0.7" } ``` ## 执行shell命令 ```bash $ curl -X POST -F command="pwd" $SERVER_URL/devices/{query}/shell { "output": "/" } ``` ## 设备管理 占用、释放 状态码 成功200,失败403 ### 占用设备 ```bash $ curl -X POST $SERVER_URL/devices/{query}/reserved Success ``` ### 释放设备 状态码 成功200,失败403 ```bash $ curl -X DELETE $SERVER_URL/devices/{query}/reserved Release success ``` 随机占用一台设备 ```bash $ curl -X POST $SERVER_URL/devices/:random/reserved Success ``` ## Communication between provider(u2init) and server(atx-server) Provider send POST to Server **heartbeat info** to let server known provider is online. It is also need to send the same data to Server in 15s or the Provider will be marked offline. ```bash $ curl -X POST -F id=$PROVIDER_ID -F port=11000 $SERVER_URL/provider/heartbeat ``` You may need to add ip field if provider and server is not in the same network ```bash $ PROVIDER_IP=10.0.0.1 # change to your provider ip $ PROVIDER_ID=ccdd11ff # change to your provider id $ curl -X POST \ -F ip=$PROVIDER_IP \ -F id=$PROVIDER_ID \ -F port=11000 \ $SERVER_URL/provider/heartbeat ``` Server response status 200 indicate success, or 400 and else means failure Send using bellow command when there is device plugged-in ```bash $ DEVICE_UDID="3578298f-b4:0b:44:e6:1f:90-OD103" # change to your device udid $ DATA="{\"status\": \"online\", \"udid\": \"$DEVICE_UDID\"}" $ curl -X POST \ -F id=$PROVIDER_ID \ -F port=11000 \ -F data="$DATA" $SERVER_URL/provider/heartbeat ``` ## Comminication between atx-agent and atx-server It is complicated. Hard to write. # Docker `atx-server` is dockerized (based on `golang` image) and depends on the official `rethinkdb` container. To build and run all services, use: ```bash docker-compuse up --build ``` `atx-server` can be accessed from `localhost:8000` and `rethinkdb` web console is available at `localhost:8001`, both specified in the compose file. `rethinkdb` data is stored at `$PWD/data` (host volume). ## References and some good resources - Golang library for rethinkdb [gorethink](https://github.com/GoRethink/gorethink) - [美团点评云真机平台实践](https://tech.meituan.com/cloud_phone.html) - [腾讯TMQ-远程移动测试平台对比分析](https://blog.csdn.net/TMQ1225/article/details/52369171) - [藏经阁-iOS多机远程控制技术](http://www.sohu.com/a/240584209_744135) # LICENSE [MIT](LICENSE) ================================================ FILE: assets/bootstrap-tabs.css ================================================ /** Make better bootstrap tabs Thanks to https://bootsnipp.com/snippets/featured/material-design-tab-style */ .nav-tabs { border-bottom: 2px solid #DDD; } .nav-tabs>li.active>a, .nav-tabs>li.active>a:focus, .nav-tabs>li.active>a:hover { border-width: 0; } .nav-tabs>li>a { border: none; color: #666; } .nav-tabs>li.active>a, .nav-tabs>li>a:hover { border: none; color: #4285F4 !important; background: transparent; } .nav-tabs>li>a::after { content: ""; background: #4285F4; height: 2px; position: absolute; width: 100%; left: 0px; bottom: -1px; transition: all 250ms ease 0s; transform: scale(0); } .nav-tabs>li.active>a::after, .nav-tabs>li:hover>a::after { transform: scale(1); } .tab-nav>li>a::after { background: #21527d none repeat scroll 0% 0%; color: #fff; } .tab-pane { padding: 15px 0; } .tab-content { padding: 20px; padding-bottom: 0px; overflow: auto; } .card { background: #FFF none repeat scroll 0% 0%; box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.3); /* margin-bottom: 30px; */ /* add */ display: flex; flex: 1; flex-direction: column; } ================================================ FILE: assets/common.js ================================================ // Copies a string to the clipboard. Must be called from within an // event handler such as click. May return false if it failed, but // this is not always possible. Browser support for Chrome 43+, // Firefox 42+, Safari 10+, Edge and IE 10+. // IE: The clipboard feature may be disabled by an administrator. By // default a prompt is shown the first time the clipboard is // used (per session). function copyToClipboard(text) { if (window.clipboardData && window.clipboardData.setData) { // IE specific code path to prevent textarea being shown while dialog is visible. return clipboardData.setData("Text", text); } else if (document.queryCommandSupported && document.queryCommandSupported("copy")) { var textarea = document.createElement("textarea"); textarea.textContent = text; textarea.style.position = "fixed"; // Prevent scrolling to bottom of page in MS Edge. document.body.appendChild(textarea); textarea.select(); try { return document.execCommand("copy"); // Security exception may be thrown by some browsers. } catch (ex) { console.warn("Copy to clipboard failed.", ex); return false; } finally { document.body.removeChild(textarea); } } } /* Image Pool */ function ImagePool(size) { this.size = size this.images = [] this.counter = 0 } ImagePool.prototype.next = function() { if (this.images.length < this.size) { var image = new Image() this.images.push(image) return image } else { if (this.counter >= this.size) { // Reset for unlikely but theoretically possible overflow. this.counter = 0 } } return this.images[this.counter++ % this.size] } // convert to blob data function b64toBlob(b64Data, contentType, sliceSize) { contentType = contentType || ''; sliceSize = sliceSize || 512; var byteCharacters = atob(b64Data); var byteArrays = []; for (var offset = 0; offset < byteCharacters.length; offset += sliceSize) { var slice = byteCharacters.slice(offset, offset + sliceSize); var byteNumbers = new Array(slice.length); for (var i = 0; i < slice.length; i++) { byteNumbers[i] = slice.charCodeAt(i); } var byteArray = new Uint8Array(byteNumbers); byteArrays.push(byteArray); } return new Blob(byteArrays, { type: contentType }); } var MiniTouch = { createNew: function(ws) { // ws: Websocket connection communication with minitouch var control = {} function sendJSON(obj) { ws.send(JSON.stringify(obj)) } // control.coords = function(w, h, x, y, rotation) { // console.log(w, h, x, y, rotation) // return { // xP: x / w, // yP: y / h, // } // }; control.touchDown = function(index, xP, yP, pressure) { sendJSON({ operation: 'd', index: index, pressure: pressure, xP: xP, yP: yP, }) }; control.touchWait = function(mseconds) { sendJSON({ operation: 'w', milliseconds: mseconds }) } control.touchCommit = function() { sendJSON({ operation: 'c' }) }; control.touchMove = function(index, xP, yP, pressure) { sendJSON({ operation: 'm', index: index, pressure: pressure, xP: xP, yP: yP, }) }; control.touchUp = function(index) { sendJSON({ operation: 'u', index: index }) }; return control; } } /** * Rotation affects the screen as follows: * * 0deg * |------| * | MENU | * |------| * --> | | --| * | | | v * | | * | | * |------| * |----|-| |-|----| * | |M| | | | * | |E| | | | * 90deg | |N| |U| | 270deg * | |U| |N| | * | | | |E| | * | | | |M| | * |----|-| |-|----| * |------| * ^ | | | * |-- | | <-- * | | * | | * |------| * | UNEM | * |------| * 180deg * * Which leads to the following mapping: * * |--------------|------|---------|---------|---------| * | | 0deg | 90deg | 180deg | 270deg | * |--------------|------|---------|---------|---------| * | CSS rotate() | 0deg | -90deg | -180deg | 90deg | * | bounding w | w | h | w | h | * | bounding h | h | w | h | w | * | pos x | x | h-y | w-x | y | * | pos y | y | x | h-y | h-x | * |--------------|------|---------|---------|---------| */ function coords(boundingW, boundingH, relX, relY, rotation) { var w, h, x, y; switch (rotation) { case 0: w = boundingW h = boundingH x = relX y = relY break case 90: w = boundingH h = boundingW x = boundingH - relY y = relX break case 180: w = boundingW h = boundingH x = boundingW - relX y = boundingH - relY break case 270: w = boundingH h = boundingW x = relY y = boundingW - relX break } return { xP: x / w, yP: y / h, } } /* accepts parameters * h Object = {h:x, s:y, v:z} * OR * h, s, v * This code expects 0 <= h, s, v <= 1 * The returned 0 <= r, g, b <= 255 are rounded to the nearest Integer */ function HSVtoRGB(h, s, v) { var r, g, b, i, f, p, q, t; if (arguments.length === 1) { s = h.s, v = h.v, h = h.h; } i = Math.floor(h * 6); f = h * 6 - i; p = v * (1 - s); q = v * (1 - f * s); t = v * (1 - (1 - f) * s); switch (i % 6) { case 0: r = v, g = t, b = p; break; case 1: r = q, g = v, b = p; break; case 2: r = p, g = v, b = t; break; case 3: r = p, g = q, b = v; break; case 4: r = t, g = p, b = v; break; case 5: r = v, g = p, b = q; break; } return [ Math.round(r * 255), Math.round(g * 255), Math.round(b * 255) ] } function getRandomRgb(brightness) { var rgb = HSVtoRGB(Math.random(), Math.random(), 0.8); return 'rgb(' + rgb.join(",") + ")"; } ================================================ FILE: assets/libs/jquery-tiny-pubsub.js ================================================ /*! Tiny Pub/Sub - v0.7.0 - 2013-01-29 * https://github.com/cowboy/jquery-tiny-pubsub * Copyright (c) 2013 "Cowboy" Ben Alman; Licensed MIT */ (function ($) { var o = $({}); $.subscribe = function () { o.on.apply(o, arguments); }; $.unsubscribe = function () { o.off.apply(o, arguments); }; $.publish = function () { o.trigger.apply(o, arguments); }; }(jQuery)); ================================================ FILE: assets/libs/notify.js ================================================ /* Notify.js - http://notifyjs.com/ Copyright (c) 2015 MIT */ (function (factory) { // UMD start // https://github.com/umdjs/umd/blob/master/jqueryPluginCommonjs.js if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. define(['jquery'], factory); } else if (typeof module === 'object' && module.exports) { // Node/CommonJS module.exports = function( root, jQuery ) { if ( jQuery === undefined ) { // require('jQuery') returns a factory that requires window to // build a jQuery instance, we normalize how we use modules // that require this pattern but the window provided is a noop // if it's defined (how jquery works) if ( typeof window !== 'undefined' ) { jQuery = require('jquery'); } else { jQuery = require('jquery')(root); } } factory(jQuery); return jQuery; }; } else { // Browser globals factory(jQuery); } }(function ($) { //IE8 indexOf polyfill var indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) { return i; } } return -1; }; var pluginName = "notify"; var pluginClassName = pluginName + "js"; var blankFieldName = pluginName + "!blank"; var positions = { t: "top", m: "middle", b: "bottom", l: "left", c: "center", r: "right" }; var hAligns = ["l", "c", "r"]; var vAligns = ["t", "m", "b"]; var mainPositions = ["t", "b", "l", "r"]; var opposites = { t: "b", m: null, b: "t", l: "r", c: null, r: "l" }; var parsePosition = function(str) { var pos; pos = []; $.each(str.split(/\W+/), function(i, word) { var w; w = word.toLowerCase().charAt(0); if (positions[w]) { return pos.push(w); } }); return pos; }; var styles = {}; var coreStyle = { name: "core", html: "
\n
\n
\n
", css: "." + pluginClassName + "-corner {\n position: fixed;\n margin: 5px;\n z-index: 1050;\n}\n\n." + pluginClassName + "-corner ." + pluginClassName + "-wrapper,\n." + pluginClassName + "-corner ." + pluginClassName + "-container {\n position: relative;\n display: block;\n height: inherit;\n width: inherit;\n margin: 3px;\n}\n\n." + pluginClassName + "-wrapper {\n z-index: 1;\n position: absolute;\n display: inline-block;\n height: 0;\n width: 0;\n}\n\n." + pluginClassName + "-container {\n display: none;\n z-index: 1;\n position: absolute;\n}\n\n." + pluginClassName + "-hidable {\n cursor: pointer;\n}\n\n[data-notify-text],[data-notify-html] {\n position: relative;\n}\n\n." + pluginClassName + "-arrow {\n position: absolute;\n z-index: 2;\n width: 0;\n height: 0;\n}" }; var stylePrefixes = { "border-radius": ["-webkit-", "-moz-"] }; var getStyle = function(name) { return styles[name]; }; var removeStyle = function(name) { if (!name) { throw "Missing Style name"; } if (styles[name]) { delete styles[name]; } }; var addStyle = function(name, def) { if (!name) { throw "Missing Style name"; } if (!def) { throw "Missing Style definition"; } if (!def.html) { throw "Missing Style HTML"; } //remove existing style var existing = styles[name]; if (existing && existing.cssElem) { if (window.console) { console.warn(pluginName + ": overwriting style '" + name + "'"); } styles[name].cssElem.remove(); } def.name = name; styles[name] = def; var cssText = ""; if (def.classes) { $.each(def.classes, function(className, props) { cssText += "." + pluginClassName + "-" + def.name + "-" + className + " {\n"; $.each(props, function(name, val) { if (stylePrefixes[name]) { $.each(stylePrefixes[name], function(i, prefix) { return cssText += " " + prefix + name + ": " + val + ";\n"; }); } return cssText += " " + name + ": " + val + ";\n"; }); return cssText += "}\n"; }); } if (def.css) { cssText += "/* styles for " + def.name + " */\n" + def.css; } if (cssText) { def.cssElem = insertCSS(cssText); def.cssElem.attr("id", "notify-" + def.name); } var fields = {}; var elem = $(def.html); findFields("html", elem, fields); findFields("text", elem, fields); def.fields = fields; }; var insertCSS = function(cssText) { var e, elem, error; elem = createElem("style"); elem.attr("type", 'text/css'); $("head").append(elem); try { elem.html(cssText); } catch (_) { elem[0].styleSheet.cssText = cssText; } return elem; }; var findFields = function(type, elem, fields) { var attr; if (type !== "html") { type = "text"; } attr = "data-notify-" + type; return find(elem, "[" + attr + "]").each(function() { var name; name = $(this).attr(attr); if (!name) { name = blankFieldName; } fields[name] = type; }); }; var find = function(elem, selector) { if (elem.is(selector)) { return elem; } else { return elem.find(selector); } }; var pluginOptions = { clickToHide: true, autoHide: true, autoHideDelay: 5000, arrowShow: true, arrowSize: 5, breakNewLines: true, elementPosition: "bottom", globalPosition: "top right", style: "bootstrap", className: "error", showAnimation: "slideDown", showDuration: 400, hideAnimation: "slideUp", hideDuration: 200, gap: 5 }; var inherit = function(a, b) { var F; F = function() {}; F.prototype = a; return $.extend(true, new F(), b); }; var defaults = function(opts) { return $.extend(pluginOptions, opts); }; var createElem = function(tag) { return $("<" + tag + ">"); }; var globalAnchors = {}; var getAnchorElement = function(element) { var radios; if (element.is('[type=radio]')) { radios = element.parents('form:first').find('[type=radio]').filter(function(i, e) { return $(e).attr("name") === element.attr("name"); }); element = radios.first(); } return element; }; var incr = function(obj, pos, val) { var opp, temp; if (typeof val === "string") { val = parseInt(val, 10); } else if (typeof val !== "number") { return; } if (isNaN(val)) { return; } opp = positions[opposites[pos.charAt(0)]]; temp = pos; if (obj[opp] !== undefined) { pos = positions[opp.charAt(0)]; val = -val; } if (obj[pos] === undefined) { obj[pos] = val; } else { obj[pos] += val; } return null; }; var realign = function(alignment, inner, outer) { if (alignment === "l" || alignment === "t") { return 0; } else if (alignment === "c" || alignment === "m") { return outer / 2 - inner / 2; } else if (alignment === "r" || alignment === "b") { return outer - inner; } throw "Invalid alignment"; }; var encode = function(text) { encode.e = encode.e || createElem("div"); return encode.e.text(text).html(); }; function Notification(elem, data, options) { if (typeof options === "string") { options = { className: options }; } this.options = inherit(pluginOptions, $.isPlainObject(options) ? options : {}); this.loadHTML(); this.wrapper = $(coreStyle.html); if (this.options.clickToHide) { this.wrapper.addClass(pluginClassName + "-hidable"); } this.wrapper.data(pluginClassName, this); this.arrow = this.wrapper.find("." + pluginClassName + "-arrow"); this.container = this.wrapper.find("." + pluginClassName + "-container"); this.container.append(this.userContainer); if (elem && elem.length) { this.elementType = elem.attr("type"); this.originalElement = elem; this.elem = getAnchorElement(elem); this.elem.data(pluginClassName, this); this.elem.before(this.wrapper); } this.container.hide(); this.run(data); } Notification.prototype.loadHTML = function() { var style; style = this.getStyle(); this.userContainer = $(style.html); this.userFields = style.fields; }; Notification.prototype.show = function(show, userCallback) { var args, callback, elems, fn, hidden; callback = (function(_this) { return function() { if (!show && !_this.elem) { _this.destroy(); } if (userCallback) { return userCallback(); } }; })(this); hidden = this.container.parent().parents(':hidden').length > 0; elems = this.container.add(this.arrow); args = []; if (hidden && show) { fn = "show"; } else if (hidden && !show) { fn = "hide"; } else if (!hidden && show) { fn = this.options.showAnimation; args.push(this.options.showDuration); } else if (!hidden && !show) { fn = this.options.hideAnimation; args.push(this.options.hideDuration); } else { return callback(); } args.push(callback); return elems[fn].apply(elems, args); }; Notification.prototype.setGlobalPosition = function() { var p = this.getPosition(); var pMain = p[0]; var pAlign = p[1]; var main = positions[pMain]; var align = positions[pAlign]; var key = pMain + "|" + pAlign; var anchor = globalAnchors[key]; if (!anchor || !document.body.contains(anchor[0])) { anchor = globalAnchors[key] = createElem("div"); var css = {}; css[main] = 0; if (align === "middle") { css.top = '45%'; } else if (align === "center") { css.left = '45%'; } else { css[align] = 0; } anchor.css(css).addClass(pluginClassName + "-corner"); $("body").append(anchor); } return anchor.prepend(this.wrapper); }; Notification.prototype.setElementPosition = function() { var arrowColor, arrowCss, arrowSize, color, contH, contW, css, elemH, elemIH, elemIW, elemPos, elemW, gap, j, k, len, len1, mainFull, margin, opp, oppFull, pAlign, pArrow, pMain, pos, posFull, position, ref, wrapPos; position = this.getPosition(); pMain = position[0]; pAlign = position[1]; pArrow = position[2]; elemPos = this.elem.position(); elemH = this.elem.outerHeight(); elemW = this.elem.outerWidth(); elemIH = this.elem.innerHeight(); elemIW = this.elem.innerWidth(); wrapPos = this.wrapper.position(); contH = this.container.height(); contW = this.container.width(); mainFull = positions[pMain]; opp = opposites[pMain]; oppFull = positions[opp]; css = {}; css[oppFull] = pMain === "b" ? elemH : pMain === "r" ? elemW : 0; incr(css, "top", elemPos.top - wrapPos.top); incr(css, "left", elemPos.left - wrapPos.left); ref = ["top", "left"]; for (j = 0, len = ref.length; j < len; j++) { pos = ref[j]; margin = parseInt(this.elem.css("margin-" + pos), 10); if (margin) { incr(css, pos, margin); } } gap = Math.max(0, this.options.gap - (this.options.arrowShow ? arrowSize : 0)); incr(css, oppFull, gap); if (!this.options.arrowShow) { this.arrow.hide(); } else { arrowSize = this.options.arrowSize; arrowCss = $.extend({}, css); arrowColor = this.userContainer.css("border-color") || this.userContainer.css("border-top-color") || this.userContainer.css("background-color") || "white"; for (k = 0, len1 = mainPositions.length; k < len1; k++) { pos = mainPositions[k]; posFull = positions[pos]; if (pos === opp) { continue; } color = posFull === mainFull ? arrowColor : "transparent"; arrowCss["border-" + posFull] = arrowSize + "px solid " + color; } incr(css, positions[opp], arrowSize); if (indexOf.call(mainPositions, pAlign) >= 0) { incr(arrowCss, positions[pAlign], arrowSize * 2); } } if (indexOf.call(vAligns, pMain) >= 0) { incr(css, "left", realign(pAlign, contW, elemW)); if (arrowCss) { incr(arrowCss, "left", realign(pAlign, arrowSize, elemIW)); } } else if (indexOf.call(hAligns, pMain) >= 0) { incr(css, "top", realign(pAlign, contH, elemH)); if (arrowCss) { incr(arrowCss, "top", realign(pAlign, arrowSize, elemIH)); } } if (this.container.is(":visible")) { css.display = "block"; } this.container.removeAttr("style").css(css); if (arrowCss) { return this.arrow.removeAttr("style").css(arrowCss); } }; Notification.prototype.getPosition = function() { var pos, ref, ref1, ref2, ref3, ref4, ref5, text; text = this.options.position || (this.elem ? this.options.elementPosition : this.options.globalPosition); pos = parsePosition(text); if (pos.length === 0) { pos[0] = "b"; } if (ref = pos[0], indexOf.call(mainPositions, ref) < 0) { throw "Must be one of [" + mainPositions + "]"; } if (pos.length === 1 || ((ref1 = pos[0], indexOf.call(vAligns, ref1) >= 0) && (ref2 = pos[1], indexOf.call(hAligns, ref2) < 0)) || ((ref3 = pos[0], indexOf.call(hAligns, ref3) >= 0) && (ref4 = pos[1], indexOf.call(vAligns, ref4) < 0))) { pos[1] = (ref5 = pos[0], indexOf.call(hAligns, ref5) >= 0) ? "m" : "l"; } if (pos.length === 2) { pos[2] = pos[1]; } return pos; }; Notification.prototype.getStyle = function(name) { var style; if (!name) { name = this.options.style; } if (!name) { name = "default"; } style = styles[name]; if (!style) { throw "Missing style: " + name; } return style; }; Notification.prototype.updateClasses = function() { var classes, style; classes = ["base"]; if ($.isArray(this.options.className)) { classes = classes.concat(this.options.className); } else if (this.options.className) { classes.push(this.options.className); } style = this.getStyle(); classes = $.map(classes, function(n) { return pluginClassName + "-" + style.name + "-" + n; }).join(" "); return this.userContainer.attr("class", classes); }; Notification.prototype.run = function(data, options) { var d, datas, name, type, value; if ($.isPlainObject(options)) { $.extend(this.options, options); } else if ($.type(options) === "string") { this.options.className = options; } if (this.container && !data) { this.show(false); return; } else if (!this.container && !data) { return; } datas = {}; if ($.isPlainObject(data)) { datas = data; } else { datas[blankFieldName] = data; } for (name in datas) { d = datas[name]; type = this.userFields[name]; if (!type) { continue; } if (type === "text") { d = encode(d); if (this.options.breakNewLines) { d = d.replace(/\n/g, '
'); } } value = name === blankFieldName ? '' : '=' + name; find(this.userContainer, "[data-notify-" + type + value + "]").html(d); } this.updateClasses(); if (this.elem) { this.setElementPosition(); } else { this.setGlobalPosition(); } this.show(true); if (this.options.autoHide) { clearTimeout(this.autohideTimer); this.autohideTimer = setTimeout(this.show.bind(this, false), this.options.autoHideDelay); } }; Notification.prototype.destroy = function() { this.wrapper.data(pluginClassName, null); this.wrapper.remove(); }; $[pluginName] = function(elem, data, options) { if ((elem && elem.nodeName) || elem.jquery) { $(elem)[pluginName](data, options); } else { options = data; data = elem; new Notification(null, data, options); } return elem; }; $.fn[pluginName] = function(data, options) { $(this).each(function() { var prev = getAnchorElement($(this)).data(pluginClassName); if (prev) { prev.destroy(); } var curr = new Notification($(this), data, options); }); return this; }; $.extend($[pluginName], { defaults: defaults, addStyle: addStyle, removeStyle: removeStyle, pluginOptions: pluginOptions, getStyle: getStyle, insertCSS: insertCSS }); //always include the default bootstrap style addStyle("bootstrap", { html: "
\n\n
", classes: { base: { "font-weight": "bold", "padding": "8px 15px 8px 14px", "text-shadow": "0 1px 0 rgba(255, 255, 255, 0.5)", "background-color": "#fcf8e3", "border": "1px solid #fbeed5", "border-radius": "4px", "white-space": "nowrap", "padding-left": "25px", "background-repeat": "no-repeat", "background-position": "3px 7px" }, error: { "color": "#B94A48", "background-color": "#F2DEDE", "border-color": "#EED3D7", "background-image": "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAtRJREFUeNqkVc1u00AQHq+dOD+0poIQfkIjalW0SEGqRMuRnHos3DjwAH0ArlyQeANOOSMeAA5VjyBxKBQhgSpVUKKQNGloFdw4cWw2jtfMOna6JOUArDTazXi/b3dm55socPqQhFka++aHBsI8GsopRJERNFlY88FCEk9Yiwf8RhgRyaHFQpPHCDmZG5oX2ui2yilkcTT1AcDsbYC1NMAyOi7zTX2Agx7A9luAl88BauiiQ/cJaZQfIpAlngDcvZZMrl8vFPK5+XktrWlx3/ehZ5r9+t6e+WVnp1pxnNIjgBe4/6dAysQc8dsmHwPcW9C0h3fW1hans1ltwJhy0GxK7XZbUlMp5Ww2eyan6+ft/f2FAqXGK4CvQk5HueFz7D6GOZtIrK+srupdx1GRBBqNBtzc2AiMr7nPplRdKhb1q6q6zjFhrklEFOUutoQ50xcX86ZlqaZpQrfbBdu2R6/G19zX6XSgh6RX5ubyHCM8nqSID6ICrGiZjGYYxojEsiw4PDwMSL5VKsC8Yf4VRYFzMzMaxwjlJSlCyAQ9l0CW44PBADzXhe7xMdi9HtTrdYjFYkDQL0cn4Xdq2/EAE+InCnvADTf2eah4Sx9vExQjkqXT6aAERICMewd/UAp/IeYANM2joxt+q5VI+ieq2i0Wg3l6DNzHwTERPgo1ko7XBXj3vdlsT2F+UuhIhYkp7u7CarkcrFOCtR3H5JiwbAIeImjT/YQKKBtGjRFCU5IUgFRe7fF4cCNVIPMYo3VKqxwjyNAXNepuopyqnld602qVsfRpEkkz+GFL1wPj6ySXBpJtWVa5xlhpcyhBNwpZHmtX8AGgfIExo0ZpzkWVTBGiXCSEaHh62/PoR0p/vHaczxXGnj4bSo+G78lELU80h1uogBwWLf5YlsPmgDEd4M236xjm+8nm4IuE/9u+/PH2JXZfbwz4zw1WbO+SQPpXfwG/BBgAhCNZiSb/pOQAAAAASUVORK5CYII=)" }, success: { "color": "#468847", "background-color": "#DFF0D8", "border-color": "#D6E9C6", "background-image": "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAutJREFUeNq0lctPE0Ecx38zu/RFS1EryqtgJFA08YCiMZIAQQ4eRG8eDGdPJiYeTIwHTfwPiAcvXIwXLwoXPaDxkWgQ6islKlJLSQWLUraPLTv7Gme32zoF9KSTfLO7v53vZ3d/M7/fIth+IO6INt2jjoA7bjHCJoAlzCRw59YwHYjBnfMPqAKWQYKjGkfCJqAF0xwZjipQtA3MxeSG87VhOOYegVrUCy7UZM9S6TLIdAamySTclZdYhFhRHloGYg7mgZv1Zzztvgud7V1tbQ2twYA34LJmF4p5dXF1KTufnE+SxeJtuCZNsLDCQU0+RyKTF27Unw101l8e6hns3u0PBalORVVVkcaEKBJDgV3+cGM4tKKmI+ohlIGnygKX00rSBfszz/n2uXv81wd6+rt1orsZCHRdr1Imk2F2Kob3hutSxW8thsd8AXNaln9D7CTfA6O+0UgkMuwVvEFFUbbAcrkcTA8+AtOk8E6KiQiDmMFSDqZItAzEVQviRkdDdaFgPp8HSZKAEAL5Qh7Sq2lIJBJwv2scUqkUnKoZgNhcDKhKg5aH+1IkcouCAdFGAQsuWZYhOjwFHQ96oagWgRoUov1T9kRBEODAwxM2QtEUl+Wp+Ln9VRo6BcMw4ErHRYjH4/B26AlQoQQTRdHWwcd9AH57+UAXddvDD37DmrBBV34WfqiXPl61g+vr6xA9zsGeM9gOdsNXkgpEtTwVvwOklXLKm6+/p5ezwk4B+j6droBs2CsGa/gNs6RIxazl4Tc25mpTgw/apPR1LYlNRFAzgsOxkyXYLIM1V8NMwyAkJSctD1eGVKiq5wWjSPdjmeTkiKvVW4f2YPHWl3GAVq6ymcyCTgovM3FzyRiDe2TaKcEKsLpJvNHjZgPNqEtyi6mZIm4SRFyLMUsONSSdkPeFtY1n0mczoY3BHTLhwPRy9/lzcziCw9ACI+yql0VLzcGAZbYSM5CCSZg1/9oc/nn7+i8N9p/8An4JMADxhH+xHfuiKwAAAABJRU5ErkJggg==)" }, info: { "color": "#3A87AD", "background-color": "#D9EDF7", "border-color": "#BCE8F1", "background-image": "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3QYFAhkSsdes/QAAA8dJREFUOMvVlGtMW2UYx//POaWHXg6lLaW0ypAtw1UCgbniNOLcVOLmAjHZolOYlxmTGXVZdAnRfXQm+7SoU4mXaOaiZsEpC9FkiQs6Z6bdCnNYruM6KNBw6YWewzl9z+sHImEWv+vz7XmT95f/+3/+7wP814v+efDOV3/SoX3lHAA+6ODeUFfMfjOWMADgdk+eEKz0pF7aQdMAcOKLLjrcVMVX3xdWN29/GhYP7SvnP0cWfS8caSkfHZsPE9Fgnt02JNutQ0QYHB2dDz9/pKX8QjjuO9xUxd/66HdxTeCHZ3rojQObGQBcuNjfplkD3b19Y/6MrimSaKgSMmpGU5WevmE/swa6Oy73tQHA0Rdr2Mmv/6A1n9w9suQ7097Z9lM4FlTgTDrzZTu4StXVfpiI48rVcUDM5cmEksrFnHxfpTtU/3BFQzCQF/2bYVoNbH7zmItbSoMj40JSzmMyX5qDvriA7QdrIIpA+3cdsMpu0nXI8cV0MtKXCPZev+gCEM1S2NHPvWfP/hL+7FSr3+0p5RBEyhEN5JCKYr8XnASMT0xBNyzQGQeI8fjsGD39RMPk7se2bd5ZtTyoFYXftF6y37gx7NeUtJJOTFlAHDZLDuILU3j3+H5oOrD3yWbIztugaAzgnBKJuBLpGfQrS8wO4FZgV+c1IxaLgWVU0tMLEETCos4xMzEIv9cJXQcyagIwigDGwJgOAtHAwAhisQUjy0ORGERiELgG4iakkzo4MYAxcM5hAMi1WWG1yYCJIcMUaBkVRLdGeSU2995TLWzcUAzONJ7J6FBVBYIggMzmFbvdBV44Corg8vjhzC+EJEl8U1kJtgYrhCzgc/vvTwXKSib1paRFVRVORDAJAsw5FuTaJEhWM2SHB3mOAlhkNxwuLzeJsGwqWzf5TFNdKgtY5qHp6ZFf67Y/sAVadCaVY5YACDDb3Oi4NIjLnWMw2QthCBIsVhsUTU9tvXsjeq9+X1d75/KEs4LNOfcdf/+HthMnvwxOD0wmHaXr7ZItn2wuH2SnBzbZAbPJwpPx+VQuzcm7dgRCB57a1uBzUDRL4bfnI0RE0eaXd9W89mpjqHZnUI5Hh2l2dkZZUhOqpi2qSmpOmZ64Tuu9qlz/SEXo6MEHa3wOip46F1n7633eekV8ds8Wxjn37Wl63VVa+ej5oeEZ/82ZBETJjpJ1Rbij2D3Z/1trXUvLsblCK0XfOx0SX2kMsn9dX+d+7Kf6h8o4AIykuffjT8L20LU+w4AZd5VvEPY+XpWqLV327HR7DzXuDnD8r+ovkBehJ8i+y8YAAAAASUVORK5CYII=)" }, warn: { "color": "#C09853", "background-color": "#FCF8E3", "border-color": "#FBEED5", "background-image": "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAMAAAC6V+0/AAABJlBMVEXr6eb/2oD/wi7/xjr/0mP/ykf/tQD/vBj/3o7/uQ//vyL/twebhgD/4pzX1K3z8e349vK6tHCilCWbiQymn0jGworr6dXQza3HxcKkn1vWvV/5uRfk4dXZ1bD18+/52YebiAmyr5S9mhCzrWq5t6ufjRH54aLs0oS+qD751XqPhAybhwXsujG3sm+Zk0PTwG6Shg+PhhObhwOPgQL4zV2nlyrf27uLfgCPhRHu7OmLgAafkyiWkD3l49ibiAfTs0C+lgCniwD4sgDJxqOilzDWowWFfAH08uebig6qpFHBvH/aw26FfQTQzsvy8OyEfz20r3jAvaKbhgG9q0nc2LbZxXanoUu/u5WSggCtp1anpJKdmFz/zlX/1nGJiYmuq5Dx7+sAAADoPUZSAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfdBgUBGhh4aah5AAAAlklEQVQY02NgoBIIE8EUcwn1FkIXM1Tj5dDUQhPU502Mi7XXQxGz5uVIjGOJUUUW81HnYEyMi2HVcUOICQZzMMYmxrEyMylJwgUt5BljWRLjmJm4pI1hYp5SQLGYxDgmLnZOVxuooClIDKgXKMbN5ggV1ACLJcaBxNgcoiGCBiZwdWxOETBDrTyEFey0jYJ4eHjMGWgEAIpRFRCUt08qAAAAAElFTkSuQmCC)" } } }); $(function() { insertCSS(coreStyle.css).attr("id", "core-notify"); $(document).on("click", "." + pluginClassName + "-hidable", function(e) { $(this).trigger("notify-hide"); }); $(document).on("notify-hide", "." + pluginClassName + "-wrapper", function(e) { var elem = $(this).data(pluginClassName); if(elem) { elem.show(false); } }); }); })); ================================================ FILE: assets/logcat.css ================================================ .logcat { font-family: "Courier New", Courier, monospace; border-collapse: separate; border-spacing: 4px 0px; background-color: black; color: white; width: 100%; border-radius: 5px; font-size: 10px; } .logcat-lineno { vertical-align: text-top; } .logcat-tag { color: red; text-align: right; vertical-align: text-top; width: 10em; font-weight: 500; font-family: Consolas; } .logcat-level { text-align: center; vertical-align: text-top; color: white; background-color: green; width: 1.5em; } .logcat-content { word-break: normal; white-space: pre-line; } .nav-tabs>li.follow-log { display: block; border-color: green; border-width: 1.5mm; border-style: solid; border-radius: 50%; width: 6mm; height: 6mm; opacity: 0.7; background: white; position: relative; float: right; top: 60px; right: 50px; cursor: pointer; /* pointer-events: none; */ /* left: 100mm; */ } .follow-log .hover-content { display: none; background: gray; color: white; width: 12em; margin-left: -10.5em; margin-top: -0.5em; padding: 2px; border-radius: 2px; text-align: center; } .follow-log:hover .hover-content { display: block; } ================================================ FILE: assets/remote.css ================================================ * { margin: 0px; padding: 0px; } .color-red { color: red; } .color-green { color: green; } .color-blue { color: blue; } .description { margin-top: 10px; margin-left: 5px; font-size: 0.8em; color: gray; } html, body { height: 100%; width: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol" /*display: flex;*/ } .finger { position: absolute; border-style: solid; border-radius: 50%; border-color: white; border-width: 0mm; width: 6mm; height: 6mm; top: -3mm; left: -3mm; opacity: 0.7; pointer-events: none; background: red; /*#464646;*/ /*background: red;*/ display: none; } .finger.active { display: block; border-color: #464646; border-width: 1mm; } #app { width: 100%; height: 100%; display: flex; flex-direction: column; } #left { /*height: 100%;*/ display: flex; flex-direction: column; width: 700px; } .editor-container { position: relative; min-height: 100px; padding: 0px; margin: 0px; flex: 1; } #right { display: flex; flex-direction: column; flex: 1; overflow-y: auto; overflow-x: hidden; /*border: 1px solid red;*/ /*position: relative;*/ } section { /*border: 1px solid red;*/ } #screen { display: flex; flex-direction: row; align-items: center; justify-content: center; flex: 1; background-color: gray; border-top: 1px solid darkgray; border-bottom: 1px solid darkgray; } #footer { height: 50px; display: flex; justify-content: space-around; } #footer>button { flex: 1; } .box { position: relative; display: flex; flex: 1; } #editor { position: absolute; top: 0; right: 0; bottom: 0; left: 0; } #console { height: 150px; /*border: 1px solid green;*/ background-color: #eee; word-break: break-all; padding: 0px; position: relative; background-color: #f5f5f5; overflow: auto; } #console>pre { font-family: "Courier New"; font-size: 13px; padding: 2px 5px; /*height: 100%;*/ border: 0px; } #console>img { position: absolute; left: 0px; top: 0px; } #upper { width: 100%; display: flex; flex: 1; border-top: 1px solid black; } #toolbar { border: 1px solid green; } .vertical-gap { width: 5px; background-color: #444444; } .vertical-gap:hover { cursor: col-resize; background-color: black; } .horizon-gap { height: 5px; background-color: #444444; } .horizon-gap:hover { cursor: row-resize; background-color: black; } .canvas-fg { z-index: 1; position: absolute; } .canvas-bg { z-index: 0; position: absolute; } .table-weditor { word-wrap: break-word; table-layout: fixed; } i.inactive { opacity: 0.5; } .cursor-pointer { cursor: pointer; } ================================================ FILE: assets/remote.js ================================================ /* Javascript */ $(function () { $('.btn-copy') .mouseleave(function () { var $element = $(this); $element.tooltip('hide').tooltip('disable'); }) var clipboard = new Clipboard('.btn-copy'); clipboard.on('success', function (e) { $(e.trigger) .attr('title', 'Copied') .tooltip('fixTitle') .tooltip('enable') .tooltip('show'); }) $('[data-toggle=tooltip]').tooltip({ trigger: 'hover', }); }) window.app = new Vue({ el: '#app', data: { deviceUdid: deviceUdid, device: { ip: deviceIp, port: 7912, }, deviceInfo: {}, fixConsole: '', // log for fix minicap and rotation navtabs: { active: location.hash.slice(1) || 'home', tabs: [], }, error: '', control: null, loading: false, canvas: { bg: null, fg: null, }, canvasStyle: { opacity: 1, width: 'inherit', height: 'inherit' }, lastScreenSize: { screen: {}, canvas: { width: 1, height: 1 } }, screenWS: null, browserURL: "", logcat: { follow: true, tagColors: {}, lineNumber: 0, maxKeep: 1500, cachedScrollTop: 0, logs: [{ lineno: 1, tag: "EsService2", level: "W", content: "loaded /system/lib/egl/libEGL_adreno200.so", }] }, imageBlobBuffer: [], videoUrl: '', videoReceiver: null, // sub function to receive image inputText: '', inputWS: null, }, watch: {}, computed: { deviceUrl: function () { return "http://" + this.device.ip + ":" + this.device.port; } }, mounted: function () { var URL = window.URL || window.webkitURL; var currentSize = null; var self = this; $.notify.defaults({ className: "success" }); this.canvas.bg = document.getElementById('bgCanvas') this.canvas.fg = document.getElementById('fgCanvas') // this.canvas = c; window.c = this.canvas.bg; var ctx = c.getContext('2d') $(window).resize(function () { self.resizeScreen(); }) this.initDragDealer(); // get device info $.ajax({ url: this.deviceUrl + "/info", // "/devices/" + this.deviceUdid + "/info", dateType: "json" }).then(function (ret) { this.deviceInfo = ret; document.title = ret.model; }.bind(this)) this.reserveDevice() .then(function () { this.enableTouch(); this.openScreenStream(); }.bind(this)) // wakeup device on connect setTimeout(function () { this.keyevent("WAKEUP"); }.bind(this), 1) window.k = setTimeout(function () { var lineno = (this.logcat.lineNumber += 1); this.logcat.logs.push({ lineno: lineno, tag: "EsService2", level: "W", content: "loaded /system/lib/egl/libEGL_adreno200.so", }); if (this.logcat.follow) { // only keep maxKeep lines var maxKeep = Math.max(20, this.logcat.maxKeep); var size = this.logcat.logs.length; this.logcat.logs = this.logcat.logs.slice(size - maxKeep, size); // scroll to end var el = this.$refs.tab_content; var logcat = this.logcat; if (el.scrollTop < logcat.cachedScrollTop) { this.logcat.follow = false; } else { setTimeout(function () { logcat.cachedScrollTop = el.scrollTop = el.scrollHeight - el.clientHeight; }, 2); } } }.bind(this), 200) this.inputWS = new WebSocket("ws://" + this.device.ip + ":" + this.device.port + "/whatsinput"); this.inputWS.onmessage = function (message) { // console.log(message) var data = JSON.parse(message.data) if (data.type == "InputStart") { this.inputText = data.text; } else { console.log(data) } }.bind(this); }, watch: { inputText: function (newText) { console.log(newText); this.inputWS.send(JSON.stringify({ type: "InputEdit", text: newText })) } }, methods: { reserveDevice: function () { var dtd = $.Deferred(); var ws = new WebSocket("ws://" + location.host + "/devices/" + this.deviceUdid + "/reserved") ws.onmessage = function (message) { console.log("WebSocket receive", message) } var key = setInterval(function () { ws.send("ping") }, 5000); ws.onopen = function () { dtd.resolve(); } ws.onerror = function (err) { console.log("WebSocket Error " + err) } ws.onclose = function () { dtd.reject(); clearInterval(key); console.log("websocket reserved closed"); } return dtd.promise(); }, connectImage2VideoWebSocket: function (fps) { var protocol = location.protocol == "http:" ? "ws:" : "wss:"; var wsURL = protocol + location.host + "/video/convert" var wsQueries = encodeURI("fps=" + fps) + "&" + encodeURI("udid=" + this.deviceUdid) + "&" + encodeURI("name=" + this.deviceInfo.model) var ws = new WebSocket(wsURL + "?" + wsQueries) var def = $.Deferred() ws.onopen = function () { def.resolve(this) } ws.onclose = function (ev) { def.reject("Somehow ws disconnected") } return def.promise(); }, startLowQualityScreenRecord: function (event) { $(event.target).notify("初始化中 ..."); this.connectImage2VideoWebSocket(2) .done(function (ws) { $(event.target).notify("视频录制中, 再次点击停止"); var key = setInterval(function () { $.ajax({ url: this.deviceUrl + "/screenshot/0?thumbnail=800x800", method: "get", processData: false, cache: false, xhr: function () { var xhr = new XMLHttpRequest(); xhr.responseType = "blob" return xhr; }, success: function (data) { ws.send(data) console.log("screenshot") } }) }.bind(this), 1000) this.videoReceiver = { ws: ws, key: key, } }.bind(this)) .fail(function (err) { $(event.target).notify("录制启动失败, 请点击【关于我们】,联系网站管理员", "error"); }) }, startVideoRecord: function (event) { $(event.target).notify("初始化中 ..."); this.connectImage2VideoWebSocket(10) .done(function (ws) { $(event.target).notify("视频录制中, 再次点击停止"); var cache = {} function receiver(_, data) { cache.last = data; } var key = setInterval(function () { var lastData = cache.last; cache.last = null; if (lastData) { ws.send(lastData) } }, 1000 / 6) // fps: 6 receiver.ws = ws; receiver.key = key; $.subscribe('imagedata', receiver) this.videoReceiver = receiver; }.bind(this)) .fail(function (err) { $(event.target).notify("录制启动失败, 请点击【关于我们】,联系网站管理员", "error"); }) }, stopVideoRecord: function () { if (this.videoReceiver) { $.unsubscribe("imagedata", this.videoReceiver); this.videoReceiver.ws.close() clearInterval(this.videoReceiver.key); this.videoReceiver = null; $(event.target).notify("视频录制成功"); } }, toggleScreen: function () { if (this.screenWS) { this.screenWS.close(); this.canvasStyle.opacity = 0; this.screenWS = null; } else { this.openScreenStream(); this.canvasStyle.opacity = 1; } }, saveShortVideo: function (event) { var fd = new FormData(); this.imageBlobBuffer.forEach(function (blob) { fd.append('file', blob); }); $(event.target).notify("视频后台合成中,请稍候 ..."); console.log("upload") $.ajax({ type: "post", url: "http://10.246.46.160:7000/img2video", // TODO: 临时地址,需要后期更换 processData: false, contentType: false, data: fd, dateType: 'json', }).done(function (data) { console.log(data.url); this.videoUrl = data.url; $(event.target).notify("合成完毕"); }.bind(this)) }, saveScreenshot: function () { $.ajax({ url: this.deviceUrl + "/screenshot", cache: false, xhrFields: { responseType: 'blob' }, }).then(function (blob) { saveAs(blob, "screenshot.jpg") // saveAs require FileSaver.js }) }, openBrowser: function (url) { if (!/^https?:\/\//.test(url)) { url = "http://" + url; } return this.shell("am start -a android.intent.action.VIEW -d " + url); }, uploadFile: function (event) { var formData = new FormData(event.target); $(event.target).notify("Uploading ..."); $.ajax({ method: "post", url: this.deviceUrl + "/upload/sdcard/tmp/", data: formData, processData: false, contentType: false, enctype: 'multipart/form-data', }) .then(function (ret) { $(event.target).notify("Upload success"); }, function (err) { $(event.target).notify("Upload failed:" + err.responseText, "error") console.error(err) }) }, addTabItem: function (item) { this.navtabs.tabs.push(item); }, changeTab: function (tabId) { location.hash = tabId; }, fixRotation: function () { $.ajax({ url: this.deviceUrl + "/info/rotation", method: "post", }).then(function (ret) { console.log("rotation fixed") }) }, fixMinicap: function () { this.fixConsole = "remove old minicap"; $.ajax({ method: "post", url: this.deviceUrl + "/shell", data: { command: "rm -f /data/local/tmp/minicap /data/local/tmp/minicap.so" } }) .then(function () { this.fixConsole = "download mincap to device ..." return $.ajax({ url: this.deviceUrl + "/minicap", method: "put", }) }.bind(this)) .then(function () { this.fixConsole = "minicap fixed" }.bind(this), function () { this.fixConsole = "minicap can not be fixed, open Browser Console for more detail" }.bind(this)) }, tabScroll: function (ev) { // var el = ev.target; // var el = this.$refs.tab_content; // var bottom = (el.scrollTop == (el.scrollHeight - el.clientHeight)); // console.log("Bottom", bottom, el.scrollTop, el.scrollHeight, el.clientHeight, el.scrollHeight - el.clientHeight) // console.log(ev.target.scrollTop, ev.target.scrollHeight, ev.target.clientHeight); this.logcat.follow = false; }, followLog: function () { this.logcat.follow = !this.logcat.follow; if (this.logcat.follow) { var el = this.$refs.tab_content; el.scrollTop = el.scrollHeight - el.clientHeight; } }, logcatTag2Color: function (tag) { var color = this.logcat.tagColors[tag]; if (!color) { color = this.logcat.tagColors[tag] = getRandomRgb(5); } return color; }, logcatLevel2Color: function (level) { switch (level) { case "W": return "goldenrod"; case "I": return "darkgreen"; case "D": return "gray"; default: return "gray"; } }, hold: function (msecs) { this.control.touchDown(0, 0.5, 0.5, 5, 0.5) this.control.touchCommit(); this.control.touchWait(msecs); this.control.touchUp(0) this.control.touchCommit(); }, keyevent: function (meta) { console.log("keyevent", meta) return this.shell("input keyevent " + meta.toUpperCase()); }, shell: function (command) { return $.ajax({ url: this.deviceUrl + "/shell", method: "post", data: { command: command, }, success: function (ret) { console.log(ret); }, error: function (ret) { console.log(ret) } }) }, showError: function (error) { this.loading = false; this.error = error; $('.modal').modal('show'); }, showAjaxError: function (ret) { if (ret.responseJSON && ret.responseJSON.description) { this.showError(ret.responseJSON.description); } else { this.showError("

Local server not started, start with

$ python -m weditor
"); } }, initDragDealer: function () { var self = this; var updateFunc = null; function dragMoveListener(evt) { evt.preventDefault(); updateFunc(evt); self.resizeScreen(); } function dragStopListener(evt) { document.removeEventListener('mousemove', dragMoveListener); document.removeEventListener('mouseup', dragStopListener); document.removeEventListener('mouseleave', dragStopListener); } $('#vertical-gap1').mousedown(function (e) { e.preventDefault(); updateFunc = function (evt) { $("#left").width(evt.clientX); } document.addEventListener('mousemove', dragMoveListener); document.addEventListener('mouseup', dragStopListener); document.addEventListener('mouseleave', dragStopListener) }); }, resizeScreen: function (img) { // check if need update if (img) { if (this.lastScreenSize.canvas.width == img.width && this.lastScreenSize.canvas.height == img.height) { return; } } else { img = this.lastScreenSize.canvas; if (!img) { return; } } var screenDiv = document.getElementById('screen'); this.lastScreenSize = { canvas: { width: img.width, height: img.height }, screen: { width: screenDiv.clientWidth, height: screenDiv.clientHeight, } } var canvasAspect = img.width / img.height; var screenAspect = screenDiv.clientWidth / screenDiv.clientHeight; if (canvasAspect > screenAspect) { Object.assign(this.canvasStyle, { width: Math.floor(screenDiv.clientWidth) + 'px', //'100%', height: Math.floor(screenDiv.clientWidth / canvasAspect) + 'px', // 'inherit', }) } else if (canvasAspect < screenAspect) { Object.assign(this.canvasStyle, { width: Math.floor(screenDiv.clientHeight * canvasAspect) + 'px', //'inherit', height: Math.floor(screenDiv.clientHeight) + 'px', //'100%', }) } }, delayReload: function (msec) { setTimeout(this.screenDumpUI, msec || 1000); }, drawBlobImageToScreen: function (blob) { // Support jQuery Promise var dtd = $.Deferred(); var bgcanvas = this.canvas.bg, fgcanvas = this.canvas.fg, ctx = bgcanvas.getContext('2d'), self = this, URL = window.URL || window.webkitURL, BLANK_IMG = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', img = this.imagePool.next(); img.onload = function () { console.log("image") fgcanvas.width = bgcanvas.width = img.width fgcanvas.height = bgcanvas.height = img.height ctx.drawImage(img, 0, 0, img.width, img.height); self.resizeScreen(img); // Try to forcefully clean everything to get rid of memory // leaks. Note self despite this effort, Chrome will still // leak huge amounts of memory when the developer tools are // open, probably to save the resources for inspection. When // the developer tools are closed no memory is leaked. img.onload = img.onerror = null img.src = BLANK_IMG img = null blob = null URL.revokeObjectURL(url) url = null dtd.resolve(); } img.onerror = function () { // Happily ignore. I suppose this shouldn't happen, but // sometimes it does, presumably when we're loading images // too quickly. // Do the same cleanup here as in onload. img.onload = img.onerror = null img.src = BLANK_IMG img = null blob = null URL.revokeObjectURL(url) url = null dtd.reject(); } var url = URL.createObjectURL(blob) img.src = url; return dtd; }, openScreenStream: function () { var self = this; var BLANK_IMG = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' var protocol = location.protocol == "http:" ? "ws://" : "wss://" var ws = new WebSocket(this.deviceUrl.replace("http:", "ws:") + '/minicap'); var canvas = document.getElementById('bgCanvas') var ctx = canvas.getContext('2d'); var lastScreenSize = { screen: {}, canvas: {} }; this.screenWS = ws; var imagePool = new ImagePool(100); ws.onopen = function (ev) { console.log('screen websocket connected') }; // FIXME(ssx): use pubsub is better var imageBlobBuffer = self.imageBlobBuffer; var imageBlobMaxLength = 300; ws.onmessage = function (message) { if (message.data instanceof Blob) { console.log("image received"); $.publish("imagedata", message.data); var blob = new Blob([message.data], { type: 'image/jpeg' }) imageBlobBuffer.push(blob); if (imageBlobBuffer.length > imageBlobMaxLength) { imageBlobBuffer.shift(); } var img = imagePool.next(); img.onload = function () { canvas.width = img.width canvas.height = img.height ctx.drawImage(img, 0, 0, img.width, img.height); self.resizeScreen(img); // Try to forcefully clean everything to get rid of memory // leaks. Note self despite this effort, Chrome will still // leak huge amounts of memory when the developer tools are // open, probably to save the resources for inspection. When // the developer tools are closed no memory is leaked. img.onload = img.onerror = null img.src = BLANK_IMG img = null blob = null URL.revokeObjectURL(url) url = null } img.onerror = function () { // Happily ignore. I suppose this shouldn't happen, but // sometimes it does, presumably when we're loading images // too quickly. // Do the same cleanup here as in onload. img.onload = img.onerror = null img.src = BLANK_IMG img = null blob = null URL.revokeObjectURL(url) url = null } var url = URL.createObjectURL(blob) img.src = url; } else if (/^data size:/.test(message.data)) { // console.log("receive message:", message.data) } else if (/^rotation/.test(message.data)) { self.rotation = parseInt(message.data.substr('rotation '.length), 10); console.log(self.rotation) } else { console.log("receive message:", message.data) } } ws.onclose = function (ev) { console.log("screen websocket closed", ev.code) }.bind(this) ws.onerror = function (ev) { console.log("screen websocket error") } }, enableTouch: function () { /** * TOUCH HANDLING */ var self = this; var element = this.canvas.fg; var screen = { bounds: {} } var ws = new WebSocket(this.deviceUrl.replace("http:", "ws:") + "/minitouch") ws.onerror = function (ev) { console.log("minitouch websocket error:", ev) } ws.onmessage = function (ev) { console.log("minitouch websocket receive message:", ev.data); } ws.onclose = function () { console.log("minitouch websocket closed"); } var control = this.control = MiniTouch.createNew(ws); function calculateBounds() { var el = element; screen.bounds.w = el.offsetWidth screen.bounds.h = el.offsetHeight screen.bounds.x = 0 screen.bounds.y = 0 while (el.offsetParent) { screen.bounds.x += el.offsetLeft screen.bounds.y += el.offsetTop el = el.offsetParent } } function activeFinger(index, x, y, pressure) { var scale = 0.5 + pressure $(".finger-" + index) .addClass("active") .css("transform", 'translate3d(' + x + 'px,' + y + 'px,0)') } function deactiveFinger(index) { $(".finger-" + index).removeClass("active") } function mouseDownListener(event) { var e = event; if (e.originalEvent) { e = e.originalEvent } // Skip secondary click if (e.which === 3) { return } e.preventDefault() fakePinch = e.altKey calculateBounds() var x = e.pageX - screen.bounds.x var y = e.pageY - screen.bounds.y var pressure = 0.5 activeFinger(0, e.pageX, e.pageY, pressure); var scaled = coords(screen.bounds.w, screen.bounds.h, x, y, self.rotation); control.touchDown(0, scaled.xP, scaled.yP, pressure); control.touchCommit(); element.removeEventListener('mousemove', mouseHoverListener); element.addEventListener('mousemove', mouseMoveListener); document.addEventListener('mouseup', mouseUpListener); } function mouseMoveListener(event) { var e = event if (e.originalEvent) { e = e.originalEvent } // Skip secondary click if (e.which === 3) { return } e.preventDefault() var pressure = 0.5 activeFinger(0, e.pageX, e.pageY, pressure); var x = e.pageX - screen.bounds.x var y = e.pageY - screen.bounds.y var scaled = coords(screen.bounds.w, screen.bounds.h, x, y, self.rotation); control.touchMove(0, scaled.xP, scaled.yP, pressure); control.touchCommit(); } function mouseUpListener(event) { var e = event if (e.originalEvent) { e = e.originalEvent } // Skip secondary click if (e.which === 3) { return } e.preventDefault() control.touchUp(0) control.touchCommit(); stopMousing() } function stopMousing() { element.removeEventListener('mousemove', mouseMoveListener); // element.addEventListener('mousemove', mouseHoverListener); document.removeEventListener('mouseup', mouseUpListener); deactiveFinger(0); } function mouseHoverListener(event) { var e = event; if (e.originalEvent) { e = e.originalEvent } // Skip secondary click if (e.which === 3) { return } e.preventDefault() var x = e.pageX - screen.bounds.x var y = e.pageY - screen.bounds.y } function markPosition(pos) { var ctx = self.canvas.fg.getContext("2d"); ctx.fillStyle = '#ff0000'; // red ctx.beginPath() ctx.arc(pos.x, pos.y, 12, 0, 2 * Math.PI) ctx.closePath() ctx.fill() ctx.fillStyle = "#fff"; // white ctx.beginPath() ctx.arc(pos.x, pos.y, 8, 0, 2 * Math.PI) ctx.closePath() ctx.fill(); } var wheelTimer, fromYP; function mouseWheelDelayTouchUp() { clearTimeout(wheelTimer); wheelTimer = setTimeout(function () { fromYP = null; control.touchUp(1) control.touchCommit(); // deactiveFinger(0); // deactiveFinger(1); }, 100) } function mouseWheelListener(event) { var e = event; if (e.originalEvent) { e = e.originalEvent } e.preventDefault() calculateBounds() var x = e.pageX - screen.bounds.x var y = e.pageY - screen.bounds.y var pressure = 0.5; var scaled; if (!fromYP) { fromYP = y / screen.bounds.h; // display Y percent // touch down when first detect mousewheel scaled = coords(screen.bounds.w, screen.bounds.h, x, y, self.rotation); control.touchDown(1, scaled.xP, scaled.yP, pressure); control.touchCommit(); // activeFinger(0, e.pageX, e.pageY, pressure); } // caculate position after scroll var toYP = fromYP + (event.wheelDeltaY < 0 ? -0.05 : 0.05); toYP = Math.max(0, Math.min(1, toYP)); var step = Math.max((toYP - fromYP) / 5, 0.01) * (event.wheelDeltaY < 0 ? -1 : 1); for (var yP = fromYP; yP < 1 && yP > 0 && Math.abs(yP - toYP) > 0.0001; yP += step) { y = screen.bounds.h * yP; var pageY = y + screen.bounds.y; scaled = coords(screen.bounds.w, screen.bounds.h, x, y, self.rotation); // activeFinger(1, e.pageX, pageY, pressure); control.touchMove(1, scaled.xP, scaled.yP, pressure); control.touchWait(10); control.touchCommit(); } fromYP = toYP; mouseWheelDelayTouchUp() } /* bind listeners */ element.addEventListener('mousedown', mouseDownListener); // element.addEventListener('mousemove', mouseHoverListener); element.addEventListener('mousewheel', mouseWheelListener); } } }) ================================================ FILE: assets/style.css ================================================ body { font-family: "Segoe UI", Arial, "Microsoft Yahei", sans-serif; } .color-inverse { color: white; background-color: black; } .color-green { color: green; } .color-red { color: red; } .color-rest { color: rgb(74, 201, 89); } .color-busy { color: tomato; } .color-yellow { color: yellowgreen; } .fa-fix-height { line-height: 24px; } .fa-battery-4 { color: green; } .fa-battery-3 { color: yellowgreen; } .fa-battery-2 { color: yellow; } .fa-battery-1 { color: red; } .fa-battery-0 { color: red; } tr.offline { /* background-color: red; */ color: gray; } ================================================ FILE: assets/vue-components.js ================================================ /* require fontawesome */ /* Example: */ Vue.component('editable-span', { template: `
`, props: ['content'], data: function () { return { editMode: false, newContent: "", } }, methods: { editContent: function () { this.newContent = this.content; this.editMode = true; this.$nextTick(function () { this.$refs.ipt.focus() }.bind(this)) }, saveContent: function () { this.editMode = false; this.$emit("change", this.newContent); } } }) ================================================ FILE: database.go ================================================ package main import ( "context" "encoding/json" "strings" "time" "github.com/openatx/atx-server/proto" "github.com/qiniu/log" r "gopkg.in/gorethink/gorethink.v4" ) var ( db *RdbUtils ) func initDB(address, dbName string) { r.SetTags("gorethink", "json") r.SetVerbose(true) session, err := r.Connect(r.ConnectOpts{ Address: address, Database: dbName, InitialCap: 1, MaxOpen: 10, }) if err != nil { log.Fatal(err) } db = &RdbUtils{session} // initial state if err := db.DBCreateAnyway(dbName); err != nil { panic(err) } log.Println("create tables") db.TableMustCreate("devices", r.TableCreateOpts{ PrimaryKey: "udid", }) db.TableMustCreate("products") db.TableMustCreate("providers") r.Table("devices").Update(map[string]interface{}{ "present": false, "using": false, "provider_id": 0, }).Exec(session) if err := r.Table("devices").IndexCreate("provider_id", r.IndexCreateOpts{}).Exec(session); err != nil { log.Println("create index", err) } r.Table("providers").Update(proto.Provider{ Present: newBool(false), }).Exec(session) } type RdbUtils struct { session *r.Session } func (db *RdbUtils) DBCreateAnyway(name string) error { res, err := r.DBList().Run(db.session) if err != nil { return err } defer res.Close() var dbNames []string if err := res.All(&dbNames); err != nil { return err } for _, dbName := range dbNames { log.Println("found db:", dbName) if dbName == name { log.Println("db exists atxserver") return nil } } err = r.DBCreate(name).Exec(db.session) return err } func (db *RdbUtils) TableMustCreate(name string, optArgs ...r.TableCreateOpts) { if err := db.TableCreateAnyway(name, optArgs...); err != nil { panic(err) } } func (db *RdbUtils) TableCreateAnyway(name string, optArgs ...r.TableCreateOpts) error { err := r.TableCreate(name, optArgs...).Exec(db.session) if err != nil && strings.Contains(err.Error(), "already exists") { return nil } return err } // DeviceUpdateOrInsert called when device plugin func (db *RdbUtils) DeviceUpdateOrInsert(dev proto.DeviceInfo) error { dev.Present = newBool(true) dev.PresenceChangedAt = time.Now() // only update when create dev.Ready = newBool(false) dev.Using = newBool(false) dev.CreatedAt = time.Now() _, err := r.Table("devices").Insert(dev, r.InsertOpts{ Conflict: func(id, oldDoc, newDoc r.Term) interface{} { return oldDoc.Merge(newDoc.Without("createdAt", "ready", "using")).Merge(map[string]interface{}{ "createdAt": oldDoc.Field("createdAt").Default(time.Now()), "ready": oldDoc.Field("ready").Default(false), "using": oldDoc.Field("using").Default(false), }) }, }).RunWrite(db.session) return err } func (db *RdbUtils) DeviceUpdate(udid string, arg interface{}) error { _, err := r.Table("devices").Get(udid).Update(arg).RunWrite(db.session) return err } func (db *RdbUtils) DeviceList() (devices []proto.DeviceInfo, err error) { res, err := r.Table("devices"). OrderBy(r.Desc("present"), r.Desc("ready"), r.Desc("using"), r.Desc("presenceChangedAt")). Merge(func(p r.Term) interface{} { return map[string]interface{}{ "product_id": r.Table("products").Get(p.Field("product_id").Default(0)), "provider_id": r.Table("providers").Get(p.Field("provider_id").Default(0)), } }).Run(db.session) if err != nil { log.Error(err) return } defer res.Close() err = res.All(&devices) return } func (db *RdbUtils) DeviceGet(udid string) (info proto.DeviceInfo, err error) { res, err := r.Table("devices").Get(udid). Merge(func(p r.Term) interface{} { return map[string]interface{}{ "product_id": r.Table("products").Get(p.Field("product_id").Default(0)), "provider_id": r.Table("providers").Get(p.Field("provider_id").Default(0)), } }).Run(db.session) if err != nil { return } defer res.Close() err = res.One(&info) return } func (db *RdbUtils) DeviceFindAll(info proto.DeviceInfo) (infos []proto.DeviceInfo) { infojson, _ := json.Marshal(info) log.Debugf("query %s", string(infojson)) res, err := r.Table("devices").Filter(info). Merge(func(p r.Term) interface{} { return map[string]interface{}{ "product_id": r.Table("products").Get(p.Field("product_id").Default(0)), "provider_id": r.Table("providers").Get(p.Field("provider_id").Default(0)), } }).Run(db.session) if err != nil { log.Error(err) return nil } defer res.Close() if err := res.All(&infos); err != nil { log.Error(err) } return } // ProviderFindAll get all providers func (db *RdbUtils) ProvidersAll() (providers []proto.Provider, err error) { res, err := r.Table("providers").OrderBy(r.Desc("present"), "id"). Merge(func(p r.Term) interface{} { return map[string]interface{}{ "devices": r.Table("devices"). GetAllByIndex("provider_id", p.Field("id")). Without("product_id", "provider_id", "battery").CoerceTo("array"), } }).Run(db.session) if err != nil { return nil, err } defer res.Close() err = res.All(&providers) return } // SetDevicePresent change present status func (db *RdbUtils) SetDeviceAbsent(udid string) error { log.Debugf("device absent: %s", udid) return db.DeviceUpdate(udid, proto.DeviceInfo{ Present: newBool(false), PresenceChangedAt: time.Now(), }) } func (db *RdbUtils) WatchDeviceChanges() (feeds chan r.ChangeResponse, cancel func(), err error) { ctx, cancel := context.WithCancel(context.Background()) res, err := r.Table("devices").Changes().Run(db.session, r.RunOpts{ Context: ctx, }) if err != nil { return } feeds = make(chan r.ChangeResponse) var change r.ChangeResponse go func() { for res.Next(&change) { feeds <- change } close(feeds) }() return } func (db *RdbUtils) ProductsFindAll(brand, model string) (products []proto.Product, err error) { res, err := r.Table("products").Filter(proto.Product{Brand: brand, Model: model}).Run(db.session) if err != nil { return } if err = res.All(&products); err != nil { return } if len(products) > 0 { return } resp, err := r.Table("products").Insert(proto.Product{Brand: brand, Model: model}).RunWrite(db.session) if err != nil { return } if len(resp.GeneratedKeys) != 1 { panic("generatedKeys must be one") } return db.ProductsFindAll(brand, model) } func (db *RdbUtils) ProductUpdate(id string, product proto.Product) error { product.Id = "" _, err := r.Table("products").Get(id).Update(product).RunWrite(db.session) return err } // ProviderUpdateOrInsert will create a record if not exists func (db *RdbUtils) ProviderUpdateOrInsert(machineId string, ip string, port int) error { p := proto.Provider{ Id: machineId, IP: ip, Port: port, Present: newBool(true), CreatedAt: time.Now(), PresenceChangedAt: time.Now(), } _, err := r.Table("providers").Insert(p, r.InsertOpts{ Conflict: func(id, oldDoc, newDoc r.Term) interface{} { return oldDoc.Merge(newDoc.Without("createdAt")).Merge(map[string]interface{}{ "createdAt": oldDoc.Field("createdAt").Default(time.Now()), }) }, }).RunWrite(db.session) return err } func (db *RdbUtils) ProviderUpdate(id string, provider proto.Provider) error { provider.Id = id _, err := r.Table("providers").Get(id).Update(provider).RunWrite(db.session) return err } func (db *RdbUtils) ProviderOffline(id string) error { _, err := r.Table("providers").Get(id).Update(proto.Provider{ Present: newBool(false), }).RunWrite(db.session) if err != nil { return err } _, err = r.Table("devices").Filter(r.Row.Field("provider_id").Eq(id)).Update(map[string]interface{}{ "provider_id": 0, }).RunWrite(db.session) return err } func (db *RdbUtils) ProviderGet(id string) (provider proto.Provider, err error) { res, err := r.Table("providers").Get(id).Run(db.session) if err != nil { return } defer res.Close() err = res.One(&provider) return } ================================================ FILE: database_test.go ================================================ package main import ( "testing" r "gopkg.in/gorethink/gorethink.v4" ) func TestInsertOrUpdateDevice(t *testing.T) { mock := r.NewMock() // mock.On(r.Table("devices")). _ = mock } // func TestTableProduct(t *testing.T) { // device, err := db.DeviceGet("6EB0217607005249-c4:86:e9:53:c2:e4-DUK-AL20") // if err != nil { // t.Fatal(err) // } // t.Logf("%#v", device) // } ================================================ FILE: docker-compose.yml ================================================ version: '3' services: atxserver: build: . container_name: atxserver ports: - "8000:8000" depends_on: - rethinkdb entrypoint: ./atx-server --rdbaddr rethinkdb:28015 --port 8000 rethinkdb: image: rethinkdb:2.3.6 container_name: rethinkdb ports: - "8001:8080" # expose rethinkdb web console volumes: - "$PWD/data:/data" ================================================ FILE: go.mod ================================================ module github.com/openatx/atx-server require ( github.com/alecthomas/kingpin v2.2.6+incompatible github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 // indirect github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect github.com/codeskyblue/dingrobot v0.0.0-20171214074958-18a295f1ddd8 github.com/codeskyblue/heartbeat v0.0.0-20180510083815-41c9a36c9169 github.com/codeskyblue/realip v0.0.0-20180509031353-57e9cd075d0e github.com/golang/protobuf v1.2.0 github.com/gorilla/context v1.1.1 github.com/gorilla/mux v1.6.2 github.com/gorilla/websocket v1.4.0 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed github.com/koding/websocketproxy v0.0.0-20180716164433-0fa3f994f6e7 github.com/mash/go-accesslog v0.0.0-20180522074327-610c2be04217 github.com/mattn/go-isatty v0.0.4 github.com/openatx/androidutils v1.0.0 github.com/opentracing/opentracing-go v1.0.2 github.com/pkg/errors v0.8.0 // indirect github.com/qiniu/log v0.0.0-20140728010919-a304a74568d6 github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect golang.org/x/sys v0.0.0-20181121002834-0cf1ed9e522b gopkg.in/gorethink/gorethink.v4 v4.1.0 ) ================================================ FILE: go.sum ================================================ github.com/alecthomas/kingpin v2.2.6+incompatible h1:5svnBTFgJjZvGKyYBtMB0+m5wvrbUHiqye8wRJMlnYI= github.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/cenkalti/backoff v2.0.0+incompatible h1:5IIPUHhlnUZbcHQsQou5k1Tn58nJkeJL9U+ig5CHJbY= github.com/cenkalti/backoff v2.0.0+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/codeskyblue/dingrobot v0.0.0-20171214074958-18a295f1ddd8 h1:+Jo1zBcaPTAj7hYZh4eJhuqzOpnsuX8dgtR2YC2k2Ws= github.com/codeskyblue/dingrobot v0.0.0-20171214074958-18a295f1ddd8/go.mod h1:vQjYKkK66U6tqIBdXHZotqxqYXLfYLJsQFNSVuwLoNM= github.com/codeskyblue/heartbeat v0.0.0-20180510083815-41c9a36c9169 h1:P+SqriwlWNS+MKgPC6JTs7Lipgk+yScuxzqFAvtVXNY= github.com/codeskyblue/heartbeat v0.0.0-20180510083815-41c9a36c9169/go.mod h1:L/Vc8wjkglTA5/s1l9/4YOa8e7sOzt4WerxU1AEC0LM= github.com/codeskyblue/realip v0.0.0-20180509031353-57e9cd075d0e h1:ImKTOlij69pQ3ftNIpBCgTpz52NLJmn3NyHITlgOwVM= github.com/codeskyblue/realip v0.0.0-20180509031353-57e9cd075d0e/go.mod h1:S09jNhPIOrprdcNbJtRvu8SxrMPp61FQ2GEOm1So8u0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/koding/websocketproxy v0.0.0-20180716164433-0fa3f994f6e7 h1:UPc4az2SLy5Usu+JKfOV4KtfzuRQXXUxY6QOWf9QBJU= github.com/koding/websocketproxy v0.0.0-20180716164433-0fa3f994f6e7/go.mod h1:Nn5wlyECw3iJrzi0AhIWg+AJUb4PlRQVW4/3XHH1LZA= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mash/go-accesslog v0.0.0-20180522074327-610c2be04217 h1:oWyemD7bnPAGRGGPE22W1Z+kspkC7Uclz5rdzgxxiwk= github.com/mash/go-accesslog v0.0.0-20180522074327-610c2be04217/go.mod h1:5JLTyA+23fYz/BfD5Hn736mGEZopzWtEx1pdNfnTp8k= github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/openatx/androidutils v1.0.0 h1:gYKFX/LqOf4LxyO7dZrNfGtPNaCaSNrniUHL06MPATQ= github.com/openatx/androidutils v1.0.0/go.mod h1:Pbja6rsE71OHQMhrK/tZm86fqB9Go8sXToi9CylrXEU= github.com/opentracing/opentracing-go v1.0.2 h1:3jA2P6O1F9UOrWVpwrIo17pu01KWvNWg4X946/Y5Zwg= github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/qiniu/log v0.0.0-20140728010919-a304a74568d6 h1:pMot4yzjv8CtGQiGhRvVhwhQl3g4D0Hwkmyd0CVCiyk= github.com/qiniu/log v0.0.0-20140728010919-a304a74568d6/go.mod h1:WSWulkCEBvfLKNNmweUmJjQGNaYzdHqpTRITXdUNQiQ= github.com/sirupsen/logrus v1.0.6 h1:hcP1GmhGigz/O7h1WVUM5KklBp1JoNS9FggWKdj/j3s= github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= golang.org/x/crypto v0.0.0-20180820150726-614d502a4dac/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869 h1:kkXA53yGe04D0adEYJwEVQjeBppL01Exg+fnMjfUraU= golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a h1:gOpx8G595UYyvj8UK4+OFyY4rx037g3fmfhe5SasG3U= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180828065106-d99a578cf41b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181121002834-0cf1ed9e522b h1:fpg9kqwtLzitbbnpLJATV5Ty8sDv8sJ2ii9+e6fG89A= golang.org/x/sys v0.0.0-20181121002834-0cf1ed9e522b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fatih/pool.v2 v2.0.0 h1:xIFeWtxifuQJGk/IEPKsTduEKcKvPmhoiVDGpC40nKg= gopkg.in/fatih/pool.v2 v2.0.0/go.mod h1:8xVGeu1/2jr2wm5V9SPuMht2H5AEmf5aFMGSQixtjTY= gopkg.in/gorethink/gorethink.v4 v4.1.0 h1:xoE9qJ9Ae9KdKEsiQGCF44u2JdnjyohrMBRDtts3Gjw= gopkg.in/gorethink/gorethink.v4 v4.1.0/go.mod h1:M7JgwrUAmshJ3iUbEK0Pt049MPyPK+CYDGGaEjdZb/c= ================================================ FILE: heartbeat/heartbeat.go ================================================ /* FormValue id and port is required Client send request example $ curl -X POST -F id=cfa124af -F port=8000 */ package heartbeat import ( "io" "net/http" "strconv" "sync" "time" "github.com/tomasen/realip" ) type Server struct { sessions map[string]*Session mu sync.RWMutex receiver Receiver } // NewServer return http.Handler func NewServer(receiver Receiver) *Server { return &Server{ sessions: make(map[string]*Session), receiver: receiver, } } func (h *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { id := r.FormValue("id") if id == "" { http.Error(w, "param id is required", 400) return } port, _ := strconv.Atoi(r.FormValue("port")) if port == 0 { http.Error(w, "param port is required", 400) return } ip := r.FormValue("ip") if ip == "" { ip = realip.FromRequest(r) } h.mu.Lock() defer h.mu.Unlock() ctx := Context{ IP: ip, ID: id, Request: r, } sess, exists := h.sessions[id] if !exists { if err := h.receiver.OnConnect(ctx); err != nil { http.Error(w, err.Error(), 400) return } h.sessions[id] = &Session{ Timeout: time.Second * 15, sigC: make(chan bool), remoteIP: ip, remotePort: port, } go func() { h.sessions[id].drain() h.receiver.OnDisconnect(id) delete(h.sessions, id) }() } else { if ip != sess.remoteIP || port != sess.remotePort { sess.remoteIP = ip sess.remotePort = port if err := h.receiver.OnConnect(ctx); err != nil { http.Error(w, err.Error(), 400) return } } sess.Update() } if r.FormValue("data") == "" || r.FormValue("data") == "null" { io.WriteString(w, "success ping\n") return } if err := h.receiver.OnRequest(ctx); err != nil { http.Error(w, err.Error(), 400) } else { io.WriteString(w, "success request\n") } } // Receiver defines on request type Receiver interface { OnConnect(ctx Context) error OnDisconnect(id string) OnRequest(ctx Context) error } type Session struct { id string remoteIP string remotePort int Timeout time.Duration sigC chan bool } func (hs *Session) Update() { select { case hs.sigC <- true: case <-time.After(100 * time.Millisecond): } } func (hs *Session) drain() { for { select { case <-time.After(hs.Timeout): return case <-hs.sigC: } } } type Context struct { Request *http.Request IP string ID string } ================================================ FILE: hostsmanager.go ================================================ package main import ( "errors" "strings" "github.com/openatx/atx-server/proto" ) func deviceQueryToUdid(query string) (udid string, err error) { if strings.HasPrefix(query, "ip:") { infos := db.DeviceFindAll(proto.DeviceInfo{IP: query[3:], Present: newBool(true)}) return extractUdidFromInfos(infos) } return query, nil } func extractUdidFromInfos(infos []proto.DeviceInfo) (udid string, err error) { if len(infos) == 0 { return "", errors.New("not found") } if len(infos) > 1 { return "", errors.New("too many matches") } return infos[0].Udid, nil } // // TODO: need to delete bellow // type HostsManager struct { // maps map[string]*proto.DeviceInfo // mu sync.RWMutex // } // func NewHostsManager() *HostsManager { // return &HostsManager{ // maps: make(map[string]*proto.DeviceInfo), // } // } // func (t *HostsManager) Lookup(query string) *proto.DeviceInfo { // if strings.HasPrefix(query, "ip:") { // return t.FromIP(query[3:]) // } // return t.FromUdid(query) // } // // A return value of nil indicates not found // func (t *HostsManager) FromIP(ip string) *proto.DeviceInfo { // t.mu.Lock() // defer t.mu.Unlock() // for _, info := range t.maps { // if info.IP == ip { // return info // } // } // return nil // } // // A return value of nil indicates not found // func (t *HostsManager) FromUdid(udid string) *proto.DeviceInfo { // t.mu.Lock() // defer t.mu.Unlock() // return t.maps[udid] // } // func (t *HostsManager) AddFromDeviceInfo(devInfo *proto.DeviceInfo) { // t.mu.Lock() // defer t.mu.Unlock() // udid := devInfo.Udid // if info, ok := t.maps[udid]; ok { // info.IP = devInfo.IP // info.ConnectionCount++ // } else { // devInfo.ConnectionCount = 1 // t.maps[udid] = devInfo // } // } // func (t *HostsManager) Remove(udid string) { // t.mu.Lock() // defer t.mu.Unlock() // if info, ok := t.maps[udid]; ok { // info.ConnectionCount-- // if info.ConnectionCount <= 0 { // delete(t.maps, udid) // } // } // } // func (t *HostsManager) Acquire(query string) error { // info := t.Lookup(query) // if info == nil { // return errors.New("device not found") // } // if info.Reserved != "" { // return errors.New("device already reserved") // } // info.Reserved = "hzsunshx" // return nil // } // func (t *HostsManager) Release(query string) error { // info := t.Lookup(query) // if info == nil { // return errors.New("device not found") // } // info.Reserved = "" // return nil // } // func (t *HostsManager) Random() (devInfo *proto.DeviceInfo, err error) { // t.mu.Lock() // defer t.mu.Unlock() // for _, info := range t.maps { // if info.Ready != nil && *info.Ready == true && info.Reserved == "" { // info.Reserved = "random" // return info, nil // } // } // return nil, errors.New("no devices avaliable") // } ================================================ FILE: httplog.go ================================================ package main import ( "fmt" "log" "os" "regexp" "runtime" "strings" accesslog "github.com/mash/go-accesslog" isatty "github.com/mattn/go-isatty" ) var logger *log.Logger var isaTTY = isatty.IsTerminal(os.Stdout.Fd()) func init() { if runtime.GOOS == "windows" { logger = log.New(os.Stdout, "", log.Ltime) } else { logger = log.New(os.Stdout, "\033[0;32m[", log.Ltime) } } type HTTPLogger struct { } // Example // [I 170227 14:47:16 web:1946] 200 GET /api/v1/devices (10.240.185.65) 28.00ms func (l HTTPLogger) Log(record accesslog.LogRecord) { // update info too many just ignore if record.Method == "POST" && regexp.MustCompile(`/devices/[^/]+/info`).MatchString(record.Uri) { return } if strings.HasSuffix(record.Uri, "/heartbeat") { return } if isaTTY { logger.Println(fmt.Sprintf("\b] \033[0;m%d %s %s (%s) %.2fms", record.Status, record.Method, record.Uri, record.Ip, float64(record.ElapsedTime.Nanoseconds()/1000)/1000.0)) } else { logger.Println(fmt.Sprintf("%d %s %s (%s) %.2fms", record.Status, record.Method, record.Uri, record.Ip, float64(record.ElapsedTime.Nanoseconds()/1000)/1000.0)) } } ================================================ FILE: httpserver.go ================================================ package main import ( "bytes" "encoding/json" "errors" "fmt" "html/template" "io" "io/ioutil" "net/http" "net/http/httputil" "net/url" "os" "strconv" "strings" "time" "github.com/codeskyblue/heartbeat" "github.com/codeskyblue/realip" "github.com/gorilla/mux" "github.com/gorilla/websocket" "github.com/koding/websocketproxy" hb2 "github.com/openatx/atx-server/heartbeat" "github.com/openatx/atx-server/proto" "github.com/qiniu/log" ) var ( upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } // Time allowed to write message to the client wsWriteWait = 10 * time.Second // Send pings to client with this period. Must be less than pongWait. wsPingPeriod = 10 * time.Second // Time allowed to read the next pong message from client wsPongWait = wsPingPeriod * 3 funcMap template.FuncMap ) func init() { funcMap = template.FuncMap{ "title": strings.Title, "urlhash": func(s string) string { path := strings.TrimPrefix(s, "/") info, err := os.Stat(path) if err != nil { return s + "#no-such-file" } return fmt.Sprintf("%s?t=%d", s, info.ModTime().Unix()) }, } } func renderHTML(w http.ResponseWriter, filename string, value interface{}) { tmpl := template.Must(template.New("").Funcs(funcMap).Delims("[[", "]]").ParseGlob("templates/*.html")) tmpl.ExecuteTemplate(w, filename, value) // content, _ := ioutil.ReadFile("templates/" + filename) // template.Must(template.New(filename).Parse(string(content))).Execute(w, nil) } func renderJSON(w http.ResponseWriter, data interface{}) { js, err := json.Marshal(data) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json; charset=UTF-8") w.Header().Set("Content-Length", fmt.Sprintf("%d", len(js))) w.Write(js) } func newHandler() http.Handler { r := mux.NewRouter() r.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) { renderJSON(w, map[string]string{ "server": version, "atx-agent": atxAgentVersion, }) }) // 设备远程控制 r.HandleFunc("/devices/ip:{ip}/remote", func(w http.ResponseWriter, r *http.Request) { ip := mux.Vars(r)["ip"] renderHTML(w, "remote.html", ip) }).Methods("GET") r.HandleFunc("/devices/{udid}/remote", func(w http.ResponseWriter, r *http.Request) { udid := mux.Vars(r)["udid"] info, err := db.DeviceGet(udid) if err != nil { http.Error(w, err.Error(), 404) return } renderHTML(w, "remote.html", map[string]interface{}{ "IP": info.IP, "Port": info.Port, "Udid": udid}) }).Methods("GET") // 设备信息修改 r.HandleFunc("/devices/{udid}/edit", func(w http.ResponseWriter, r *http.Request) { udid := mux.Vars(r)["udid"] renderHTML(w, "edit.html", udid) }).Methods("GET") // Video-backend starts videoProxyURL, _ := url.Parse(*videoBackend) wsProxyURL, _ := url.Parse(*videoBackend) wsProxyURL.Scheme = "ws" videoProxy := httputil.NewSingleHostReverseProxy(videoProxyURL) wsVideoProxy := websocketproxy.NewProxy(wsProxyURL) r.PathPrefix("/videos").Handler(videoProxy).Methods("GET", "DELETE") r.Handle("/video/images2video", videoProxy) // not working with POST proxy r.PathPrefix("/static/videos/").Handler(videoProxy) r.Handle("/video/convert", wsVideoProxy) // End of video-backend r.HandleFunc("/products/{brand}/{model}", func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) brand, model := vars["brand"], vars["model"] products, err := db.ProductsFindAll(brand, model) if err != nil { http.Error(w, err.Error(), 500) return } renderJSON(w, products) }) r.HandleFunc("/devices/{udid}/product", func(w http.ResponseWriter, r *http.Request) { var product proto.Product err := json.NewDecoder(r.Body).Decode(&product) if err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } if product.Id == "" { http.Error(w, "product id is required", http.StatusForbidden) return } if err := db.ProductUpdate(product.Id, product); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } err = db.DeviceUpdate(mux.Vars(r)["udid"], proto.DeviceInfo{ Product: &proto.Product{ Id: product.Id, }, }) if err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } renderJSON(w, map[string]interface{}{ "success": true, }) }).Methods("PUT") r.HandleFunc("/echo", echo) r.HandleFunc("/feeds", func(w http.ResponseWriter, r *http.Request) { ws, err := upgrader.Upgrade(w, r, nil) if err != nil { http.Error(w, err.Error(), 500) return } defer ws.Close() feeds, cancel, err := db.WatchDeviceChanges() if err != nil { ws.WriteJSON(map[string]string{ "error": err.Error(), }) return } go func() { defer cancel() for { _, _, err := ws.ReadMessage() if err != nil { break } } log.Debug("ws read closed") }() for change := range feeds { buf := bytes.NewBuffer(nil) json.NewEncoder(buf).Encode(map[string]interface{}{ "new": change.NewValue, "old": change.OldValue, }) err := ws.WriteMessage(websocket.TextMessage, buf.Bytes()) // []byte(`{"new": "haha", "old": "wowo"}`)) if err != nil { break } } log.Debug("ws write closed") }) r.HandleFunc("/providers", func(w http.ResponseWriter, r *http.Request) { values := r.URL.Query() if _, ok := values["json"]; ok { providers, err := db.ProvidersAll() if err != nil { renderJSON(w, map[string]interface{}{ "success": false, "description": err.Error(), }) return } renderJSON(w, providers) return } renderHTML(w, "providers.html", nil) }) r.HandleFunc("/providers/{id}", func(w http.ResponseWriter, r *http.Request) { var p proto.Provider data, _ := ioutil.ReadAll(r.Body) if err := json.Unmarshal(data, &p); err != nil { renderJSON(w, map[string]interface{}{ "success": false, "description": err.Error(), }) return } id := mux.Vars(r)["id"] db.ProviderUpdate(id, p) renderJSON(w, map[string]interface{}{ "success": true, }) }).Methods("PUT") r.HandleFunc("/api/v1/batch/unlock", func(w http.ResponseWriter, r *http.Request) { batchRunCommand("am start -W --user 0 -a com.github.uiautomator.ACTION_IDENTIFY; input keyevent HOME") io.WriteString(w, "Success") }) r.HandleFunc("/api/v1/batch/lock", func(w http.ResponseWriter, r *http.Request) { batchRunCommand("input keyevent POWER") io.WriteString(w, "Success") }) r.HandleFunc("/api/v1/batch/shell", func(w http.ResponseWriter, r *http.Request) { command := r.FormValue("command") batchRunCommand(command) io.WriteString(w, "Success") }) // r.HandleFunc("/api/v1/phones/identify") r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { renderHTML(w, "index.html", nil) }) r.PathPrefix("/assets").Handler(http.StripPrefix("/assets/", http.FileServer(http.Dir("./assets")))) r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "assets/favicon.ico") }) r.HandleFunc("/list", func(w http.ResponseWriter, r *http.Request) { devices, err := db.DeviceList() if err != nil { http.Error(w, err.Error(), 500) return } renderJSON(w, devices) }) r.HandleFunc("/devices/{query}/info", func(w http.ResponseWriter, r *http.Request) { query := mux.Vars(r)["query"] udid, err := deviceQueryToUdid(query) if err != nil { io.WriteString(w, "Failure, device "+query+" not found") return } if r.Method == "GET" { info, _ := db.DeviceGet(udid) renderJSON(w, info) return } // POST var info proto.DeviceInfo if err := json.NewDecoder(r.Body).Decode(&info); err != nil { io.WriteString(w, err.Error()) return } db.DeviceUpdate(udid, info) io.WriteString(w, "Success") }).Methods("GET", "POST") r.HandleFunc("/property", func(w http.ResponseWriter, r *http.Request) { clientIp := realip.FromRequest(r) udid, err := deviceQueryToUdid("ip:" + clientIp) if err != nil { io.WriteString(w, "init with uiautomator2") return } info, err := db.DeviceGet(udid) if err != nil { http.Error(w, err.Error(), 500) return } if r.Method == "POST" { var id string = r.FormValue("id") if id == "" && r.FormValue("id_number") != "" { id = "HIH-PHO-" + r.FormValue("id_number") } db.DeviceUpdate(info.Udid, proto.DeviceInfo{ PropertyId: id, }) info.PropertyId = id io.WriteString(w, "

Updated to "+id+"

") return } renderHTML(w, "property.html", info.PropertyId) }).Methods("GET", "POST") r.HandleFunc("/devices/{query}/reserved", func(w http.ResponseWriter, r *http.Request) { query := mux.Vars(r)["query"] udid, err := deviceQueryToUdid(query) if err != nil { http.Error(w, "Device not found "+err.Error(), http.StatusGone) return } info, err := db.DeviceGet(udid) if err != nil { http.Error(w, "Device get error "+err.Error(), http.StatusGone) return } // create websocket connection ws, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println(err) return } defer ws.Close() if toBool(info.Using) { log.Printf("Device %s is using", udid) return } db.DeviceUpdate(info.Udid, proto.DeviceInfo{ Using: newBool(true), UsingBeganAt: time.Now(), Owner: &proto.OwnerInfo{ IP: realip.FromRequest(r), }, }) defer func() { db.DeviceUpdate(udid, proto.DeviceInfo{ Using: newBool(false), }) go func() { port := info.Port if port == 0 { port = 7912 } reqURL := "http://" + info.IP + ":" + strconv.Itoa(port) + "/shell" req, _ := http.NewRequest("GET", reqURL, nil) q := req.URL.Query() q.Add("command", "am start -n com.github.uiautomator/.IdentifyActivity") req.URL.RawQuery = q.Encode() resp, err := http.DefaultClient.Do(req) if err == nil { resp.Body.Close() } }() }() // wait until ws disconnected for { if _, _, err := ws.ReadMessage(); err != nil { return } } }).Methods("GET") r.HandleFunc("/devices/{query}/reserved", func(w http.ResponseWriter, r *http.Request) { query := mux.Vars(r)["query"] udid, err := deviceQueryToUdid(query) // info := hostsManager.Lookup(query) if err != nil { http.Error(w, "Device not found "+err.Error(), http.StatusGone) return } if r.Method == "POST" { info, err := db.DeviceGet(udid) if err != nil { http.Error(w, "Device get error "+err.Error(), http.StatusGone) return } if !toBool(info.Present) { http.Error(w, "Device offline", http.StatusGone) return } if toBool(info.Using) { http.Error(w, "Device is using", http.StatusForbidden) return } db.DeviceUpdate(info.Udid, proto.DeviceInfo{ Using: newBool(true), UsingBeganAt: time.Now(), Owner: &proto.OwnerInfo{ IP: realip.FromRequest(r), }, }) io.WriteString(w, "Success") return } // DELETE db.DeviceUpdate(udid, proto.DeviceInfo{ Using: newBool(false), }) io.WriteString(w, "Release success") }).Methods("POST", "DELETE") r.HandleFunc("/devices/{query}/shell", func(w http.ResponseWriter, r *http.Request) { query := mux.Vars(r)["query"] udid, err := deviceQueryToUdid(query) if err != nil { http.Error(w, "Device not found", 410) return } info, err := db.DeviceGet(udid) if err != nil { http.Error(w, "Device get error "+err.Error(), 500) return } command := r.FormValue("command") output, err := runAndroidShell(info.IP, command) if err != nil { renderJSON(w, map[string]string{ "error": err.Error(), }) } else { w.Header().Set("Content-Type", "application/json; charset=UTF-8") io.WriteString(w, output) // the output is already json } }).Methods("POST") // heartbeat for reverse proxies (adb forward device 7912 port) hbs := heartbeat.NewServer("hello kitty", 15*time.Second) hbs.OnConnect = func(identifier string, r *http.Request) { host := realip.FromRequest(r) db.DeviceUpdateOrInsert(proto.DeviceInfo{ Udid: identifier, IP: host, }) log.Println(identifier, "is online") } // Called when client ip changes hbs.OnReconnect = func(identifier string, r *http.Request) { host := realip.FromRequest(r) db.DeviceUpdateOrInsert(proto.DeviceInfo{ Udid: identifier, IP: host, }) log.Println(identifier, "is reconnected") } hbs.OnDisconnect = func(identifier string) { db.SetDeviceAbsent(identifier) log.Println(identifier, "is offline") } r.Handle("/heartbeat", hbs) providerhbs := hb2.NewServer(&ProviderReceiver{}) r.Handle("/provider/heartbeat", providerhbs) return r } type ProviderReceiver struct{} func (p *ProviderReceiver) OnConnect(ctx hb2.Context) error { port, _ := strconv.Atoi(ctx.Request.FormValue("port")) if port == 0 { return errors.New("Request port is required") } log.Printf("Provider id:%s ip:%s port:%d is connected", ctx.ID, ctx.IP, port) return db.ProviderUpdateOrInsert(ctx.ID, ctx.IP, port) } func (p *ProviderReceiver) OnDisconnect(id string) { log.Printf("Provider %s disconnected", id) db.ProviderOffline(id) } /* POST udid, status= */ func (p *ProviderReceiver) OnRequest(ctx hb2.Context) error { id, req := ctx.ID, ctx.Request data := req.FormValue("data") log.Println("Receive data:", data) var v struct { Status string `json:"status"` Udid string `json:"udid"` ProviderForwardedPort int `json:"providerForwardedPort"` } if err := json.Unmarshal([]byte(data), &v); err != nil { return err } status, udid := v.Status, v.Udid if status == "" || udid == "" { return errors.New("status or udid is empty") } provider, err := db.ProviderGet(id) if err != nil { log.Println("Unexpect err:", err) return err } var providerId = &provider.Id if status == "online" { log.Printf("Device: %s is plugged-in", udid) } else if status == "offline" { log.Printf("Device: %s is plugged-off", udid) providerId = nil } else { log.Printf("Invalid status: %s, only is allowed.", status) return errors.New("status is required") } return db.DeviceUpdate(udid, map[string]interface{}{ "provider_id": providerId, "providerForwardedPort": v.ProviderForwardedPort, }) } ================================================ FILE: main.go ================================================ package main import ( "bytes" "encoding/json" "fmt" "io/ioutil" "math" "net" "net/http" "net/url" "sync" "time" "github.com/alecthomas/kingpin" "github.com/codeskyblue/dingrobot" "github.com/gorilla/websocket" "github.com/openatx/atx-server/proto" "github.com/qiniu/log" ) const ( version = "dev" defaultATXAgentVersion = "0.4.3" ) var ( port = kingpin.Flag("port", "http server listen port").Short('p').Default("8000").Int() addr = kingpin.Flag("addr", "http server listen address").Default(":8000").String() rdbAddr = kingpin.Flag("rdbaddr", "rethinkdb address").Default("localhost:28015").String() rdbName = kingpin.Flag("rdbname", "rethinkdb database name").Default("atxserver").String() videoBackend = kingpin.Flag("video-backend", "backend service for encoding images to video").Default("http://localhost:7000").String() atxAgentVersion string dingtalkToken string ) func handleWebsocketMessage(host string, message []byte) { return } func echo(w http.ResponseWriter, r *http.Request) { host, _, _ := net.SplitHostPort(r.RemoteAddr) ws, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Print("upgrade:", err) return } log.Debugf("new connection: %s", host) defer func() { log.Debugf("connection lost: %s", host) ws.Close() }() ws.SetReadDeadline(time.Now().Add(wsPongWait)) ws.SetPongHandler(func(string) error { ws.SetReadDeadline(time.Now().Add(wsPongWait)) return nil }) // Read device info message := &proto.CommonMessage{} if err := ws.ReadJSON(message); err != nil { log.Warn("error: read json message") return } if message.Type != proto.DeviceInfoMessage { log.Warnf("error: first message must be device info, but got %v", message.Type) return } devInfo := new(proto.DeviceInfo) jsonData, _ := json.Marshal(message.Data) json.NewDecoder(bytes.NewReader(jsonData)).Decode(devInfo) if devInfo.Udid == "" { log.Warnf("error: udid is empty") return } devInfo.IP = host log.Debugf("client ip:%s product:%s brand:%s", devInfo.IP, devInfo.Model, devInfo.Brand) if devInfo.Memory != nil { around := int(math.Ceil(float64(devInfo.Memory.Total-512*1024) / 1024.0 / 1024.0)) // around devInfo.Memory.Around = fmt.Sprintf("%d GB", around) } db.DeviceUpdateOrInsert(*devInfo) defer func() { db.SetDeviceAbsent(devInfo.Udid) // TODO(ssx): global var, not very function programing if info, err := db.DeviceGet(devInfo.Udid); err == nil && dingtalkToken != "" { robot := dingrobot.New(dingtalkToken) if err := robot.Text(info.PropertyId + " " + info.Serial + "\n" + info.Brand + " " + info.Model + " " + info.IP + " offline"); err != nil { log.Println("dingding send text err:", err) } } }() // ping ticker go func() { pingTicker := time.NewTicker(wsPingPeriod) defer pingTicker.Stop() for { select { case <-pingTicker.C: ws.SetWriteDeadline(time.Now().Add(wsWriteWait)) // here, writeMessage is not thread safe if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil { return } } } }() // Listen device info update for { mt, message, err := ws.ReadMessage() if err != nil { log.Println(host, "websocket connection closed") break } if mt == websocket.TextMessage { handleWebsocketMessage(host, message) } } } func runAndroidShell(ip string, command string) (output string, err error) { u, _ := url.Parse("http://" + ip + ":7912/shell") params := url.Values{} params.Add("command", command) u.RawQuery = params.Encode() resp, err := http.Get(u.String()) if err != nil { return } defer resp.Body.Close() jsondata, err := ioutil.ReadAll(resp.Body) return string(jsondata), err } func batchRunCommand(command string) { wg := sync.WaitGroup{} devices, _ := db.DeviceList() for _, devInfo := range devices { if devInfo.Present == nil || !*devInfo.Present { continue } wg.Add(1) go func(ip string) { runAndroidShell(ip, command) wg.Done() }(devInfo.IP) } wg.Wait() } func main() { // Refs: atx-agent version https://github.com/openatx/atx-agent/releases kingpin.Flag("agent", "atx-agent version").Default(defaultATXAgentVersion).StringVar(&atxAgentVersion) // FIXME(ssx): Ding talk is disabled because of too many boring messages kingpin.Flag("ding-token", "DingDing robot token (env: DING_TOKEN)").OverrideDefaultFromEnvar("DING_TOKEN").StringVar(&dingtalkToken) kingpin.Version(version) kingpin.HelpFlag.Short('h') kingpin.Parse() // log.SetFlags(log.Lshortfile | log.LstdFlags) // log.SetLevel(log.DebugLevel) // log.SetFormatter(&log.TextFormatter{}) // inforus.AddHookDefault() if *port != 8000 { *addr = fmt.Sprintf(":%d", *port) } if dingtalkToken != "" { log.Println("dingtalk notification enabled") if err := dingrobot.New(dingtalkToken).Text("atx-server started"); err != nil { log.Println("dingtalk test notification err:", err) } } log.Info("initial database") initDB(*rdbAddr, *rdbName) log.Info("listen address", *addr) log.Fatal(http.ListenAndServe(*addr, newHandler())) } ================================================ FILE: proto/message.go ================================================ package proto import ( "encoding/json" "fmt" "time" "github.com/openatx/androidutils" ) type MessageType int const ( DeviceInfoMessage = MessageType(0) PingMessage = MessageType(1) ) type CommonMessage struct { Type MessageType Data interface{} } func (m *CommonMessage) MarshalJSON() []byte { data, _ := json.Marshal(m) return data } type CpuInfo struct { Cores int `json:"cores"` Hardware string `json:"hardware"` } type MemoryInfo struct { Total int `json:"total"` // unit kB Around string `json:"around,omitempty"` } type OwnerInfo struct { IP string `json:"ip"` } type DeviceInfo struct { Udid string `json:"udid,omitempty"` // Unique device identifier PropertyId string `json:"propertyId,omitempty"` // For device managerment, eg: HIH-PHO-1122 Version string `json:"version,omitempty"` // ro.build.version.release Serial string `json:"serial,omitempty"` // ro.serialno Brand string `json:"brand,omitempty"` // ro.product.brand Model string `json:"model,omitempty"` // ro.product.model HWAddr string `json:"hwaddr,omitempty"` // persist.sys.wifi.mac Notes string `json:"notes,omitempty"` // device notes IP string `json:"ip,omitempty"` Port int `json:"port,omitempty"` ReverseProxyAddr string `json:"reverseProxyAddr,omitempty"` ReverseProxyServerAddr string `json:"reverseProxyServerAddr,omitempty"` Sdk int `json:"sdk,omitempty"` AgentVersion string `json:"agentVersion,omitempty"` Display *androidutils.Display `json:"display,omitempty"` Battery *androidutils.Battery `json:"battery,omitempty"` Memory *MemoryInfo `json:"memory,omitempty"` // proc/meminfo Cpu *CpuInfo `json:"cpu,omitempty"` // proc/cpuinfo Owner *OwnerInfo `json:"owner" gorethink:"owner,omitempty"` Reserved string `json:"reserved,omitempty"` ConnectionCount int `json:"-"` // > 1 happended when phone redial server CreatedAt time.Time `json:"-" gorethink:"createdAt,omitempty"` PresenceChangedAt time.Time `json:"presenceChangedAt,omitempty"` UsingBeganAt time.Time `json:"usingBeganAt,omitempty" gorethink:"usingBeganAt,omitempty"` Ready *bool `json:"ready,omitempty"` Present *bool `json:"present,omitempty"` Using *bool `json:"using,omitempty"` Product *Product `json:"product" gorethink:"product_id,reference,omitempty" gorethink_ref:"id"` Provider *Provider `json:"provider" gorethink:"provider_id,reference,omitempty" gorethink_ref:"id"` // only works when there is provider ProviderForwardedPort int `json:"providerForwardedPort,omitempty"` // used for provider to known agent server url ServerURL string `json:"serverUrl,omitempty"` } // "Brand Model Memory CPU" together can define a phone type Product struct { Id string `json:"id" gorethink:"id,omitempty"` Name string `json:"name" gorethink:"name,omitempty"` Brand string `json:"brand" gorethink:"brand,omitempty"` Model string `json:"model" gorethink:"model,omitempty"` Memory string `json:"memory,omitempty"` // eg: 4GB Cpu string `json:"cpu,omitempty"` Coverage float32 `json:"coverage" gorethink:"coverage,omitempty"` Gpu string `json:"gpu,omitempty"` Link string `json:"link,omitempty"` // Outside link // AntutuScore int `json:"antutuScore,omitempty"` } // u2init type Provider struct { Id string `json:"id" gorethink:"id,omitempty"` // machine id IP string `json:"ip" gorethink:"ip,omitempty"` Port int `json:"port" gorethink:"port,omitempty"` Present *bool `json:"present,omitempty"` Notes string `json:"notes" gorethink:"notes,omitempty"` Devices []DeviceInfo `json:"devices" gorethink:"devices,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"` PresenceChangedAt time.Time `json:"presenceChangedAt,omitempty"` } // Addr combined with ip:port func (p *Provider) Addr() string { return fmt.Sprintf("%s:%d", p.IP, p.Port) } ================================================ FILE: rethinkdb-test/main.go ================================================ package main import ( "log" "strings" "github.com/openatx/atx-server/proto" r "gopkg.in/gorethink/gorethink.v3" ) type RdbUtils struct { session *r.Session } func (db *RdbUtils) DBCreateAnyway(name string) error { res, err := r.DBList().Run(db.session) if err != nil { return err } defer res.Close() var dbNames []string if err := res.All(&dbNames); err != nil { return err } for _, dbName := range dbNames { log.Println(dbName) if dbName == name { log.Println("db exists atxserver") return nil } } err = r.DBCreate("atxserver").Exec(db.session) return err } func (db *RdbUtils) TableCreateAnyway(name string) error { err := r.TableCreate(name, r.TableCreateOpts{ PrimaryKey: "udid", }).Exec(db.session) if err != nil && strings.Contains(err.Error(), "already exists") { return nil } return err } func (db *RdbUtils) UpdateOrInsertDevice(dev proto.DeviceInfo) error { return r.Table("devices").Insert(dev, r.InsertOpts{ Conflict: func(id, oldDoc, newDoc r.Term) interface{} { return oldDoc.Merge(newDoc) }, }).Exec(db.session) } func (db *RdbUtils) DeviceList() (devices []proto.DeviceInfo) { res, err := r.Table("devices").Run(db.session) if err != nil { return nil } defer res.Close() res.All(&devices) return } var db *RdbUtils func init() { r.SetTags("gorethink", "json") r.SetVerbose(true) session, err := r.Connect(r.ConnectOpts{ Address: "localhost:28015", Database: "atxserver", InitialCap: 10, MaxOpen: 10, }) if err != nil { log.Fatal(err) } db = &RdbUtils{session} } func main() { log.Println("main") if err := db.DBCreateAnyway("atxserver"); err != nil { log.Fatal(err) } if err := db.TableCreateAnyway("devices"); err != nil { log.Fatal(err) } log.Println("table created") db.UpdateOrInsertDevice(proto.DeviceInfo{ Udid: "aaaabbbbccccdddd1234", Serial: "abcd123456", // Brand: "Huawei", }) log.Println(db.DeviceList()) feeds, err := r.Table("devices").Changes().Run(db.session) if err != nil { log.Fatal(err) } defer feeds.Close() var change r.ChangeResponse for feeds.Next(&change) { // var devInfo proto.DeviceInfo // log.Println(devInfo) // log.Println(change.State) log.Println(change.NewValue) log.Println(change.OldValue) } } ================================================ FILE: scripts/img2video/main.py ================================================ # coding: utf-8 # # Py3 only from __future__ import print_function import os import uuid import pathlib import shutil import time import io import json import traceback import numpy as np import imageio import tornado.ioloop import tornado.web import tornado.websocket from PIL import Image, ImageOps from tornado import gen from tornado.httpclient import AsyncHTTPClient from tornado.log import enable_pretty_logging enable_pretty_logging() class MainHandler(tornado.web.RequestHandler): @gen.coroutine def get(self): yield gen.sleep(.1) self.render("index.html") class VideoHandler(tornado.web.RequestHandler): def get(self): content_type = self.request.headers.get('Content-Type') if content_type and 'application/json' in content_type: videopath = pathlib.Path("static/videos") data = [] for p in sorted( videopath.glob('*.mp4'), key=lambda p: p.stat().st_mtime): info = { 'uri': str(p).replace('\\', '/'), 'mtime': p.stat().st_mtime, } meta = pathlib.Path(str(p) + ".json") if meta.exists(): with meta.open('rb') as f: # read_text not exists on py3.4 metainfo = json.loads(f.read().decode('utf-8')) info.update(metainfo) data.append(info) self.write({'data': list(reversed(data))}) return self.render("videos.html") def delete(self, name): mp4file = pathlib.Path("static/videos/" + name) mp4meta = pathlib.Path("static/videos/" + name + ".json") if mp4meta.exists(): mp4meta.unlink() if mp4file.exists(): mp4file.unlink() self.write({"success": True}) else: self.write({"success": False}) def resizefit(im, size): # resize but keep aspect ratio w, h = size oldw, oldh = old_size = im.size old_ratio = oldw / oldh new_ratio = w / h if new_ratio > old_ratio: padw = int(oldh * new_ratio - oldw) // 2 im = ImageOps.expand(im, (padw, 0, padw, 0), (0, 0, 255)) else: padh = int(oldw / new_ratio - oldh) // 2 im = ImageOps.expand(im, (0, padh, 0, padh), (0, 255, 255)) return im.resize(size, Image.ANTIALIAS) class CorsMixin(object): def set_default_headers(self): self.set_header("Access-Control-Allow-Origin", "*") self.set_header("Access-Control-Allow-Headers", "x-requested-with") self.set_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS') def options(self): # no body self.set_status(204) self.finish() class Image2VideoWebsocket(tornado.websocket.WebSocketHandler): def check_origin(self, origin): return True def open(self): self.udid = self.get_argument( 'udid') # device udid(unique device identifier) self.name = self.get_argument('name') self.video_path = 'static/videos/%s-%d.mp4' % (self.name, int(time.time() * 1000)) self.video_tmp_path = 'tmpdir/ws-%s.mp4' % str(uuid.uuid1()) fps = int(self.get_argument('fps', 10)) self.writer = imageio.get_writer(self.video_tmp_path, fps=fps) self.size = () print("websocket opened") def on_message(self, message): if isinstance(message, bytes): try: image = Image.open(io.BytesIO(message)) if not self.size: # always horizontal w, h = self.size = image.size if w < h: self.size = (h, w) if self.size != image.size: image = resizefit(image, self.size) imarray = np.asarray(image) del image self.writer.append_data(imarray) except Exception as e: print("Receive image format error") traceback.print_exc() else: print("receive", message) def on_close(self): self.writer.close() if not os.path.exists(self.video_tmp_path): print("no video file generated") return shutil.move(self.video_tmp_path, self.video_path) with open(self.video_path + '.json', 'wb') as f: f.write( json.dumps({ "udid": self.udid, "name": self.name }).encode('utf-8')) print("websocket closed, video generated", self.video_path) class Image2VideoHandler(CorsMixin, tornado.web.RequestHandler): @gen.coroutine def post(self): filemetas = self.request.files['file'] tmpdir = pathlib.Path('tmpdir/' + str(uuid.uuid1())) if not tmpdir.is_dir(): tmpdir.mkdir(parents=True) video_file = 'static/video-%s.mp4' % int(time.time() * 1000) writer = imageio.get_writer(video_file, fps=20) try: size = () for (i, meta) in enumerate(filemetas): jpgfile = tmpdir / ('%d.jpg' % i) with jpgfile.open('wb') as f: f.write(meta['body']) imarray = imageio.imread(str(jpgfile)) if not size: size = imarray.shape[1::-1] # same as reversed(shape[:2]) if size != imarray.shape[1::-1]: im = Image.fromarray(imarray) # convert to PIL im = resizefit(im, size) imarray = np.asarray(im) # convert to numpy del im writer.append_data(imarray) finally: shutil.rmtree(str(tmpdir)) writer.close() self.write({ "success": True, "url": "http://" + self.request.host + "/" + video_file }) def make_app(**settings): settings['template_path'] = 'templates' settings['static_path'] = 'static' settings['cookie_secret'] = os.environ.get("SECRET", "SECRET:_") settings['login_url'] = '/login' return tornado.web.Application([ (r"/", MainHandler), (r"/videos", VideoHandler), (r"/videos/([^/]+)", VideoHandler), (r"/video/convert", Image2VideoWebsocket), (r"/img2video", Image2VideoHandler), ], **settings) if __name__ == "__main__": hotreload = bool(os.getenv("DEBUG")) app = make_app(debug=hotreload) app.listen(7000) try: tornado.ioloop.IOLoop.instance().start() except KeyboardInterrupt: tornado.ioloop.IOLoop.instance().stop() ================================================ FILE: scripts/img2video/requirements.txt ================================================ tornado imageio numpy pillow ================================================ FILE: scripts/img2video/static/.gitkeep ================================================ ================================================ FILE: scripts/img2video/static/videos/.gitkeep ================================================ ================================================ FILE: scripts/img2video/templates/index.html ================================================ IMG2VIDEO




================================================ FILE: scripts/img2video/templates/videos.html ================================================ Videos

{{!g.date}}

================================================ FILE: scripts/levenshtein.py ================================================ # coding: utf-8 # import numpy as np def match_string(a, b): a = ' ' + a b = ' ' + b array = np.zeros((len(a), len(b)), dtype=np.int) steps = np.zeros((len(a), len(b)), dtype=np.int) array[0] = np.arange(len(b)) array[:, 0] = np.arange(len(a)) steps[0] = 1 for i in range(1, len(a)): for j in range(1, len(b)): sub_cost = 0 if a[i] == b[j] else 2 minval = array[i-1, j-1]+sub_cost # substitution steps[i, j] = 2 del_cost = 1 if a[i] != ' ' else 0 ins_cost = 1 if b[j] != ' ' else 0 # delete, insertion for step, val in enumerate((array[i-1, j]+del_cost, array[i, j-1]+ins_cost)): if minval > val: steps[i, j] = step minval = val array[i, j] = minval # print(array) # print(steps) return array, steps def backward(steps, a, b): assert len(steps) == len(a)+1 assert len(steps[0]) == len(b)+1 x = len(a) y = len(b) ss = [] while x > 0 or y > 0: step = steps[x, y] # print(x, y) if step == 0: x, y = x-1, y ss.append(['d', a[x], ' ']) elif step == 1: x, y = x, y-1 ss.append(['i', ' ', b[y]]) elif step == 2: x, y = x-1, y-1 if a[x] == b[y]: ss.append(['=', a[x], b[y]]) else: ss.append(['r', a[x], b[y]]) # for i in range(len(ss)): # print() for line in map(list, zip(*ss)): print(''.join(reversed(line))) def main(): a = "sitting" b = "kitten" # a, b = "Monday", "Hello world" array, steps = match_string(a, b) backward(steps, a, b) def match_distance(a, b): array, steps = match_string(a, b) if False: backward(steps, a, b) return int(array[-1, -1]) if __name__ == '__main__': print(match_distance('sitting', 'kitten')) ================================================ FILE: scripts/update_coverage_umeng.py ================================================ # coding: utf-8 # ''' 用途:更新ATX-Server上的覆盖率数据(来源umeng) { "equipment": { "item22": { "android": { "2017-10": { "brandRankData": [ { "name": "OPPO", "value": 18.57, "children": [ { "name": "OPPO R9", "value": 2.15 } ] }, { "name": "vivo", "value": 16.84, "children": [] } ], "phoneTypeData": [ { "name": "OPPO R9", "value": 2.15, "trend": "up" } ] }, "2017-09": {}, "2017-08": {}, "2017-07": {}, "2017-06": {}, "2017-05": {}, "2017-04": {}, "2017-03": {} }, "ios": { "2017-10": {}, "2017-09": {}, "2017-08": {}, "2017-07": {}, "2017-06": {}, "2017-05": {}, "2017-04": {}, "2017-03": {} } } } } ''' import requests import json import levenshtein def get_umeng_data(): umeng_data = requests.get('http://compass.umeng.com/data/equipmentItem2_2.json').json() android_datas = umeng_data['equipment']['item22']['android'] keys = sorted(android_datas.keys(), reverse=True) key = keys[0] print("Year-month:", key) rank_data = android_datas[key]['brandRankData'] data = {} for brand in rank_data: for cov in brand['children']: name, value = cov['name'], cov['value'] if name.startswith('畅玩'): name = '荣耀'+name if name.startswith('畅享'): name = '华为'+name data[name] = value rank_data = android_datas[keys[1]]['brandRankData'] for brand in rank_data: for cov in brand['children']: name, value = cov['name'], cov['value'] if name.startswith('畅玩'): name = '荣耀'+name if name.startswith('畅享'): name = '华为'+name if name not in data: data[name] = value return data def main(): data = get_umeng_data() keys = data.keys() for device in requests.get('http://10.246.46.160:8200/list').json(): product = device.get('product') or {} name = product.get('name').lower() if not name: continue udid = device.get('udid') # 查找最匹配的名字 mindist = 1e9 bestkey = '' for key in keys: dist = levenshtein.match_distance(name, key.lower()) if mindist > dist: mindist = dist bestkey = key # 全部匹配自动更新 if mindist == 0: print(name, "==", bestkey, ">>", mindist, data[bestkey]) requests.put('http://10.246.46.160:8200/devices/'+udid+'/product', data=json.dumps({ 'id': product['id'], 'coverage': data[bestkey] })) else: print("?", name, "==", bestkey, ">>", mindist, data[bestkey]) confirm = input("Confirm update [Y/n]") if confirm == '': # print('update name') # requests.post('http://10.246.46.160:8200/devices/'+udid+'/info', data=json.dumps({'name': bestkey})) print('update coverage') requests.put('http://10.246.46.160:8200/devices/'+udid+'/product', data=json.dumps({ 'id': product['id'], 'name': bestkey, 'coverage': data[bestkey] })) if __name__ == '__main__': main() ================================================ FILE: templates/edit.html ================================================ Product

手机信息修改

UDID: {{udid}}

Serial
{{device.serial}}
CPU
{{device.cpu && device.cpu.hardware}}
Memory
{{device.memory && device.memory.around}}

Product

ID: {{product.id}}

Brand: {{product.brand}}

Model: {{product.model}} 百度 Google

Name:

CPU:

GPU:

Link:

Coverage: %

{{message}}
================================================ FILE: templates/index.html ================================================

{{errmsg}}

@ :) 名字 备注
Offline Busy {{d.battery.level}}% {{d.battery.temperature/10}}℃
================================================ FILE: templates/property.html ================================================

资产编号修改

HIH-PHO-
================================================ FILE: templates/providers.html ================================================
Present ({{presentCount}}) IP Notes Uptime Devices ID
{{p.ip}} {{p.notes}} {{p.presenceChangedAt | timeSince}}
================================================ FILE: templates/remote.html ================================================ DRC [[.]]
================================================ FILE: utils.go ================================================ package main func newBool(v bool) *bool { return &v } func toBool(v *bool) bool { if v == nil { return false } return *v }