luma_third_party: Initial add of OpenSTF

This is an unmodified import of the original OpenSTF library into our
third party source. Other modifications will be submitted in future CLs.

Change-Id: I219a0c0c4a0fc032d61d0a037730558e9d641dab
diff --git a/crowdstf/.bowerrc b/crowdstf/.bowerrc
new file mode 100644
index 0000000..a13a285
--- /dev/null
+++ b/crowdstf/.bowerrc
@@ -0,0 +1,3 @@
+{
+  "directory": "res/bower_components"
+}
diff --git a/crowdstf/.dockerignore b/crowdstf/.dockerignore
new file mode 100644
index 0000000..868d0d8
--- /dev/null
+++ b/crowdstf/.dockerignore
@@ -0,0 +1,14 @@
+*.mo
+*.tgz
+.DS_Store
+.env
+.git/
+.idea/
+node_modules/
+npm-debug.log
+res/bower_components/
+res/build/
+rethinkdb_data/
+temp/
+tmp/
+.eslintcache
diff --git a/crowdstf/.editorconfig b/crowdstf/.editorconfig
new file mode 100644
index 0000000..9912b2a
--- /dev/null
+++ b/crowdstf/.editorconfig
@@ -0,0 +1,11 @@
+# http://editorconfig.org/
+
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
diff --git a/crowdstf/.eslintrc b/crowdstf/.eslintrc
new file mode 100644
index 0000000..d06fd53
--- /dev/null
+++ b/crowdstf/.eslintrc
@@ -0,0 +1,158 @@
+{
+  "extends": "eslint:recommended",
+  "env": {
+    "node": true
+  },
+  "rules": {
+    // Possible errors
+    "no-cond-assign": 2, // TODO: conflicts with no-extra-parens for while use case
+    "no-extra-parens": 0, // For now going with 0 since 1 does more harm than good
+    "no-unexpected-multiline": 2,
+    "valid-jsdoc": 1,
+    "valid-typeof": 2,
+
+    // Best practices
+    "accessor-pairs": 2,
+    "block-scoped-var": 2,
+    "complexity": 0,
+    "consistent-return": 1,
+    "curly": 2,
+    "dot-location": [2, "property"], // defaults to "object"
+    "dot-notation": 2,
+    "eqeqeq": [2, "smart"], // `2` is recommended
+    "guard-for-in": 2,
+    "no-alert": 1, // `2` is recommended
+    "no-caller": 2,
+    "no-div-regex": 2,
+    "no-else-return": 0, // `2` may be ok, but indent consistency is better
+    "no-empty-pattern": 2,
+    "no-eq-null": 2,
+    "no-eval": 2,
+    "no-extend-native": 2,
+    "no-extra-bind": 2,
+    "no-fallthrough": 1, // `2` is recommended
+    "no-floating-decimal": 1, // `2` is recommended
+    "no-implicit-coercion": [2, {"boolean": false, "number": true, "string": false}], // `[2, {"boolean": true, "number": true, "string": true}],` is recommended
+    "no-implied-eval": 2,
+    "no-invalid-this": 1, // `2` is recommended
+    "no-iterator": 2,
+    "no-labels": 2,
+    "no-lone-blocks": 2,
+    "no-loop-func": 2,
+    "no-magic-numbers": 0, // `1` would also be fine
+    "no-multi-spaces": 2,
+    "no-multi-str": 2,
+    "no-native-reassign": 2,
+    "no-new-func": 2,
+    "no-new-wrappers": 2,
+    "no-new": 2,
+    "no-octal-escape": 2,
+    "no-octal": 1, // TODO: accept until we use ES6 0o755 notation
+    "no-param-reassign": 2,
+    "no-process-env": 0, // `2` is recommended
+    "no-proto": 2,
+    "no-redeclare": [2, {"builtinGlobals": true}], // `2` is recommended and actually defaults to `[2, {"builtinGlobals": false}]`
+    "no-return-assign": [2, "except-parens"],
+    "no-script-url": 2,
+    "no-self-compare": 2,
+    "no-sequences": 2,
+    "no-throw-literal": 2,
+    "no-unused-expressions": 2, // `2` is recommended and actually defaults to `[2, {"allowShortCircuit": false, "allowTernary": false}]`
+    "no-useless-call": 2, // `2` is recommended
+    "no-useless-concat": 2,
+    "no-void": 2,
+    "no-warning-comments": [1, { "terms": ["todo", "fixme", "@todo", "@fixme"]}], // `[0, { "terms": ["todo", "fixme", "xxx"], "location": "start" }]` is recommended
+    "no-with": 2,
+    "radix": 1, // `2` is recommended
+    "vars-on-top": 0, // `2` is recommended TODO: review this
+    "wrap-iife": [2, "inside"], // `[2, "outside"]` is recommended
+    "yoda": 2, // `[2, "never"]` is recommended, optionally set `[2, "never", {"exceptRange": true, "onlyEquality": false}]
+
+    // Strict
+    "strict": [0, "function"],
+
+    // Variables
+    "init-declarations": [0, "always"], // `[2, "always"]` is recommended
+    "no-delete-var": 2,
+    "no-label-var": 2,
+    "no-shadow-restricted-names": 2,
+    "no-shadow": 0, // TODO: 1 may be ok
+    "no-undefined": 1,
+    "no-unused-vars": 1,
+    "no-use-before-define": 1, // TODO: 0 or 2 may be ok, sometimes there are ciclic dependencies
+
+    // Style
+    "array-bracket-spacing": [2, "never"], // optionally set `[2, "never", {"singleValue": true, "objectsInArrays": true, "arraysInArrays": true}]`
+    "block-spacing": [2, "always"], // optionally set `[2, "always"]`
+    "brace-style": [2, "stroustrup", {"allowSingleLine": false}],
+    "camelcase": [2, {"properties": "never"}], // TODO: 2 might be too much
+    "comma-spacing": [2, {"before": false, "after": true}],
+    "comma-style": [1, "first"], // optionally set `[2, "first", {"exceptions": {"ArrayExpression": true, "ObjectExpression": true}}]`
+    "computed-property-spacing": [2, "never"],
+    "consistent-this": [2, "that"],
+    "eol-last": 2,
+    "func-names": 0,
+    "func-style": 0, // optionally set `[2, "expression"]`
+    "id-length": 0, // optionally set `[2, {"min": 3, "max": 10, "properties": "never", "exceptions": ["x"]}]`
+    "id-match": 0, // optionally set `[2, "^[a-z]+([A-Z][a-z]+)*$", {"properties": false}]`
+    "indent": [0, 2, {"SwitchCase": 0, "VariableDeclarator": 2}], // TODO: optionally set `[2, 2, {"SwitchCase": 1, "VariableDeclarator": {"var": 2, "let": 2, "const": 3}}]` this gives too many errors
+    "jsx-quotes": [2, "prefer-single"],
+    "key-spacing": [2, {"beforeColon": false, "afterColon": true, "mode": "strict"}], // optionally set `[2, {"beforeColon": false, "afterColon": true, "mode": "strict", "align": "colon"}]`
+    "lines-around-comment": 2, // optionally set `[2, {"beforeBlockComment": true, "beforeLineComment": true, "allowBlockStart": true}]`
+    "linebreak-style": 0, // optionally set `[1, "unix"]`
+    "max-len": [2, 100, 2, {"ignoreComments": true, "ignoreUrls": true}], // NOTE: Our limit is 80 however ESLint does not have an ignoreStrings so lets have more buffer
+    "max-nested-callbacks": [1, 5],
+    "new-cap": 2, // optionally set `[2, {"capIsNewExceptions": ["Person"]}]`
+    "new-parens": 2,
+    "newline-after-var": [0, "always"], // TODO: 1 may be ok
+    "no-array-constructor": 2,
+    "no-bitwise": 0, // We use them
+    "no-continue": 1,
+    "no-inline-comments": 0,
+    "no-lonely-if": 0, // TODO: 1 may be ok
+    "no-mixed-spaces-and-tabs": 2, // optionally set `[2, "smart-tabs"]`
+    "no-multiple-empty-lines": [1, {"max": 2}],
+    "no-negated-condition": 0, // Prioritize intent order over readability
+    "no-nested-ternary": 2,
+    "no-new-object": 2, // TODO: check this one
+    "no-plusplus": 0,
+    "no-restricted-syntax": 0, // optionally set `[2, "FunctionExpression", "WithStatement"]`
+    "no-spaced-func": 2,
+    "no-ternary": 0,
+    "no-trailing-spaces": [2, {"skipBlankLines": true}],
+    "no-underscore-dangle": 0,
+    "no-unneeded-ternary": [2, {"defaultAssignment": false}],
+    "object-curly-spacing": [2, "never"], // optionally set `[2, "always", {"objectsInObjects": false, "arraysInObjects": false}]` // TODO: check if always or never is being more used
+    "one-var": [2, {"uninitialized": "always", "initialized": "never"}], // optionally set `[2, {"var": "always", "let": "never", "const": "never"}]`
+    "operator-assignment": [2, "always"], // optionally set `[2, "always"]`
+    "operator-linebreak": [2, "after"], // optionally set `[2, "before", {"overrides": {"?": "after"}}]` // TODO: check for conditionals
+    "padded-blocks": [2, "never"],
+    "quote-props": [2, "as-needed", { "numbers": true }],
+    "quotes": [2, "single", "avoid-escape"],
+    "require-jsdoc": 0,
+    "semi-spacing": [2, {"before": false, "after": true}],
+    "semi": [2, "never"],
+    "sort-vars": 0, // optaionlly set `[2, {"ignoreCase": true}]`
+    "space-before-blocks": [2, "always"], // optionally set `[2, {"functions": "never", "keywords": "always"}]`
+    "space-before-function-paren": [2, "never"], // optionally set `[2, {"anonymous": "always", "named": "never"}]`
+    "space-in-parens": [2, "never"], // optionally set `[2, "always", {"exceptions": ["empty"]}]`
+    "space-infix-ops": 2,
+    "space-unary-ops": [2, {"words": true, "nonwords": false}],
+    "spaced-comment": [1, "always", {"exceptions": ["/"]}], // optionally set `[2, "always", {"exceptions": ["-", "+"], "markers": ["/"]}]`
+    "wrap-regex": 0, // TODO: 2 is ok but the division edge case is too edgy
+
+    // Node.js / Common.js
+    "callback-return": 1, // `2` is default, optionally set `[2, ["callback", "cb", "next"]]`
+    "global-require": 0, // `2` is default
+    "handle-callback-err": 1, // `2` is default, optionally set `[2, "^(err|error)$"]`
+    "no-mixed-requires": [0, {"grouping": true}], // [2, false] is default
+    "no-new-require": 2, // `2` is default
+    "no-path-concat": 2, // `2` is default
+    "no-process-exit": 0, // `2` is default
+    "no-restricted-modules": 0, // no default, optionally set `[2, "fs", "os"]`
+    "no-sync": 1, // `2` is default
+
+    // eslint v2
+    "keyword-spacing": 2
+  }
+}
diff --git a/crowdstf/.gitignore b/crowdstf/.gitignore
new file mode 100644
index 0000000..bb12375
--- /dev/null
+++ b/crowdstf/.gitignore
@@ -0,0 +1,13 @@
+*.mo
+.DS_Store
+.idea/
+/*.tgz
+/.env
+/node_modules/
+/res/bower_components/
+/res/build/
+/res/test_out/
+/rethinkdb_data/
+/temp/
+/tmp/
+.eslintcache
diff --git a/crowdstf/.npmignore b/crowdstf/.npmignore
new file mode 100644
index 0000000..4b1edee
--- /dev/null
+++ b/crowdstf/.npmignore
@@ -0,0 +1,27 @@
+*.!sync
+.DS_Store
+/*.tgz
+/.bowerrc
+/.dockerignore
+/.editorconfig
+/.env
+/.gitignore
+/.idea/
+/.jscsrc
+/.npmignore
+/.npmrc
+/.travis.yml
+/docker
+/Dockerfile
+/bower.json
+/component.json
+/gulpfile.js
+/npm-debug.log
+/res/bower_components/
+/res/test/
+/res/web_modules/
+/rethinkdb_data/
+/temp/
+/test/
+/tmp/
+/webpack.config.js
diff --git a/crowdstf/.travis.yml b/crowdstf/.travis.yml
new file mode 100644
index 0000000..68ead8a
--- /dev/null
+++ b/crowdstf/.travis.yml
@@ -0,0 +1,54 @@
+language: cpp
+os:
+- linux
+- osx
+sudo: false
+addons:
+  apt:
+    sources:
+    - ubuntu-toolchain-r-test
+    packages:
+    - libzmq3-dev
+    - libprotobuf-dev
+    - graphicsmagick
+    - rethinkdb
+    - g++-4.9
+    - yasm
+env:
+  matrix:
+  - NODE_VERSION=4
+  - NODE_VERSION=5
+matrix:
+  allow_failures:
+  - os: osx
+  fast_finish: true
+before_install:
+- rm -rf ~/.nvm && git clone --depth 1 https://github.com/creationix/nvm.git ~/.nvm
+- source ~/.nvm/nvm.sh
+- nvm install $NODE_VERSION
+- node --version
+- npm --version
+- npm set progress=false
+- if [ "${TRAVIS_OS_NAME}" == "linux" ]; then export CXX=g++-4.9; fi
+- if [ "${TRAVIS_OS_NAME}" == "osx" ]; then brew install rethinkdb graphicsmagick
+  zeromq protobuf yasm pkg-config; fi
+install:
+- npm install
+- export PATH=$PWD/node_modules/.bin:$PATH
+before_script:
+#  - rethinkdb --daemon
+
+script:
+- npm test
+#  - ./bin/stf local
+- gulp build
+after_script:
+#  - killall rethinkdb
+
+cache:
+  directories:
+  - node_modules
+  - res/bower_components
+notifications:
+  slack:
+    secure: F+Xq1LGQgPZtEkHB2EX6rK1MEdoCRAig8gl16hiRBJ8di6bvKvUS8sF/vLtRrbX1LdjyxsbZDhX2qrA+3bBkzLgsVulcH+jdmaiRI0kNjjTxvLXYfIgTrHhiyXUg9lItEMR0JjcGFe3D5FVkPx4bcvS5ZrfsIu1a/Hm7JzdxI3BaBkHe5k9xshvnyr+joc2DyPQp+rvVBukhr9sSrJMrGvGtMgzlpUlb3dr7BeeSTKqdG7NgGc3NL2mb2ugookh+vPP26RT6Vsk6wFGY/hnP7EFbHmM1L8yEEskjLuOMxl6xmcA/fCpJov/6kXf+KxQIwtl5XUgR0HM5O8BHXid2rLIxIu4YIpd4pAXuuUTqXdnETbGBfzn8DP4h0y0zYGRLWC/NEuBalWU165KbA/wgsU24Zo6tcViqozzpiyjGJSxElVTcIkiAbKyHGjahNdPBj3kz0KwiV36B/eo3IHHl5Udm2DL2nvqUD/BtitprnCYSInXF5x/3T69pgV6M7rIztH0rmWRiSvRy3mqgB8mTbfMD+9rvS5DY6zygb1tUPOTGeVSTLARi+Kn1UladA2XYxvBUYwWua+wB4wOiLfY+HBuXeXQBSYVLjz8pBMUF9dGBwsrlb9DeQviCEIaDavqnLliEnPHH7yPp32QD0zPE2s5P9TPLByypL8VFuTs/hNI=
diff --git a/crowdstf/.tx/config b/crowdstf/.tx/config
new file mode 100644
index 0000000..9a5bbec
--- /dev/null
+++ b/crowdstf/.tx/config
@@ -0,0 +1,9 @@
+[main]
+host = https://www.transifex.com
+
+[stf.ui]
+file_filter = res/common/lang/po/stf.<lang>.po
+source_file = res/common/lang/po/stf.pot
+source_lang = en
+type = PO
+
diff --git a/crowdstf/CHANGELOG.md b/crowdstf/CHANGELOG.md
new file mode 100644
index 0000000..41da23f
--- /dev/null
+++ b/crowdstf/CHANGELOG.md
@@ -0,0 +1,22 @@
+## CHANGES IN VERSION 1.1.1 (from 1.1)
+
+Patch release addressing the following:
+
+### Fixes
+- [Disabled Nagle Algorithm in adbkit connection](https://github.com/openstf/adbkit/issues/41) to improve remote debugging speed 
+
+## CHANGES IN VERSION 1.1 (from 1.0.10)
+
+Minor release addressing the following:
+
+### Enhancements
+- Android 6.0 support
+- Added translation support for Chinese, Korean, Russian and Spanish
+- Added File Explorer feature in device controller where you can access device file system
+- Added optional storage-s3 unit which can store storage data in Amazon S3 bucket instead of local
+- Now, "Notes" column of device list is editable.
+- Experimental armv7l support
+- Added [stf-setup-examples](https://github.com/openstf/setup-examples) using [Vagrant](https://www.vagrantup.com/) and [Virtual Box](https://www.virtualbox.org/)
+
+### Fixes
+- [DEPLOYMENT doc ](https://github.com/openstf/stf/blob/master/doc/DEPLOYMENT.md) fixes
diff --git a/crowdstf/CONTRIBUTING.md b/crowdstf/CONTRIBUTING.md
new file mode 100644
index 0000000..01afa68
--- /dev/null
+++ b/crowdstf/CONTRIBUTING.md
@@ -0,0 +1,27 @@
+# Contributing
+
+We are happy to accept any contributions that make sense and respect the rules listed below.
+
+## How to contribute
+
+1. Fork the repo.
+2. Create a feature branch for your contribution out of the `master` branch. Only one contribution per branch is accepted.
+3. Implement your contribution while respecting our rules (see below).
+4. If possible, add tests for your contribution to make sure it actually works.
+5. Don't forget to run `npm test` just right before submitting, it also checks for code styling issues.
+6. Submit a pull request against our `master` branch!
+
+## Rules
+
+* **Do** use feature branches.
+* **Do** conform to existing coding style so that your contribution fits in.
+* **Do** use [EditorConfig] to enforce our [whitespace rules](.editorconfig). If your editor is not supported, enforce the settings manually.
+* **Do not** touch the `version` field in [package.json](package.json).
+* **Do not** commit any generated files, unless already in the repo. If absolutely necessary, explain why.
+* **Do not** create any top level files or directories. If absolutely necessary, explain why and update [.npmignore](.npmignore).
+
+## License
+
+By contributing your code, you agree to license your contribution under our [LICENSE](LICENSE).
+
+[editorconfig]: <http://editorconfig.org/>
diff --git a/crowdstf/DONATION-TRANSPARENCY.md b/crowdstf/DONATION-TRANSPARENCY.md
new file mode 100644
index 0000000..2f00dae
--- /dev/null
+++ b/crowdstf/DONATION-TRANSPARENCY.md
@@ -0,0 +1,21 @@
+# Donation transparency report
+
+Here we attempt to list, to the best of our abilities, the team's use of donated funds and goods.
+
+If you're interested in supporting future development, [check us out on Bountysource](https://salt.bountysource.com/teams/openstf).
+
+## Hardware purchases
+
+Here's a list of hardware purchases we've made using donated funds.
+
+| Date | Hardware | Type | Reason | Thanks to |
+|------|----------|------|--------|-----------|
+| 2016-04-12 | Nexus 5X LG-H791 16GB | Phone | Android N development | @qband, @juliusspencer, Anonymous |
+
+## Hardware donations
+
+Here's a list of hardware that has been given to the team for development purposes.
+
+*None as of yet.*
+
+Please [email us directly](mailto:contact@openstf.io) if you'd like to donate a device for development.
diff --git a/crowdstf/Dockerfile b/crowdstf/Dockerfile
new file mode 100644
index 0000000..4f89d3d
--- /dev/null
+++ b/crowdstf/Dockerfile
@@ -0,0 +1,42 @@
+FROM openstf/base:v1.0.6
+
+# Sneak the stf executable into $PATH.
+ENV PATH /app/bin:$PATH
+
+# Work in app dir by default.
+WORKDIR /app
+
+# Export default app port, not enough for all processes but it should do
+# for now.
+EXPOSE 3000
+
+# Copy app source.
+COPY . /tmp/build/
+
+# Give permissions to our build user.
+RUN mkdir -p /app && \
+    chown -R stf-build:stf-build /tmp/build /app
+
+# Switch over to the build user.
+USER stf-build
+
+# Run the build.
+RUN set -x && \
+    cd /tmp/build && \
+    export PATH=$PWD/node_modules/.bin:$PATH && \
+    npm install --loglevel http && \
+    npm pack && \
+    tar xzf stf-*.tgz --strip-components 1 -C /app && \
+    bower cache clean && \
+    npm prune --production && \
+    mv node_modules /app && \
+    npm cache clean && \
+    rm -rf ~/.node-gyp && \
+    cd /app && \
+    rm -rf /tmp/*
+
+# Switch to the app user.
+USER stf
+
+# Show help by default.
+CMD stf --help
diff --git a/crowdstf/ISSUE_TEMPLATE.md b/crowdstf/ISSUE_TEMPLATE.md
new file mode 100644
index 0000000..925f143
--- /dev/null
+++ b/crowdstf/ISSUE_TEMPLATE.md
@@ -0,0 +1,51 @@
+<!---
+
+Hi there!
+
+Please be sure to run `stf doctor` before submitting an issue. Please include screenshots, command output and log files if any.
+
+=== SUPER COMMON QUESTIONS ===
+
+Q. I'm having issues when multiple devices are connected.
+A. Usually caused by insufficient USB power supply or other hardware issue we can't do anything about.
+
+Q. Can I connect a device to STF over WIFI?
+A. Yes, with the `--allow-remote` option. However, ADB over WIFI can easily be 10x slower than USB, so don't expect too much.
+
+Q. How do I connect to my local STF from another computer?
+A. Try the `--public-ip` option or do a full deployment. See DEPLOYMENT.md in the doc folder.
+
+Q. Can I run STF on multiple machines?
+A. Yes, if you do a full deployment. See DEPLOYMENT.md in the doc folder.
+-->
+
+## What's the problem (or question)?
+<!--- If describing a bug, tell us what happens instead of the expected behavior -->
+<!--- If suggesting a change/improvement, explain the difference from current behavior -->
+
+## What should have happened?
+<!--- If you're describing a bug, tell us what should happen -->
+<!--- If you're suggesting a change/improvement, tell us how it should work -->
+
+## Do you have an idea for a solution?
+<!--- Not obligatory, but suggest a fix/reason for the bug, -->
+<!--- or ideas how to implement the addition or change -->
+
+## How can we reproduce the issue?
+<!--- Provide unambiguous set of steps to reproduce this bug. Include code to reproduce, if relevant -->
+1.
+2.
+3.
+4.
+
+## Help us understand your issue by providing context.
+<!--- How has this issue affected you? What are you trying to accomplish? -->
+<!--- Providing context helps us come up with a solution that is most useful in the real world -->
+
+## Please tell us details about your environment.
+<!--- Include as many relevant details about the environment you experienced the bug in -->
+* Are you using `stf local` or a [full deployment](doc/DEPLOYMENT.md): 
+* Version used (git hash or `stf -V`): 
+* Environment name and version (e.g. Chrome 39, node.js 5.4): 
+* Operating System and version: 
+* If there was a problem with a specific device, run `adb devices -l` and paste the relevant row here: 
diff --git a/crowdstf/LICENSE b/crowdstf/LICENSE
new file mode 100644
index 0000000..2ffff3d
--- /dev/null
+++ b/crowdstf/LICENSE
@@ -0,0 +1,13 @@
+Copyright © CyberAgent, Inc. All Rights Reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/crowdstf/README.google b/crowdstf/README.google
new file mode 100644
index 0000000..9bb5361
--- /dev/null
+++ b/crowdstf/README.google
@@ -0,0 +1,10 @@
+URL: https://github.com/openstf/stf
+Version: b61df410f72505367728c4d05ef53449eadd4d69
+License: Apache License, Version 2.0
+License File: LICENSE
+
+Description:
+Control and manage Android devices from your browser. https://openstf.io
+
+Local Modifications:
+No modifications
\ No newline at end of file
diff --git a/crowdstf/README.md b/crowdstf/README.md
new file mode 100644
index 0000000..af182bb
--- /dev/null
+++ b/crowdstf/README.md
@@ -0,0 +1,420 @@
+<img src="res/common/logo/exports/STF-128.png?raw=true" style="width:100px;height:100px;" alt="STF">
+
+[![Build Status](https://travis-ci.org/openstf/stf.svg?branch=master)](https://travis-ci.org/openstf/stf)
+[![Docker Pulls](https://img.shields.io/docker/pulls/openstf/stf.svg)](https://hub.docker.com/r/openstf/stf/)
+[![NPM version](https://img.shields.io/npm/v/stf.svg)](https://www.npmjs.com/package/stf)
+
+[Help keep OpenSTF up to date and in active development!](https://salt.bountysource.com/teams/openstf)
+
+**STF** (or Smartphone Test Farm) is a web application for debugging smartphones, smartwatches and other gadgets remotely, from the comfort of your browser.
+
+It is currently being used at [CyberAgent](https://www.cyberagent.co.jp/en/) to control a growing collection of more than 160 devices.
+
+![Close-up of device shelf](doc/shelf_closeup_790x.jpg?raw=true)
+
+![Super short screencast showing usage](doc/7s_usage.gif?raw=true)
+
+## Announcements
+
+* Thanks to @qband, @juliusspencer and Anonymous donors, we've been able to confirm [Android N Preview 2 support!](https://github.com/openstf/stf/issues/279)
+* We've added a [donation transparency report](DONATION-TRANSPARENCY.md) for all to see.
+
+## Features
+
+* OS support
+  - Android
+    * Supports versions 2.3.3 (SDK level 10) to Android N Preview 2 (which still shows up as SDK level 23)
+    * Supports Wear 5.1 (but not 5.0 due to missing permissions)
+    * Supports Fire OS, CyanogenMod, and other heavily Android based distributions
+    * `root` is **not** required for any current functionality
+* Remote control any device from your browser
+  - Real-time screen view
+    * Refresh speed can reach 30-40 FPS depending on specs and Android version. See [minicap](https://github.com/openstf/minicap) for more information.
+    * Rotation support
+  - Supports typing text from your own keyboard
+    * Supports meta keys
+    * Copy and paste support (although it can be a bit finicky on older devices, you may need to long-press and select paste manually)
+    * May sometimes not work well with non-Latin languages unfortunately.
+  - Multitouch support on touch screens via [minitouch](https://github.com/openstf/minitouch), two finger pinch/rotate/zoom gesture support on regular screens by pressing `Alt` while dragging
+  - Drag & drop installation and launching of `.apk` files
+    * Launches main launcher activity if specified in the manifest
+  - Reverse port forwarding via [minirev](https://github.com/openstf/minirev)
+    * Access your local server directly from the device, even if it's not on the same network
+  - Open websites easily in any browser
+    * Installed browsers are detected in real time and shown as selectable options
+    * Default browser is detected automatically if selected by the user
+  - Execute shell commands and see real-time output
+  - Display and filter device logs
+  - Use `adb connect` to connect to a remote device as if it was plugged in to your computer, regardless of [ADB](http://developer.android.com/tools/help/adb.html) mode and whether you're connected to the same network
+    * Run any `adb` command locally, including shell access
+    * [Android Studio](http://developer.android.com/tools/studio/index.html) and other IDE support, debug your app while watching the device screen on your browser
+    * Supports [Chrome remote debug tools](https://developer.chrome.com/devtools/docs/remote-debugging)
+  - File Explorer to access device file system
+  - Experimental VNC support (work in progress)
+* Manage your device inventory
+  - See which devices are connected, offline/unavailable (indicating a weak USB connection), unauthorized or unplugged
+  - See who's using a device
+  - Search devices by phone number, IMEI, ICCID, Android version, operator, product name and/or many other attributes with easy but powerful queries
+  - Show a bright red screen with identifying information on a device you need to locate physically
+  - Track battery level and health
+  - Rudimentary Play Store account management
+    * List, remove and add new accounts (adding may not work on all devices)
+  - Display hardware specs
+
+## Status
+
+STF is in continued, active development, but as of late 2015 the team is operating mostly on their private time and funds. While normal for many open source projects, STF is quite heavy on the hardware side, and is therefore somewhat of a money sink. [Contact us][contact-link] if you'd like to support future development or even become our next sponsor.
+
+We're also actively working to expand the team. Welcome **@vbanthia** as our newest full contributor!
+
+### Short term goals
+
+Here are some things we are planning to address ASAP.
+
+1. Properly expose the new VNC functionality in the UI
+2. Implement a basic REST API for programmatically using devices
+3. Properly reset user data between uses (Android 4.0+)
+4. Automated scheduled restarts for devices
+
+### Sponsors wanted
+
+Is your company (or you!) a heavy user of STF? Consider becoming a hardware sponsor. If you find a device that doesn't work, or would simply like to ensure support for a new model, [send it to us][contact-link]! While we can't guarantee a fix, we can promise that someone will take a detailed look into what's going on with your device, and fix it when possible. For difficult cases you may need to use our [consulting services](#consulting-services) instead.
+
+You can also sponsor a feature or bug fix and get it attributed to you or your company in the release notes.
+
+### Consulting services
+
+We highly encourage open participation in the community. However, if you're running a business that uses STF or would like to use STF, you may sometimes want to have an expert, i.e. one of the original developers or a skilled contributor, work with you to set up a prototype for evaluation purposes, add support for new or old hardware, figure out an issue, fix a bug or add some new feature. Our services are similar to [FFmpeg's](https://ffmpeg.org/consulting.html). [Contact us][contact-link] with details and we'll see what we can do.
+
+Availability is limited and tied to individual developer's schedules.
+
+## A quick note about security
+
+As the product has evolved from an internal tool running in our internal network, we have made certain assumptions about the trustworthiness of our users. As such, there is little to no security or encryption between the different processes. Furthermore, devices do not get completely reset between uses, potentially leaving accounts logged in or exposing other sensitive data. This is not an issue for us, as all of our devices are test devices and are only used with test accounts, but it may be an issue for you if you plan on deploying STF to a multiuser environment. We welcome contributions in this area.
+
+## Requirements
+
+* [Node.js](https://nodejs.org/) >= 0.12
+* [ADB](http://developer.android.com/tools/help/adb.html) properly set up
+* [RethinkDB](http://rethinkdb.com/) >= 2.2
+* [GraphicsMagick](http://www.graphicsmagick.org/) (for resizing screenshots)
+* [ZeroMQ](http://zeromq.org/) libraries installed
+* [Protocol Buffers](https://github.com/google/protobuf) libraries installed
+* [yasm](http://yasm.tortall.net/) installed (for compiling embedded [libjpeg-turbo](https://github.com/sorccu/node-jpeg-turbo))
+* [pkg-config](http://www.freedesktop.org/wiki/Software/pkg-config/) so that Node.js can find the libraries
+
+Note that you need these dependencies even if you've installed STF directly from [NPM](https://www.npmjs.com/), because they can't be included in the package.
+
+On OS X, you can use [homebrew](http://brew.sh/) to install most of the dependencies:
+
+```bash
+brew install rethinkdb graphicsmagick zeromq protobuf yasm pkg-config
+```
+
+On Windows you're on your own. In theory you might be able to get STF installed via [Cygwin](https://www.cygwin.com/) or similar, but we've never tried. In principle we will not provide any Windows installation support, but please do send a documentation pull request if you figure out what to do.
+
+We also provide a [Docker](http://docker.com/) container in the [Docker Hub](https://hub.docker.com/) as [openstf/stf](https://registry.hub.docker.com/u/openstf/stf/). You can use our [Dockerfile](Dockerfile) as guidance if you'd prefer to do the installation yourself.
+
+You should now be ready to [build](#building) or [run](#running) STF.
+
+Note that while OS X can be used for development, it doesn't provide a very reliable experience in production due to (presumed) bugs in ADB's OS X implementation. We use [CoreOS](https://coreos.com/) but any Linux or BSD distribution should do fine.
+
+## Installation
+
+As mentioned earlier, you must have all of the [requirements](#requirements) installed first. Then you can simply install via NPM:
+
+```bash
+npm install -g stf
+```
+
+Now you're ready to [run](#running). For development, though, you should [build](#building) instead.
+
+## Building
+
+After you've got all the [requirements](#requirements) installed, it's time to fetch the rest of the dependencies.
+
+First, fetch all NPM and Bower modules:
+
+```bash
+npm install
+```
+
+You may also wish to link the module so that you'll be able to access the `stf` command directly from the command line:
+
+```bash
+npm link
+```
+
+You should now have a working installation for local development.
+
+## Running
+
+STF comprises of several independent processes that must normally be launched separately. In our own setup each one these processes is its own [systemd](http://www.freedesktop.org/wiki/Software/systemd/) unit. See [DEPLOYMENT.md](doc/DEPLOYMENT.md) and [Setup Examples](https://github.com/openstf/setup-examples) if you're interested.
+
+For development purposes, however, there's a helper command to quickly launch all required processes along with a mock login implementation. Note that you **must** have RethinkDB running first.
+
+If you don't have RethinkDB set up yet, to start it up, go to the folder where you'd like RethinkDB to create a `rethinkdb_data` folder in (perhaps the folder where this repo is) and run the following command:
+
+```bash
+rethinkdb
+```
+
+You should now have RethinkDB running locally. Running the command again in the same folder will reuse the data from the previous session.
+
+You're now ready to start up STF itself:
+
+```bash
+stf local
+```
+
+After the [webpack](http://webpack.github.io/) build process has finished (which can take a small while) you should have your private STF running on [http://localhost:7100](http://localhost:7100). If you had devices connected before running the command, those devices should now be available for use. If not, you should see what went wrong from your console. Feel free to plug in or unplug any devices at any time.
+
+Note that if you see your device ready to use but without a name or a proper image, we're probably missing the data for that model in [our device database](https://github.com/openstf/stf-device-db). Everything should work fine either way.
+
+If you want to access STF from other machines, you can add the `--public-ip` option for quick testing.
+
+```bash
+stf local --public-ip <your_internal_network_ip_here>
+```
+
+## Updating
+
+To update your development version, simply pull the repo and run `npm install` again. You may occasionally have to remove the whole `node_modules` and `res/bower_components` folder to prevent NPM or Bower from complaining about version mismatches.
+
+## FAQ
+
+### Can I deploy STF to actual servers?
+
+Yes, see [DEPLOYMENT.md](doc/DEPLOYMENT.md) and [Setup Examples](https://github.com/openstf/setup-examples).
+
+### Will I have to change battery packs all the time?
+
+Nope, we've had many devices running since the initial prototype phase about two years ago, and we've only had a single incident so far. The battery expanded causing the casing to split from the seams. The device itself was working fine and reporting full battery health, but it was discarded due to safety reasons.
+
+Devices should be allowed to turn their screens off when idle, which is what we are doing. All of our devices report perfect battery health so far.
+
+Note that you may have a problem if your USB hubs are unable to both provide enough power and support the data connection at the same time. This can cause a device to stop charging when being used, resulting in many charging cycles. If this happens you will just need to [get a better USB hub](#recommended-hardware).
+
+### Is the system secure?
+
+It's possible to run the whole user-facing side behind HTTPS, but that's pretty much it. All internal communication between processes is insecure and unencrypted, which is a problem if you can eavesdrop on the network. See our [quick note about security](#a-quick-note-about-security).
+
+### Can I just put the system online, put a few devices there and start selling it?
+
+Yes and no. See "[Is the system secure?](#is-the-system-secure)". The system has been built in an environment where we are able to trust our users and be confident that they're not going to want to mess with others. In the current incarnation of the system a malicious user with knowledge of the inner workings will, for instance, be able to control any device at any time, whether it is being used by someone or not. Pull requests are welcome.
+
+### Once I've got the system running, can I pretty much leave it like that or is manual intervention required?
+
+In our experience the system runs just fine most of the time, and any issues are mostly USB-related. You'll usually have to do something about once a week.
+
+The most common issue is that a device will lose all of its active USB connections momentarily. You'll get errors in the logs but the worker process will either recover or get respawned, requiring no action on your side.
+
+Below are the most common errors that do require manual intervention.
+
+* One device worker keeps getting respawned all the time
+  - Rebooting the device usually helps. If the device stays online for long enough you might be able to do it from the UI. Otherwise you'll have to SSH into the server and run `adb reboot` manually.
+  - This could be a sign that you're having USB problems, and the device wishes to be moved elsewhere. The less complex your setup is the fewer problems you're going to experience. See [troubleshooting](#troubleshooting).
+  - We're working on adding periodic automatic restarts and better graceful recovery to alleviate the issue.
+* A whole group of devices keeps dying at once
+  - They're most likely connected to the same USB hub. Either the hub is bad or you have other compatibility issues. In our experience this usually happens with USB 3.0 hubs, or you may have a problem with your USB extension card. See [recommended hardware](#recommended-hardware).
+* A device that should be online is not showing up in the list or is showing up as disconnected
+  - See [troubleshooting](#troubleshooting).
+
+### How do I uninstall STF from my device?
+
+When you unplug your device, all STF utilities except STFService stop running automatically. It doesn't do any harm to force stop or uninstall it.
+
+To uninstall the STFService, run the following command:
+
+```bash
+adb uninstall jp.co.cyberagent.stf
+```
+
+You may also wish to remove our support binaries, although as mentioned before they won't run unless the device is actually connected to STF. You can do this as follows:
+
+```bash
+adb shell rm /data/local/tmp/minicap \
+  /data/local/tmp/minicap.so \
+  /data/local/tmp/minitouch \
+  /data/local/tmp/minirev
+```
+
+Your device is now clean.
+
+## Troubleshooting
+
+### I plugged in a new device but it's not showing up in the list.
+
+There can be various reasons for this behavior. Some especially common reasons are:
+
+* USB debugging is not enabled
+  - Enable it.
+* USB debugging is enabled but the USB connection mode is wrong
+  - Try switching between MTP and PTP modes and see if the device appears. This happens fairly often on OS X but almost never on Linux.
+* You don't have the ADB daemon running
+  - Make sure ADB is running with `adb start-server`.
+* You haven't authorized the ADB key yet
+  - Check your device for an authentication dialog. You may need to unplug and then plug the device back in to see the dialog.
+* ADB hasn't whitelisted the manufacturer's vendor ID
+  - [Add it yourself](https://github.com/apkudo/adbusbini) or wait for the new version that removes the stupid whitelisting feature to be deployed.
+* Insufficient power supply
+  - If you're using a USB hub, try a [powered hub](#recommended-hardware) instead (one that comes with a separate AC adapter).
+  - Even if you're using a powered hub, there might not actually be enough power for all ports simultaneously. [Get a better hub](#recommended-hardware) or use fewer ports.
+  - Your device is too power hungry, can happen with tablets. [Get a better hub](#recommended-hardware).
+* Insufficient USB host controller resources
+  - On Linux, use `dmesg` to check for this error
+  - If you've only got 9-12 devices connected and an Intel (Haswell) processor, it's most likely an issue with the processor. If your BIOS has an option to disable USB 3.0, that might help. If not, you're screwed and must get a PCIE extension card with onboard controllers.
+* Your powered USB hub does not support the device
+  - Can happen with older devices and newer Battery Charging spec compatible hubs. [Get a more compatible hub](#recommended-hardware).
+* The USB cable is bad
+  - It happens. Try another one.
+* The USB hub is broken
+  - This, too, happens. Just try a new one.
+* The device might not have a unique USB serial number, causing STF to overwrite the other device instead
+  - This has never happened to us so far, but we do have one dirt-cheap Android 4.4 device whose serial number is the wonderfully unique "0123456789ABCDEF". Presumably if we had more than one unit we would have a problem.
+
+### A device that was previously connected no longer shows up in the list.
+
+Again, there can be various reasons for this behavior as well. Some common reasons are:
+
+* The device ran out of power
+  - You can see the last reported power level in the device list, unless there was a lengthy power outage preventing the battery level from being reported.
+* Someone accidentally disabled USB debugging remotely
+  - Yes, it happens.
+* An OS update disabled USB debugging
+  - Yes, it happens. Especially on Fire OS.
+* Someone touched the USB cable just the wrong way causing a disconnection
+  - Happens easily.
+* Your PCIE USB extension card died
+  - Yes, it happens.
+* Temporary network issues
+  - Can't help with that.
+* Someone removed the device physically.
+  - Or that.
+* You're on OS X
+  - There's a bug in ADB's OS X implementation that causes devices to be lost on error conditions. The problem is more pronounced when using USB hubs. You have to unplug and then plug it back in again.
+* The USB hub broke
+  - Happens. Just try a new one.
+
+### Remote debugging (i.e. `adb connect`) disconnects while I'm working.
+
+If you're using STF locally, the most common cause is that you're not filtering the devices STF is allowed to connect to. The problem is that once you do `adb connect`, STF sees a new device and tries to set it up. Unfortunately since it's already connected via USB, setting up the new device causes the worker process handling the original USB device to fail. This is not a problem in production, since the devices should be connected to an entirely different machine anyway. For development it's a bit inconvenient. What you can do is give `stf local` a list of serials you wish to use. For example, if your device's serial is `0123456789ABCDEF`, use `stf local 0123456789ABCDEF`. Now you can use `adb connect` and STF will ignore the new device.
+
+There's another likely cause if you're running STF locally. Even if you whitelist devices by serial in STF, your IDE (e.g. Android Studio) doesn't know anything about that. From the IDE's point of view, you have two devices connected. When you try to run or debug your application, Android Studio suddenly notices that two devices are now providing JDWP connections and tries to connect to them both. This doesn't really work since the debugger will only allow one simultaneous connection, which causes problems with ADB. It then decides to disconnect the device (or sometimes itself) entirely.
+
+One more sad possibility is that your Android Studio likes to restart ADB behind the scenes. Even if you restart ADB, USB devices will soon reappear as they're still connected. The same is not true for remote devices, as ADB never stores the list anywhere. This can sometimes also happen with the Android Device Monitor (`monitor`).
+
+## Recommended hardware
+
+This is a list of components we are currently using and are proven to work.
+
+### PC components
+
+These components are for the PC where the USB devices are connected. Our operating system of choice is [CoreOS](https://coreos.com/), but any other Linux or BSD distribution should do fine. Be sure to use reasonably recent kernels, though, as they often include improvements for the USB subsystem.
+
+Our currently favorite build is as follows. It will be able to provide 28 devices using powered USB hubs, and about 10 more if you're willing to use the motherboard's USB ports, which is usually not recommended for stability reasons. Note that our component selection is somewhat limited by their availability in Japan.
+
+| Component | Recommendation | How many |
+|-----------|------|----------|
+| PC case | [XIGMATEK Nebula](http://www.xigmatek.com/product.php?productid=219) | x1 |
+| Motherboard | [ASUS H97I-PLUS](https://www.asus.com/Motherboards/H97IPLUS/) | x1 |
+| Processor | [Intel® Core™ i5-4460](http://ark.intel.com/products/80817/Intel-Core-i5-4460-Processor-6M-Cache-up-to-3_40-GHz) | x1 |
+| PSU | [Corsair CX Series™ Modular CX430M ATX Power Supply](http://www.corsair.com/en/cx-series-cx430m-modular-atx-power-supply-430-watt-80-plus-bronze-certified-modular-psu) | x1 |
+| Memory | Your favorite DDR3 1600 MHz 8GB stick | x1 |
+| SSD | [A-DATA Premier Pro SP900 64GB SSD](http://www.adata.com/en/ssd/specification/171) | x1 |
+| USB extension card | [StarTech.com 4 Port PCI Express (PCIe) SuperSpeed USB 3.0 Card Adapter w/ 4 Dedicated 5Gbps Channels - UASP - SATA / LP4 Power](http://www.startech.com/Cards-Adapters/USB-3.0/Cards/PCI-Express-USB-3-Card-4-Dedicated-Channels-4-Port~PEXUSB3S44V) | x1 |
+| USB hub | [Plugable USB 2.0 7 Port Hub with 60W Power Adapter](http://plugable.com/products/usb2-hub7bc) | x4 |
+| MicroUSB cable | [Monoprice.com 1.5ft USB 2.0 A Male to Micro 5pin Male 28/24AWG Cable w/ Ferrite Core (Gold Plated)](http://www.monoprice.com/Product?c_id=103&cp_id=10303&cs_id=1030307&p_id=5456&seq=1&format=2) | x28 |
+
+You may also need extension cords for power.
+
+Alternatively, if you find that some of your older devices [do not support the recommended hub](#troubleshooting), you may wish to mix the hub selection as follows:
+
+| Component | Recommendation | How many |
+|-----------|------|----------|
+| USB hub | [Plugable USB 2.0 7 Port Hub with 60W Power Adapter](http://plugable.com/products/usb2-hub7bc) | x2 |
+| USB hub for older devices | [System TALKS USB2-HUB4XA-BK](http://www.system-talks.co.jp/product/sgc-4X.htm) | x2-4 |
+
+You can connect up to two of the older hubs (providing up to 8 devices total) directly to the motherboard without exhausting USB host controller resources.
+
+We also have several "budget builds" with an [MSI AM1I](http://www.msi.com/product/mb/AM1I.html#hero-overview) motherboard and an [AMD Athlon 5350 4-core processor](http://www.amd.com/en-gb/products/processors/desktop/athlon). These builds, while significantly cheaper, sometimes completely lose the USB PCIE extension cards, and even a reboot will not always fix it. This may normally be fixable via BIOS USB settings, but unfortunately the budget motherboard has a complete lack of any useful options. Fortunately the AMD processor does not share Intel's Haswell [USB host control resource problem](#troubleshooting), so you can also just connect your hubs to the motherboard directly if you don't mind sharing the root bus.
+
+Below is an incomplete list of some of the components we have tried so far, including unsuitable ones.
+
+#### Tested equipment
+
+_Note that our hardware score ratings only reflect their use for the purposes of this project, and are not an overall statement about the quality of the product._
+
+##### USB extension cards
+
+| Name | Score | Short explanation |
+|------|-------|-------------------|
+| [StarTech.com 4 Port PCI Express (PCIe) SuperSpeed USB 3.0 Card Adapter w/ 4 Dedicated 5Gbps Channels - UASP - SATA / LP4 Power](http://www.startech.com/Cards-Adapters/USB-3.0/Cards/PCI-Express-USB-3-Card-4-Dedicated-Channels-4-Port~PEXUSB3S44V) | 9/10 | Reliable, well supported chipset and good power connections |
+| [StarTech.com 4 Independent Port PCI Express USB 2.0 Adapter Card](http://www.startech.com/Cards-Adapters/USB-2/Card/4-Independent-Port-PCI-Express-USB-Card~PEXUSB400) | 8/10 | Reliable |
+| [玄人志向 USB3.0RX4-P4-PCIE](http://www.kuroutoshikou.com/product/interface/usb/usb3_0rx4-p4-pcie/) | 4/10 | Well supported chipset but breaks VERY easily |
+
+Our current recommendation is [StarTech.com's PEXUSB3S44V](http://www.startech.com/Cards-Adapters/USB-3.0/Cards/PCI-Express-USB-3-Card-4-Dedicated-Channels-4-Port~PEXUSB3S44V). It provides an independent Renesas (allegedly Linux-friendliest) µPD720202 host controller for each port. Another option from the same maker is [PEXUSB400](http://www.startech.com/Cards-Adapters/USB-2/Card/4-Independent-Port-PCI-Express-USB-Card~PEXUSB400), which also works great but may offer slightly less future proofing.
+
+Our [玄人志向 USB3.0RX4-P4-PCIE](http://www.kuroutoshikou.com/product/interface/usb/usb3_0rx4-p4-pcie/) cards have been nothing but trouble and we've mostly phased them out by now. Chipset-wise it's pretty much the same thing as StarTech's offering, but the SATA power connector is awfully flimsy and can actually physically break off. The card is also incredibly sensitive to static electricity and will permanently brick itself, which happened on numerous occasions.
+
+##### USB hubs
+
+| Name | Score | Short explanation |
+|------|-------|-------------------|
+| [Plugable USB 2.0 7 Port Hub with 60W Power Adapter](http://plugable.com/products/usb2-hub7bc) | 8/10 | High power output, high reliability |
+| [Plugable USB 3.0 7-port Charging Hub with 60W Power Adapter](http://plugable.com/products/usb3-hub7bc) | 5/10 | High power output, low reliability |
+| [System TALKS USB2-HUB4XA-BK USB 2.0 hub with power adapter](http://www.system-talks.co.jp/product/sgc-4X.htm) | 7/10 | High power output on two ports which complicates device positioning, low port count |
+| [Anker USB 3.0 9-Port Hub + 5V 2.1A Charging Port](http://www.ianker.com/product/68ANHUB-B10A) | 2/10 | High port count, insufficient power |
+| [ORICO P10-U2 External ABS 10 Port 2.0 USB HUB for Laptop/Desktop-BLACK](http://www.aliexpress.com/store/product/Orico-p10-u2-computer-usb-hub-usb-splitter-10-usb-hub-interface/105327_1571541943.html) | 3/10 | High port count, insufficient power |
+| [ORICO BH4-U3-BK ABS 4 Port USB3.0 BC1.2 Charging HUB with 12V3A Power Adapter-BLACK](http://www.aliexpress.com/store/product/ORICO-BH4-U3-BK-ABS-4-Port-USB3-0-BC1-2-Charging-HUB-with-12V3A-Power/105327_2035899542.html) | 5/10 | High power output, low reliability |
+
+The best hub we've found so far is Plugable's [USB 2.0 7 Port Hub with 60W Power Adapter](http://plugable.com/products/usb2-hub7bc). It's able to provide 1.5A per port for Battery Charging spec compliant devices, which is enough to both charge and sync even tablets (although charging will not occur at maximum speed, but that's irrelevant to us). Note that even devices that are not compliant will usually charge and sync just fine, albeit slower. The more recent USB 3.0 version has proven unreliable with the rest of our components, causing the whole hub to disconnect at times. Annoyingly the ports face the opposite direction, too. Note that ORICO also provides hubs that are identical to Plugable's offerings, the latter of which seem to be rebrands.
+
+Unfortunately Plugable's USB 2.0 hub is not perfect either, at least for our purposes. It includes a physical on/off switch which can be especially annoying if your devices are in a regular office with occasional scheduled power outages. This will shut down the PC too, of course, but the problem is that once power comes back online, the hubs will be unable to switch themselves on and the devices won't charge, leading you to find a bunch of dead devices the next Monday.
+
+The System TALKS USB 2.0 hub is very reliable, but has a few annoying drawbacks. First, the power adapter only provides power to two of its four ports, while the other two are powered by the host PC. The problem with this approach is that you must figure out which devices are power hungry yourself and put them on the ports with higher current. This can complicate device setup/positioning quite a bit. Another drawback is that if the host PC is turned off, only the powered ports will keep charging the connected devices. However, the hub is amazingly compatible with pretty much anything, making it the top choice for older devices that do not support the Battery Charging hubs.
+
+Most powered USB 3.0 hubs we've tested have had a serious problem: the whole hub occasionally disconnected. This may have been caused by the specific combination of our components and/or OS, but as of yet we don't really know. Disabling USB 3.0 may help if you run into the same problem.
+
+## Translating
+
+Currently STF UI is available in English and Japanese.
+
+If you would like translate to any other language, please contribute in the [STF Transifex project](https://www.transifex.com/projects/p/stf/).
+
+For updating the source and all the translation files first you have to install the [Transifex client](http://docs.transifex.com/client/setup/).
+
+Then just run:
+```bash
+gulp translate
+```
+
+It will do the following:
+
+1. Convert all the `jade` files to `html`.
+2. Extract with gettext all translatable strings to `stf.pot`.
+3. Push `stf.pot` to Transifex.
+4. Pull from Transifex all `po` translations.
+5. Compile all `po` files to `json`.
+
+Then in order to add it officially (only needs to be done once):
+
+1. Add the language to `res/common/lang/langs.json`.
+2. Pull the specific language `tx pull -l <lang>`.
+3. Run `gulp translate`.
+
+## Testing
+
+See [TESTING.md](TESTING.md).
+
+## Contributing
+
+See [CONTRIBUTING.md](CONTRIBUTING.md).
+
+## License
+
+See [LICENSE](LICENSE).
+
+Copyright © CyberAgent, Inc. All Rights Reserved.
+
+[contact-link]: mailto:contact@openstf.io
diff --git a/crowdstf/TESTING.md b/crowdstf/TESTING.md
new file mode 100644
index 0000000..3b0fcac
--- /dev/null
+++ b/crowdstf/TESTING.md
@@ -0,0 +1,25 @@
+## Unit Frontend
+
+- `brew install phantomjs`
+- `gulp karma`
+
+## E2E Frontend
+
+### On first run
+- `gulp webdriver-update`
+
+### Chrome Local STF
+- Connect a device
+- Run stf
+- `gulp protractor`
+
+### Multiple Browsers Local STF with a specific suite
+- Connect a device
+- Run stf
+- `gulp protractor --multi --suite devices`
+
+### Chrome Remote STF
+- `export STF_URL='http://stf-url/#!/'`
+- `export STF_USERNAME='user'`
+- `export STF_PASSWORD='pass'`
+- `gulp protractor`
diff --git a/crowdstf/bin/stf b/crowdstf/bin/stf
new file mode 100755
index 0000000..fa67b84
--- /dev/null
+++ b/crowdstf/bin/stf
@@ -0,0 +1,2 @@
+#!/usr/bin/env node
+require('../lib/cli')
diff --git a/crowdstf/bower.json b/crowdstf/bower.json
new file mode 100644
index 0000000..9fea32c
--- /dev/null
+++ b/crowdstf/bower.json
@@ -0,0 +1,49 @@
+{
+  "name": "stf",
+  "version": "0.1.0",
+  "dependencies": {
+    "angular": "~1.5.0-rc.2",
+    "angular-cookies": "~1.5.0-rc.2",
+    "angular-route": "~1.5.0-rc.2",
+    "angular-sanitize": "~1.5.0-rc.2",
+    "angular-animate": "~1.5.0-rc.2",
+    "angular-touch": "~1.5.0-rc.2",
+    "lodash": "~3.10.1",
+    "oboe": "~2.1.2",
+    "ng-table": "~1.0.0-beta.9",
+    "angular-gettext": "~2.2.0",
+    "angular-ui-ace": "~0.2.3",
+    "angular-dialog-service": "~5.2.11",
+    "ng-file-upload": "~2.0.5",
+    "angular-growl-v2": "JanStevens/angular-growl-2#~0.7.9",
+    "underscore.string": "~3.2.3",
+    "bootstrap": "~3.3.6",
+    "font-lato-2-subset": "~0.4.0",
+    "packery": "~1.4.3",
+    "draggabilly": "~1.2.4",
+    "angular-elastic": "~2.5.1",
+    "angular-hotkeys": "chieffancypants/angular-hotkeys#~1.6.0",
+    "angular-borderlayout": "git://github.com/filearts/angular-borderlayout.git#7c9716aebd9260763f798561ca49d6fbfd4a5c67",
+    "angular-ui-bootstrap": "~1.1.1",
+    "ng-context-menu": "AdiDahan/ng-context-menu#~1.0.5",
+    "components-font-awesome": "~4.5.0",
+    "epoch": "~0.8.4",
+    "ng-epoch": "~1.0.7",
+    "eventEmitter": "~4.3.0",
+    "angular-ladda": "~0.3.1",
+    "d3": "~3.5.14",
+    "spin.js": "~2.3.2",
+    "angular-xeditable": "~0.1.9"
+  },
+  "private": true,
+  "devDependencies": {
+    "angular-mocks": "~1.5.0-rc.2"
+  },
+  "resolutions": {
+    "angular": "~1.5.0-rc.2",
+    "d3": "~3.5.5",
+    "spin.js": "~2.3.2",
+    "eventEmitter": "~4.3.0",
+    "epoch": "~0.8.4"
+  }
+}
diff --git a/crowdstf/doc/7s_usage.gif b/crowdstf/doc/7s_usage.gif
new file mode 100644
index 0000000..dda8c48
--- /dev/null
+++ b/crowdstf/doc/7s_usage.gif
Binary files differ
diff --git a/crowdstf/doc/DEPLOYMENT.md b/crowdstf/doc/DEPLOYMENT.md
new file mode 100644
index 0000000..4e8d40a
--- /dev/null
+++ b/crowdstf/doc/DEPLOYMENT.md
@@ -0,0 +1,942 @@
+# Deployment
+
+So you've got STF running via `stf local` and now you'd like to deploy it to real servers. While there are of course various ways to set everything up, this document will focus on a [systemd](http://www.freedesktop.org/wiki/Software/systemd/) + [Docker](https://www.docker.com/) deployment. Even if you've got a different setup, you should be able to use the configuration files as a rough guide. You can also check some [Setup Examples](https://github.com/openstf/setup-examples) which uses [Vagrant](https://www.vagrantup.com/) and [Virtual Box](https://www.virtualbox.org/) to create a virtual setup. But before going there, it is highly recommended that you read this document thoroughly.
+
+STF consists of multiple independent processes communicating via [ZeroMQ](http://zeromq.org/) and [Protocol Buffers](https://github.com/google/protobuf). We call each process a "unit" to match systemd terminology.
+
+The core topology is as follows.
+
+![Rough core topology](topo-v1.png?raw=true)
+
+Each unit and its function will be explained later in the document.
+
+## Assumptions
+
+For this example deployment, the following assumptions will be made. You will need to adjust them as you see fit. Note that this deployment was designed to be relatively easy to set up without external tools, and may not be optimal. They're also configured so that you can run everything on a single host if required.
+
+* You have [systemd](http://www.freedesktop.org/wiki/Software/systemd/) running on each host
+* You have [Docker](https://www.docker.com/) running on each host
+* Each host has an `/etc/environment` (a la [CoreOS](https://coreos.com/)) file with `COREOS_PRIVATE_IPV4=MACHINE_IP_HERE`. This is used to load the machine IP address in configuration files.
+  - You can create the file yourself or alternatively replace `${COREOS_PRIVATE_IPV4}` manually as required.
+* You're deploying [openstf/stf:latest](https://registry.hub.docker.com/u/openstf/stf/). There's also a fixed tag for each release if you're feeling less adventurous.
+* You want to access the app at https://stf.example.org/. Change to the actual URL you want to use.
+* You have RethinkDB running on `rethinkdb.stf.example.org`. Change to the actual address/IP where required.
+  - You may also use SRV records by giving the url in `srv+tcp://rethinkdb-28015.skydns.stf.example.org` format.
+* You have two static IPs available for the main communication bridges (or "triproxies"), or are able to figure out an alternate method. In this example we'll use `devside.stf.example.org` and `appside.stf.example.org` as easy to remember addresses.
+  - You can also use SRV records as mentioned above.
+
+## Roles
+
+Since we're dealing with actual physical devices, some units need to be deployed to specific servers to make sure that they actually connect with the devices. We currently use [fleet](https://github.com/coreos/fleet), but in this example deployment we'll just assume that you already know how you wish to deploy and distribute the systemd units.
+
+### Provider role
+
+The provider role requires the following units, which must be together on a single or more hosts.
+
+* [adbd.service](#adbservice)
+* [stf-provider@.service](#stf-providerservice)
+
+### App role
+
+The app role can contain any of the following units. You may distribute them as you wish, as long as the [assumptions above](#assumptions) hold. Some units may have more requirements, they will be listed where applicable.
+
+* [rethinkdb-proxy-28015.service](#rethinkdb-proxy-28015service)
+* [stf-app@.service](#stf-appservice)
+* [stf-auth@.service](#stf-authservice)
+* [stf-log-rethinkdb.service](#stf-log-rethinkdbservice)
+* [stf-migrate.service](#stf-migrateservice)
+* [stf-notify-hipchat.service](#stf-notify-hipchatservice)
+* [stf-processor@.service](#stf-processorservice)
+* [stf-provider@.service](#stf-providerservice)
+* [stf-reaper.service](#stf-reaperservice)
+* [stf-storage-plugin-apk@.service](#stf-storage-plugin-apkservice)
+* [stf-storage-plugin-image@.service](#stf-storage-plugin-imageservice)
+* [stf-storage-temp@.service](#stf-storage-tempservice)
+* [stf-triproxy-app.service](#stf-triproxy-appservice)
+* [stf-triproxy-dev.service](#stf-triproxy-devservice)
+* [stf-websocket@.service](#stf-websocketservice)
+
+### Database role
+
+The database role requires the following units, UNLESS you already have a working RethinkDB server/cluster running somewhere. In that case you simply will not have this role, and should point your [rethinkdb-proxy-28015.service](#rethinkdb-proxy-28015service) to that server instead.
+
+* [rethinkdb.service](#rethinkdbservice)
+
+### Proxy role
+
+The proxy role ties all HTTP-based units together behind a common reverse proxy. See [nginx configuration](#nginx-configuration) for more information.
+
+## Support units
+
+These external units are required for the actual STF units to work.
+
+### `adbd.service`
+
+You need to have a single `adbd.service` unit running on each host where you have devices connected.
+
+The docker container comes with a default, insecure ADB key for convenience purposes, so that you won't have to accept a new ADB key on your devices each time the unit restarts. This is insecure because anyone in possession of the insecure key will then be able to access your device without any prompt, assuming they have physical access to it. This may or may not be a problem for you. See [sorccu/adb](https://registry.hub.docker.com/u/sorccu/adb/) for more information if you'd like to provide your own keys.
+
+```ini
+[Unit]
+Description=ADB daemon
+After=docker.service
+Requires=docker.service
+
+[Service]
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull sorccu/adb:latest
+ExecStartPre=-/usr/bin/docker kill %p
+ExecStartPre=-/usr/bin/docker rm %p
+ExecStart=/usr/bin/docker run --rm \
+  --name %p \
+  --privileged \
+  -v /dev/bus/usb:/dev/bus/usb \
+  --net host \
+  sorccu/adb:latest
+ExecStop=-/usr/bin/docker stop -t 2 %p
+```
+
+### `rethinkdb.service`
+
+As mentioned before, you only need this unit if you do not have an existing RethinkDB cluster. This configuration is provided as an example, and will get you going, but is not very robust or secure.
+
+If you need to expand your RethinkDB cluster beyond one server you may encounter problems that you'll have to solve by yourself, we're not going to help with that. There are many ways to configure the unit, this is just one possibility! Note that if you end up not using `--net host`, you will then have to give `rethinkdb` the `--canonical-address` option with the server's real IP, and expose the necessary ports somehow.
+
+You will also have to:
+
+1. Modify the `--cache-size` as you please. It limits the amount of memory RethinkDB uses and is given in megabytes, but is not an absolute limit! Real usage can be slightly higher.
+2. Update the version number in `rethinkdb:2.1.1` for the latest release. We don't use `rethinkdb:latest` here because then you might occasionally have to manually rebuild your indexes after an update and not even realize it, bringing the whole system effectively down.
+3. The `AUTHKEY` environment variable is only for convenience when linking. So, the first time you set things up, you will have to access http://DB_SERVER_IP:8080 after starting the unit and run the following command:
+
+```javascript
+r.db('rethinkdb').table('cluster_config').get('auth').update({auth_key: 'newkey'})
+```
+
+More information can be found [here](https://rethinkdb.com/docs/security/). You will then need to replace `YOUR_RETHINKDB_AUTH_KEY_HERE_IF_ANY` in the the rest of the units with the real authentication key.
+
+Here's the unit configuration itself.
+
+```ini
+[Unit]
+Description=RethinkDB
+After=docker.service
+Requires=docker.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull rethinkdb:2.1.1
+ExecStartPre=-/usr/bin/docker kill %p
+ExecStartPre=-/usr/bin/docker rm %p
+ExecStartPre=/usr/bin/mkdir -p /srv/rethinkdb
+ExecStartPre=/usr/bin/chattr -R +C /srv/rethinkdb
+ExecStart=/usr/bin/docker run --rm \
+  --name %p \
+  -v /srv/rethinkdb:/data \
+  -e "AUTHKEY=YOUR_RETHINKDB_AUTH_KEY_HERE_IF_ANY" \
+  --net host \
+  rethinkdb:2.1.1 \
+  rethinkdb --bind all \
+    --cache-size 8192
+ExecStop=-/usr/bin/docker stop -t 10 %p
+```
+
+### `rethinkdb-proxy-28015.service`
+
+You need a single instance of the `rethinkdb-proxy-28015.service` unit on each host where you have another unit that needs to access the database. Having a local proxy simplifies configuration for other units and allows the `AUTHKEY` to be specified only once.
+
+Note that the `After` condition also specifies the [rethinkdb.service](#rethinkdbservice) unit just in case you're on a low budget and want to run the RethinkDB unit on the same server as the rest of the units, which by the way is NOT recommended at all.
+
+```ini
+[Unit]
+Description=RethinkDB proxy/28015
+After=docker.service rethinkdb.service
+Requires=docker.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/ambassador:latest
+ExecStartPre=-/usr/bin/docker kill %p
+ExecStartPre=-/usr/bin/docker rm %p
+ExecStart=/usr/bin/docker run --rm \
+  --name %p \
+  -e "AUTHKEY=YOUR_RETHINKDB_AUTH_KEY_HERE_IF_ANY" \
+  -p 28015 \
+  -e RETHINKDB_PORT_28015_TCP=tcp://rethinkdb.stf.example.org:28015 \
+  openstf/ambassador:latest
+ExecStop=-/usr/bin/docker stop -t 10 %p
+```
+
+## Main units
+
+These units are required for proper operation of STF. Unless mentioned otherwise, each unit can have multiple running instances (possibly on separate hosts) if desired.
+
+### `stf-app@.service`
+
+**Requires** the `rethinkdb-proxy-28015.service` unit on the same host.
+
+The app unit provides the main HTTP server and currently a very, very modest API for the client-side. It also serves all static resources including images, scripts and stylesheets.
+
+This is a template unit, meaning that you'll need to start it with an instance identifier. In this example configuration the identifier is used to specify the exposed port number (i.e. `stf-app@3100.service` runs on port 3100). You can have multiple instances running on the same host by using different ports.
+
+```ini
+[Unit]
+Description=STF app
+After=rethinkdb-proxy-28015.service
+BindsTo=rethinkdb-proxy-28015.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p-%i
+ExecStartPre=-/usr/bin/docker rm %p-%i
+ExecStart=/usr/bin/docker run --rm \
+  --name %p-%i \
+  --link rethinkdb-proxy-28015:rethinkdb \
+  -e "SECRET=YOUR_SESSION_SECRET_HERE" \
+  -p %i:3000 \
+  openstf/stf:latest \
+  stf app --port 3000 \
+    --auth-url https://stf.example.org/auth/mock/ \
+    --websocket-url https://stf.example.org/
+ExecStop=-/usr/bin/docker stop -t 10 %p-%i
+```
+
+You may have to change the `--auth-url` depending on which authentication method you choose.
+
+### `stf-auth@.service`
+
+You have multiple options here. STF currently provides authentication units for [OAuth 2.0](http://oauth.net/2/) and [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol), plus a mock implementation that simply asks for a name and an email address.
+
+#### Option A: Mock auth
+
+With the mock auth provider the user simply enters their name and email and the system trusts those values. This is what the development version uses by default. Obviously not very secure, but very easy to set up if you can trust your users.
+
+This is a template unit, meaning that you'll need to start it with an instance identifier. In this example configuration the identifier is used to specify the exposed port number (i.e. `stf-auth@3200.service` runs on port 3200). You can have multiple instances running on the same host by using different ports.
+
+**NOTE:** Don't forget to change the `--auth-url` option in the `stf-app` unit. For mock auth, the value should be `https://stf.example.org/auth/mock/`.
+
+```ini
+[Unit]
+Description=STF mock auth
+After=docker.service
+Requires=docker.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p-%i
+ExecStartPre=-/usr/bin/docker rm %p-%i
+ExecStart=/usr/bin/docker run --rm \
+  --name %p-%i \
+  -e "SECRET=YOUR_SESSION_SECRET_HERE" \
+  -p %i:3000 \
+  openstf/stf:latest \
+  stf auth-mock --port 3000 \
+    --app-url https://stf.example.org/
+ExecStop=-/usr/bin/docker stop -t 10 %p-%i
+```
+
+#### Option B: OAuth 2.0
+
+We'll set up [Google's OAuth 2.0 provider](https://developers.google.com/identity/protocols/OpenIDConnect#appsetup) as an example, allowing users to log in with their Google accounts. You must be able to sign up for the API and configure the authorized URLs by yourself, we won't help you. You can see the callback URL in the unit config below. Proceed once you've received the client id and client secret.
+
+Note that if you use another OAuth 2 provider that uses a self-signed cert, you may have to add `-e "NODE_TLS_REJECT_UNAUTHORIZED=0"` to the `docker run` command. Don't forget to end the line with `\`.
+
+This is a template unit, meaning that you'll need to start it with an instance identifier. In this example configuration the identifier is used to specify the exposed port number (i.e. `stf-auth@3200.service` runs on port 3200). You can have multiple instances running on the same host by using different ports.
+
+**NOTE:** Don't forget to change the `--auth-url` option in the `stf-app` unit. For OAuth 2.0, the value should be `https://stf.example.org/auth/oauth/`.
+
+```ini
+[Unit]
+Description=STF OAuth 2.0 auth
+After=docker.service
+Requires=docker.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p-%i
+ExecStartPre=-/usr/bin/docker rm %p-%i
+ExecStart=/usr/bin/docker run --rm \
+  --name %p-%i \
+  -e "SECRET=YOUR_SESSION_SECRET_HERE" \
+  -e "OAUTH_AUTHORIZATION_URL=https://accounts.google.com/o/oauth2/v2/auth" \
+  -e "OAUTH_TOKEN_URL=https://www.googleapis.com/oauth2/v4/token" \
+  -e "OAUTH_USERINFO_URL=https://www.googleapis.com/oauth2/v3/userinfo" \
+  -e "OAUTH_CLIENT_ID=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.apps.googleusercontent.com" \
+  -e "OAUTH_CLIENT_SECRET=BBBBBBBBBBBBBBBBBBBBBBBB" \
+  -e "OAUTH_CALLBACK_URL=https://stf.example.org/auth/oauth/callback" \
+  -e "OAUTH_SCOPE=openid email" \
+  -p %i:3000 \
+  openstf/stf:latest \
+  stf auth-oauth2 --port 3000 \
+    --app-url https://stf.example.org/
+ExecStop=-/usr/bin/docker stop -t 10 %p-%i
+```
+
+#### Option C: LDAP
+
+See `stf auth-ldap --help` and change one of the unit files above as required.
+
+**NOTE:** Don't forget to change the `--auth-url` option in the `stf-app` unit. For LDAP, the value should be `https://stf.example.org/auth/ldap/`.
+
+#### Option D: SAML 2.0
+
+This is one of the multiple options for authentication provided by STF. It uses [SAML 2.0](http://saml.xml.org/saml-specifications) protocol. If your company uses [Okta](https://www.okta.com/) or some other SAML 2.0 supported id provider, you can use it.
+
+This is a template unit, meaning that you'll need to start it with an instance identifier. In this example configuration the identifier is used to specify the exposed port number (i.e. `stf-auth@3200.service` runs on port 3200). You can have multiple instances running on the same host by using different ports.
+
+**NOTE:** Don't forget to change the `--auth-url` option in the `stf-app` unit. For SAML 2.0, the value should be `https://stf.example.org/auth/saml/`.
+
+```ini
+[Unit]
+Description=STF SAML 2.0 auth
+After=docker.service
+Requires=docker.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p-%i
+ExecStartPre=-/usr/bin/docker rm %p-%i
+ExecStart=/usr/bin/docker run --rm \
+  --name %p-%i \
+  -v /srv/ssl/id_provider.cert:/etc/id_provider.cert:ro \
+  -e "SECRET=YOUR_SESSION_SECRET_HERE" \
+  -e "SAML_ID_PROVIDER_ENTRY_POINT_URL=YOUR_ID_PROVIDER_ENTRY_POINT" \
+  -e "SAML_ID_PROVIDER_ISSUER=YOUR_ID_PROVIDER_ISSUER" \
+  -e "SAML_ID_PROVIDER_CERT_PATH=/etc/id_proider.cert" \
+  -p %i:3000 \
+  openstf/stf:latest \
+  stf auth-saml2 --port 3000 \
+    --app-url https://stf.example.org/
+ExecStop=-/usr/bin/docker stop -t 10 %p-%i
+```
+
+#### Other options
+
+See `stf -h` for other possible options.
+
+### `stf-migrate.service`
+
+**Requires** the `rethinkdb-proxy-28015.service` unit on the same host.
+
+This unit migrates the database to the latest version, which pretty much means creating tables and setting up indexes. Schema changes do not require a migration unless a new index is introduced.
+
+This is a oneshot unit, meaning that it shuts down after it's done.
+
+```ini
+[Unit]
+Description=STF migrate
+After=rethinkdb-proxy-28015.service
+BindsTo=rethinkdb-proxy-28015.service
+
+[Service]
+EnvironmentFile=/etc/environment
+Type=oneshot
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p
+ExecStartPre=-/usr/bin/docker rm %p
+ExecStart=/usr/bin/docker run --rm \
+  --name %p \
+  --link rethinkdb-proxy-28015:rethinkdb \
+  openstf/stf:latest \
+  stf migrate
+```
+
+### `stf-processor@.service`
+
+**Requires** the `rethinkdb-proxy-28015.service` unit on the same host.
+
+The processor is the main workhorse of STF. It acts as a bridge between the devices and the app, and nearly all communication goes through it. You may wish to have more than one instance running.
+
+This is a template unit, meaning that you'll need to start it with an instance identifier. In this example the identifier has no special purpose, but having it allows you to start more than one unit on the same host.
+
+```ini
+[Unit]
+Description=STF processor
+After=rethinkdb-proxy-28015.service
+BindsTo=rethinkdb-proxy-28015.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p-%i
+ExecStartPre=-/usr/bin/docker rm %p-%i
+ExecStart=/usr/bin/docker run --rm \
+  --name %p-%i \
+  --link rethinkdb-proxy-28015:rethinkdb \
+  openstf/stf:latest \
+  stf processor %p-%i \
+    --connect-app-dealer tcp://appside.stf.example.org:7160 \
+    --connect-dev-dealer tcp://devside.stf.example.org:7260
+ExecStop=-/usr/bin/docker stop -t 10 %p-%i
+```
+
+### `stf-provider@.service`
+
+**Requires** the `adbd.service` unit on the same host.
+
+The provider unit connects to ADB and start worker processes for each device. It then sends and receives commands from the processor.
+
+The name of the provider shows up in the device list, making it easier to see where the physical devices are located. In this configuration the name is set to the hostname.
+
+Note that the provider needs to be able to manage a certain port range, so `--net host` is required until Docker makes it easier to work with ranges. The ports are used for internal services and the screen capturing WebSocket.
+
+This is a template unit, meaning that you'll need to start it with an instance identifier. In this example configuration the identifier is used to specify the provider ID, which can then be matched against in the [nginx](http://nginx.org/) configuration later on. The ID should be unique and persistent. This is only one way to set things up, you may choose to do things differently if it seems sketchy.
+
+Note that you cannot have more than one provider unit running on the same host, as they would compete over which one gets to control the devices. In the future we might add a negotiation protocol to allow for relatively seamless upgrades.
+
+Furthermore, if you're using a self-signed cert, you may have to add `-e "NODE_TLS_REJECT_UNAUTHORIZED=0"` to the `docker run` command. Don't forget to end the line with `\`.
+
+```ini
+[Unit]
+Description=STF provider
+After=adbd.service
+BindsTo=adbd.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p-%i
+ExecStartPre=-/usr/bin/docker rm %p-%i
+ExecStart=/usr/bin/docker run --rm \
+  --name %p-%i \
+  --net host \
+  openstf/stf:latest \
+  stf provider \
+    --name "%H/%i" \
+    --connect-sub tcp://devside.stf.example.org:7250 \
+    --connect-push tcp://devside.stf.example.org:7270 \
+    --storage-url https://stf.example.org/ \
+    --public-ip ${COREOS_PRIVATE_IPV4} \
+    --min-port=15000 \
+    --max-port=25000 \
+    --heartbeat-interval 10000 \
+    --screen-ws-url-pattern "wss://stf.example.org/d/%i/<%= serial %>/<%= publicPort %>/"
+ExecStop=-/usr/bin/docker stop -t 10 %p-%i
+```
+
+### `stf-reaper.service`
+
+**Requires** the `rethinkdb-proxy-28015.service` unit on the same host.
+
+The reaper unit receives heartbeat events from device workers, and marks lost devices as absent until a heartbeat is received again. The purpose of this unit is to ensure the integrity of the present/absent flag in the database, in case a provider shuts down unexpectedly or another unexpected failure occurs. It loads the current state from the database on startup and keeps patching its internal view as events are routed to it.
+
+Note that it doesn't make sense to have more than one reaper running at once, as they would just duplicate the events.
+
+```ini
+[Unit]
+Description=STF reaper
+After=rethinkdb-proxy-28015.service
+BindsTo=rethinkdb-proxy-28015.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p
+ExecStartPre=-/usr/bin/docker rm %p
+ExecStart=/usr/bin/docker run --rm \
+  --name %p \
+  --link rethinkdb-proxy-28015:rethinkdb \
+  openstf/stf:latest \
+  stf reaper dev \
+    --connect-push tcp://devside.stf.example.org:7270 \
+    --connect-sub tcp://appside.stf.example.org:7150 \
+    --heartbeat-timeout 30000
+ExecStop=-/usr/bin/docker stop -t 10 %p
+```
+
+### `stf-storage-plugin-apk@.service`
+
+The APK storage plugin loads raw blobs from the main storage unit and allows additional actions to be performed on APK files, such as retrieving the `AndroidManifest.xml`.
+
+This is a template unit, meaning that you'll need to start it with an instance identifier. In this example configuration the identifier is used to specify the exposed port number (i.e. `stf-storage-plugin-apk@3300.service` runs on port 3300). You can have multiple instances running on the same host by using different ports.
+
+Furthermore, if you're using a self-signed cert, you may have to add `-e "NODE_TLS_REJECT_UNAUTHORIZED=0"` to the `docker run` command. Don't forget to end the line with `\`.
+
+```ini
+[Unit]
+Description=STF APK storage plugin
+After=docker.service
+Requires=docker.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p-%i
+ExecStartPre=-/usr/bin/docker rm %p-%i
+ExecStart=/usr/bin/docker run --rm \
+  --name %p-%i \
+  -p %i:3000 \
+  openstf/stf:latest \
+  stf storage-plugin-apk --port 3000 \
+    --storage-url https://stf.example.org/
+ExecStop=-/usr/bin/docker stop -t 10 %p-%i
+```
+
+### `stf-storage-plugin-image@.service`
+
+The image storage plugin loads raw blobs from the main storage unit and and allows images to be resized using parameters.
+
+This is a template unit, meaning that you'll need to start it with an instance identifier. In this example configuration the identifier is used to specify the exposed port number (i.e. `stf-storage-plugin-image@3400.service` runs on port 3400). You can have multiple instances running on the same host by using different ports.
+
+Furthermore, if you're using a self-signed cert, you may have to add `-e "NODE_TLS_REJECT_UNAUTHORIZED=0"` to the `docker run` command. Don't forget to end the line with `\`.
+
+```ini
+[Unit]
+Description=STF image storage plugin
+After=docker.service
+Requires=docker.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p-%i
+ExecStartPre=-/usr/bin/docker rm %p-%i
+ExecStart=/usr/bin/docker run --rm \
+  --name %p-%i \
+  -p %i:3000 \
+  openstf/stf:latest \
+  stf storage-plugin-image --port 3000 \
+    --storage-url https://stf.example.org/
+ExecStop=-/usr/bin/docker stop -t 10 %p-%i
+```
+
+### `stf-storage-temp@.service`
+
+This is a template unit, meaning that you'll need to start it with an instance identifier. In this example configuration the identifier is used to specify the exposed port number (i.e. `stf-storage-temp@3500.service` runs on port 3500). Currently, **you cannot have more than one instance of this unit**, as both temporary files and an in-memory mapping is used. Using a template unit makes it easy to set the port.
+
+```ini
+[Unit]
+Description=STF temp storage
+After=docker.service
+Requires=docker.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p-%i
+ExecStartPre=-/usr/bin/docker rm %p-%i
+ExecStart=/usr/bin/docker run --rm \
+  --name %p-%i \
+  -v /mnt/storage:/data \
+  -p %i:3000 \
+  openstf/stf:latest \
+  stf storage-temp --port 3000 \
+    --save-dir /data
+ExecStop=-/usr/bin/docker stop -t 10 %p-%i
+```
+
+### `stf-triproxy-app.service`
+
+This unit provides the `appside.stf.example.org` service mentioned earlier. Its purpose is to send and receive requests from the app units, and distribute them across the processor units. It's "dumb" in that it contains no real logic, and you rarely if ever need to upgrade the unit.
+
+We call it a triproxy because it deals with three endpoints instead of the usual two.
+
+You may have more than one instance running simultaneously, and then give a comma separated list to the provider. For simplicity we're using a normal unit here.
+
+```ini
+[Unit]
+Description=STF app triproxy
+After=docker.service
+Requires=docker.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p
+ExecStartPre=-/usr/bin/docker rm %p
+ExecStart=/usr/bin/docker run --rm \
+  --name %p \
+  --net host \
+  openstf/stf:latest \
+  stf triproxy app \
+    --bind-pub "tcp://*:7150" \
+    --bind-dealer "tcp://*:7160" \
+    --bind-pull "tcp://*:7170"
+ExecStop=-/usr/bin/docker stop -t 10 %p
+```
+
+### `stf-triproxy-dev.service`
+
+This unit provides the `devside.stf.example.org` service mentioned earlier. Its purpose is to send and receive requests from the provider units, and distribute them across the processor units. It's "dumb" in that it contains no real logic, and you rarely if ever need to upgrade the unit.
+
+We call it a triproxy because it deals with three endpoints instead of the usual two.
+
+You may have more than one instance running simultaneously, and then give a comma separated list to the provider. For simplicity we're using a normal unit here.
+
+```ini
+[Unit]
+Description=STF dev triproxy
+After=docker.service
+Requires=docker.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p
+ExecStartPre=-/usr/bin/docker rm %p
+ExecStart=/usr/bin/docker run --rm \
+  --name %p \
+  --net host \
+  openstf/stf:latest \
+  stf triproxy dev \
+    --bind-pub "tcp://*:7250" \
+    --bind-dealer "tcp://*:7260" \
+    --bind-pull "tcp://*:7270"
+ExecStop=-/usr/bin/docker stop -t 10 %p
+```
+
+### `stf-websocket@.service`
+
+**Requires** the `rethinkdb-proxy-28015.service` unit on the same host.
+
+The websocket unit provides the communication layer between client-side JavaScript and the server-side ZeroMQ+Protobuf combination. Almost every action in STF goes through the websocket unit.
+
+This is a template unit, meaning that you'll need to start it with an instance identifier. In this example configuration the identifier is used to specify the exposed port number (i.e. `stf-websocket@3600.service` runs on port 3600). You can have multiple instances running on the same host by using different ports.
+
+Furthermore, if you're using a self-signed cert, you may have to add `-e "NODE_TLS_REJECT_UNAUTHORIZED=0"` to the `docker run` command. Don't forget to end the line with `\`.
+
+```ini
+[Unit]
+Description=STF websocket
+After=rethinkdb-proxy-28015.service
+BindsTo=rethinkdb-proxy-28015.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p-%i
+ExecStartPre=-/usr/bin/docker rm %p-%i
+ExecStart=/usr/bin/docker run --rm \
+  --name %p-%i \
+  --link rethinkdb-proxy-28015:rethinkdb \
+  -e "SECRET=YOUR_SESSION_SECRET_HERE" \
+  -p %i:3000 \
+  openstf/stf:latest \
+  stf websocket --port 3000 \
+    --storage-url https://stf.example.org/ \
+    --connect-sub tcp://appside.stf.example.org:7150 \
+    --connect-push tcp://appside.stf.example.org:7170
+ExecStop=/usr/bin/docker stop -t 10 %p-%i
+```
+
+## Optional units
+
+These units are optional and don't affect the way STF works in any way.
+
+### `stf-log-rethinkdb.service`
+
+**Requires** the `rethinkdb-proxy-28015.service` unit on the same host.
+
+Allows you to store device log events into RethinkDB.
+
+Note that it doesn't make sense to have more than one instance of this unit running at once as you'd just record the same events twice.
+
+```ini
+[Unit]
+Description=STF RethinkDB log recorder
+After=rethinkdb-proxy-28015.service
+BindsTo=rethinkdb-proxy-28015.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p
+ExecStartPre=-/usr/bin/docker rm %p
+ExecStart=/usr/bin/docker run --rm \
+  --name %p \
+  --link rethinkdb-proxy-28015:rethinkdb \
+  openstf/stf:latest \
+  stf log-rethinkdb \
+    --connect-sub tcp://appside.stf.example.org:7150
+ExecStop=-/usr/bin/docker stop -t 10 %p
+```
+
+### `stf-notify-hipchat.service`
+
+If you use [HipChat](https://www.hipchat.com/), you can use this unit to push notifications to your room. Check `stf notify-hipchat --help` for more configuration options.
+
+Even if you don't use HipChat, you can use the code as a base for implementing a new notifier.
+
+Note that it doesn't make sense to have more than one instance of this unit running at once. You'd just get the same notifications twice.
+
+```ini
+[Unit]
+Description=STF HipChat notifier
+After=docker.service
+BindsTo=docker.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p
+ExecStartPre=-/usr/bin/docker rm %p
+ExecStart=/usr/bin/docker run --rm \
+  --name %p \
+  -e "HIPCHAT_TOKEN=YOUR_HIPCHAT_TOKEN_HERE" \
+  -e "HIPCHAT_ROOM=YOUR_HIPCHAT_ROOM_HERE" \
+  openstf/stf:latest \
+  stf notify-hipchat \
+    --connect-sub tcp://appside.stf.example.org:7150
+ExecStop=-/usr/bin/docker stop -t 10 %p
+```
+
+### `stf-storage-s3@.service`
+
+If you want to store data such as screenshots and apk files into [Amazon S3](https://aws.amazon.com/s3/) instead of locally, then you can use this optional unit. Before using this you will need to setup your amazon account and get proper credentials for S3 bucket. You can read more about this at [AWS documentation](https://aws.amazon.com/s3/).
+
+** NOTE** If you are using this storage, you will not need [stf-storage-temp@.service](#stf-storage-tempservice) unit, since both do the same thing. Only the storage location is different.
+
+This is a template unit, meaning that you'll need to start it with an instance identifier. In this example configuration the identifier is used to specify the exposed port number (i.e. `stf-storage-s3@3500.service` runs on port 3500). Currently, **you cannot have more than one instance of this unit**, as both temporary files and an in-memory mapping is used. Using a template unit makes it easy to set the port.
+
+```ini
+[Unit]
+Description=STF s3 storage
+After=docker.service
+Requires=docker.service
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull openstf/stf:latest
+ExecStartPre=-/usr/bin/docker kill %p-%i
+ExecStartPre=-/usr/bin/docker rm %p-%i
+ExecStart=/usr/bin/docker run --rm \
+  --name %p-%i \  
+  -p %i:3000 \
+  openstf/stf:latest \
+  stf storage-s3 --port 3000 \
+    --bucket YOUR_S3_BUCKET_NAME_HERE \
+    --profile YOUR_AWS_CREDENTIALS_PROFILE \
+    --endpoint YOUR_BUCKET_ENDPOING_HERE
+ExecStop=-/usr/bin/docker stop -t 10 %p-%i
+```
+
+## Nginx configuration
+
+Now that you've got all the units ready, it's time to set up [nginx](http://nginx.org/) to tie all the processes together with a clean URL.
+
+So, to recap, our example setup is as follows:
+
+| Unit | IP | Port |
+|------|----|------|
+| [stf-app@3100.service](#stf-appservice) | 192.168.255.100 | 3100 |
+| [stf-auth@3200.service](#stf-authservice) | 192.168.255.150 | 3200 |
+| [stf-storage-plugin-apk@3300.service](#stf-storage-plugin-apkservice) | 192.168.255.100 | 3300 |
+| [stf-storage-plugin-image@3400.service](#stf-storage-plugin-imageservice) | 192.168.255.100 | 3400 |
+| [stf-storage-temp@3500.service](#stf-storage-tempservice) | 192.168.255.100 | 3500 |
+| [stf-websocket@3600.service](#stf-websocketservice) | 192.168.255.100 | 3600 |
+
+Furthermore, let's assume that we have the following providers set up:
+
+| Unit | IP | Identifier |
+|------|----|------------|
+| [stf-provider@floor4.service](#stf-providerservice) | 192.168.255.200 | floor4 |
+| [stf-provider@floor8.service](#stf-providerservice) | 192.168.255.201 | floor8 |
+
+Our base nginx configuration for `stf.example.org` would then be:
+
+```nginx
+daemon off;
+worker_processes 4;
+
+events {
+  worker_connections 1024;
+}
+
+http {
+  upstream stf_app {
+    server 192.168.255.100:3100 max_fails=0;
+  }
+
+  upstream stf_auth {
+    server 192.168.255.150:3200 max_fails=0;
+  }
+
+  upstream stf_storage_apk {
+    server 192.168.255.100:3300 max_fails=0;
+  }
+
+  upstream stf_storage_image {
+    server 192.168.255.100:3400 max_fails=0;
+  }
+
+  upstream stf_storage {
+    server 192.168.255.100:3500 max_fails=0;
+  }
+
+  upstream stf_websocket {
+    server 192.168.255.100:3600 max_fails=0;
+  }
+
+  types {
+    application/javascript  js;
+    image/gif               gif;
+    image/jpeg              jpg;
+    text/css                css;
+    text/html               html;
+  }
+
+  map $http_upgrade $connection_upgrade {
+    default  upgrade;
+    ''       close;
+  }
+
+  server {
+    listen 80;
+    server_name stf.example.org;
+    return 301 https://$server_name$request_uri;
+  }
+
+  server {
+    listen 443 ssl;
+    server_name stf.example.org;
+    keepalive_timeout 70;
+    root /dev/null;
+
+    # https://mozilla.github.io/server-side-tls/ssl-config-generator/
+    ssl_certificate /etc/nginx/ssl/cert.pem;
+    ssl_certificate_key /etc/nginx/ssl/cert.key;
+    ssl_session_timeout 5m;
+    ssl_session_cache shared:SSL:10m;
+    ssl_dhparam /etc/nginx/ssl/dhparam.pem;
+    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+    ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA';
+    ssl_prefer_server_ciphers on;
+
+    #add_header Strict-Transport-Security max-age=15768000;
+
+    #ssl_stapling on;
+    #ssl_stapling_verify on;
+    #ssl_trusted_certificate /etc/nginx/ssl/cert.pem;
+
+    resolver 8.8.4.4 8.8.8.8 valid=300s;
+    resolver_timeout 10s;
+
+    # Handle stf-provider@floor4.service
+    location ~ "^/d/floor4/([^/]+)/(?<port>[0-9]{5})/$" {
+      proxy_pass http://192.168.255.200:$port/;
+      proxy_http_version 1.1;
+      proxy_set_header Upgrade $http_upgrade;
+      proxy_set_header Connection $connection_upgrade;
+      proxy_set_header X-Forwarded-For $remote_addr;
+      proxy_set_header X-Real-IP $remote_addr;
+    }
+
+    # Handle stf-provider@floor8.service
+    location ~ "^/d/floor8/([^/]+)/(?<port>[0-9]{5})/$" {
+      proxy_pass http://192.168.255.201:$port/;
+      proxy_http_version 1.1;
+      proxy_set_header Upgrade $http_upgrade;
+      proxy_set_header Connection $connection_upgrade;
+      proxy_set_header X-Forwarded-For $remote_addr;
+      proxy_set_header X-Real-IP $remote_addr;
+    }
+
+    location /auth/ {
+      proxy_pass http://stf_auth/auth/;
+    }
+
+    location /s/image/ {
+      proxy_pass http://stf_storage_image;
+    }
+
+    location /s/apk/ {
+      proxy_pass http://stf_storage_apk;
+    }
+
+    location /s/ {
+      client_max_body_size 1024m;
+      client_body_buffer_size 128k;
+      proxy_pass http://stf_storage;
+    }
+
+    location /socket.io/ {
+      proxy_pass http://stf_websocket;
+      proxy_http_version 1.1;
+      proxy_set_header Upgrade $http_upgrade;
+      proxy_set_header Connection $connection_upgrade;
+      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+      proxy_set_header X-Real-IP $http_x_real_ip;
+    }
+
+    location / {
+      proxy_pass http://stf_app;
+      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+      proxy_set_header X-Real-IP $http_x_real_ip;
+    }
+  }
+}
+
+```
+
+Here's one possible unit configuration for `nginx.service`:
+
+```ini
+[Unit]
+Description=STF nginx public load balancer
+After=docker.service
+Requires=docker.service
+ConditionPathExists=/srv/ssl/stf.example.org.crt
+ConditionPathExists=/srv/ssl/stf.example.org.key
+ConditionPathExists=/srv/ssl/dhparam.pem
+ConditionPathExists=/srv/nginx/nginx.conf
+
+[Service]
+EnvironmentFile=/etc/environment
+TimeoutStartSec=0
+Restart=always
+ExecStartPre=/usr/bin/docker pull nginx:1.7.10
+ExecStartPre=-/usr/bin/docker kill %p
+ExecStartPre=-/usr/bin/docker rm %p
+ExecStart=/usr/bin/docker run --rm \
+  --name %p \
+  --net host \
+  -v /srv/ssl/stf.example.org.crt:/etc/nginx/ssl/cert.pem:ro \
+  -v /srv/ssl/stf.example.org.key:/etc/nginx/ssl/cert.key:ro \
+  -v /srv/ssl/dhparam.pem:/etc/nginx/ssl/dhparam.pem:ro \
+  -v /srv/nginx/nginx.conf:/etc/nginx/nginx.conf:ro \
+  nginx:1.7.10 \
+  nginx
+ExecStop=/usr/bin/docker stop -t 2 %p
+```
+
+Start everything up and you should be good to go.
diff --git a/crowdstf/doc/VNC.md b/crowdstf/doc/VNC.md
new file mode 100644
index 0000000..40bcce1
--- /dev/null
+++ b/crowdstf/doc/VNC.md
@@ -0,0 +1,17 @@
+# VNC
+
+## Implementation details
+
+### Authentication
+
+#### According to the spec
+
+VNC authentication is very weak by default, and doesn't encrypt traffic in any way. It works by sending a random 16-byte challenge to the user, who then encrypts with his/her password and sends back the 16-byte result. The server then encrypts the challenge as well, and checks whether the result sent by the client matches the server's result. Passwords are required to be 8 characters long. Shorter passwords are padded with zeroes and longer passwords simply truncated. Both the server and the client have to know the password. There are no usernames.
+
+#### The way we do it
+
+Since the authentication is very weak anyway, we might as well exploit it. The problem with the spec method is that since there's no username, it's difficult to know *who* wants to connect to a device. The only place for any kind of information is the password, but without knowing the password we can't decrypt the challenge response to see the contents. While we could go through our whole user database encrypting the challenge with each user's password, that doesn't really scale in the long run, especially since we're interested in having per-device passwords as well (more on that later).
+
+Instead, we send over a *static* challenge, e.g. 16 zeroes, every time. Then we simply identify the user by the returned challenge response itself, which is both unique and constant for each password. This makes the authentication more susceptible to eavesdropping since responses from previous sessions can be reused, but given the already weak nature of basic VNC authentication this shouldn't be a massive downgrade, and the app should be running inside an internal network anyway. For real security, all connections should be over a secure tunnel.
+
+Furthermore, each password is only valid for a single device. This will enable interesting proxying and/or load balancing opportunities in the future as we should be able to expose every single device in the system via a single port if desired.
diff --git a/crowdstf/doc/WHY.md b/crowdstf/doc/WHY.md
new file mode 100644
index 0000000..4b499a7
--- /dev/null
+++ b/crowdstf/doc/WHY.md
@@ -0,0 +1,7 @@
+# Why?
+
+This document lists reasons for various internal decisions so that we'll hopefully never forget them (again).
+
+## Why not keep a rotation lock to prevent the screen from reverting to its natural position?
+
+Because a physical rotation would then have no effect. While STF is meant for larger device farms, we anticipate a large portion of our user base to be running STF on their local computers with the actual devices right next to them, and it would be confusing if rotation suddenly had no effect.
diff --git a/crowdstf/doc/shelf_closeup_790x.jpg b/crowdstf/doc/shelf_closeup_790x.jpg
new file mode 100644
index 0000000..fa48a53
--- /dev/null
+++ b/crowdstf/doc/shelf_closeup_790x.jpg
Binary files differ
diff --git a/crowdstf/doc/topo-v1.ditaa b/crowdstf/doc/topo-v1.ditaa
new file mode 100644
index 0000000..79903f6
--- /dev/null
+++ b/crowdstf/doc/topo-v1.ditaa
@@ -0,0 +1,67 @@
+   /------------\  /------------\  /------------\
+   |  websocket |  |  websocket |  |  websocket |
+   |------------|  |------------|  |------------|  x N
+   | PUSH | SUB |  | PUSH | SUB |  | PUSH | SUB |          /------------\
+   \------------/  \------------/  \------------/          |   notify   |
+      |     ^        |     ^        |     ^                |------------|
+      |     |        |     |        |     |                |     SUB    |
+      +-------------++--------------+     |                \------------/
+            |       |      |              |                       ^
+            +-------|------+--------------+                       |
+                    |      | +------------------------------------+
+                    v      | |
+                /--------------\
+                |  PULL | PUB  |----------------------------------+
+                |--------------|                                  |
+----------------|   triproxy   |-----------------  x N            |
+                |--------------|                                  |
+                |    DEALER    |                                  |
+                \--------------/                                  |
+                        ^                                         |
+                        |                                         |
+       +----------------+----------------+                        |
+       |                |                |                        |
+       v                v                v                        v
+/-------------\  /-------------\  /-------------\           /------------\
+|    DEALER   |  |    DEALER   |  |    DEALER   |           |    SUB     |
+|-------------|  |-------------|  |-------------|           |------------|
+|  processor  |  |  processor  |  |  processor  |  x N      |   reaper   |
+|-------------|  |-------------|  |-------------|           |------------|
+|    DEALER   |  |    DEALER   |  |    DEALER   |           |    PUSH    |
+\-------------/  \-------------/  \-------------/           \------------/
+       ^                ^                ^                        |
+       |                |                |                        |
+       +----------------+----------------+                        |
+                        |                                         |
+                        v                                         |
+                 /--------------\                                 |
+                 |    DEALER    |                                 |
+                 |--------------|                                 |
+-----------------|   triproxy   |----------------  x N            |
+                 |--------------|                                 |
+ +-------------->|  PULL | PUB  |                                 |
+ |               \--------------/                                 |
+ |                   ^ ^    | |                                   |
+ |                   | |    | +----------------------------+      |
+ |                   | |    |                              |      |
+ |                   | +----|-------------------------------------+
+ |           +-------|------+----------------+             |
+ |           |       |      |                |             |
+ |     +-------------+-+---------------+     |             |
+ |     |     |         |    |          |     |             |
+ |     |     v         |    v          |     v             |
+ | /------------\  /------------\  /------------\          |
+ | | PUSH | SUB |  | PUSH | SUB |  | PUSH | SUB |          |
+ | |------------|  |------------|  |------------|  x N     |
+ | |     dev    |  |     dev    |  |     dev    |          |
+ | \------------/  \------------/  \------------/          |
+ |        ^               ^               ^                |
+ |        :               :               :                |
+ |        +---------------+---------------+                |
+ |                        :                                |
+ |                        v                                |
+ |                 /------------\                          |
+ +-----------------| PUSH | SUB |<-------------------------+
+                   |------------|
+                   |  provider  |
+                   \------------/
diff --git a/crowdstf/doc/topo-v1.png b/crowdstf/doc/topo-v1.png
new file mode 100644
index 0000000..2f6681d
--- /dev/null
+++ b/crowdstf/doc/topo-v1.png
Binary files differ
diff --git a/crowdstf/docker/armv7l/Dockerfile b/crowdstf/docker/armv7l/Dockerfile
new file mode 100644
index 0000000..b06cfa9
--- /dev/null
+++ b/crowdstf/docker/armv7l/Dockerfile
@@ -0,0 +1,65 @@
+# Get the base image by running the included `mkimage-alpine.sh` script, or
+# get a fresh copy from github.com/docker/docker/contrib/.
+FROM alpine:edge
+
+# Copy app source.
+COPY . /tmp/build/
+
+# Sneak the stf executable into $PATH.
+ENV PATH /app/bin:$PATH
+
+# Build the whole thing. Since Docker Hub doesn't really support other archs,
+# we'll run a full daily build by ourselves, so it doesn't necessary have to
+# be separated into multiple steps for speed.
+#
+# Node build taken from https://github.com/mhart/alpine-node and slightly adapted.
+RUN set -xo pipefail && \
+    echo '--- Updating repositories' && \
+    echo '@testing http://nl.alpinelinux.org/alpine/edge/testing' >> /etc/apk/repositories && \
+    apk update && \
+    echo '--- Building node' && \
+    apk add curl make gcc g++ binutils-gold python linux-headers paxctl libgcc libstdc++ && \
+    curl -sSL https://nodejs.org/dist/v5.7.0/node-v5.7.0.tar.gz | tar -xz && \
+    cd /node-v* && \
+    ./configure --prefix=/usr && \
+    make -j$(grep -c ^processor /proc/cpuinfo 2>/dev/null || 1) && \
+    make install && \
+    paxctl -cm /usr/bin/node && \
+    echo '--- Building app' && \
+    addgroup -S stf && \
+    adduser -S -G stf stf && \
+    chown -R stf:stf /tmp/build && \
+    cd /tmp/build && \
+    export PATH=$PWD/node_modules/.bin:$PATH && \
+    sed -i'' -e '/phantomjs/d' package.json && \
+    apk add git zeromq-dev protobuf-dev graphicsmagick@testing && \
+    export JOBS=$(grep -c ^processor /proc/cpuinfo 2>/dev/null || 1) && \
+    echo 'npm install --no-optional' | su stf -s /bin/sh && \
+    echo '--- Assembling app' && \
+    echo 'npm pack' | su stf -s /bin/sh && \
+    tar -xzf stf-*.tgz && \
+    mv package /app && \
+    echo 'bower cache clean' | su stf -s /bin/sh && \
+    echo 'npm prune --production' | su stf -s /bin/sh && \
+    mv node_modules /app && \
+    chown -R root:root /app && \
+    echo '--- Cleaning up' && \
+    echo 'npm cache clean' | su stf -s /bin/sh && \
+    rm -rf /home/stf/.node-gyp && \
+    apk del curl make gcc g++ binutils-gold python linux-headers paxctl && \
+    rm -rf /node-v* \
+      /usr/share/man /tmp/* /var/cache/apk/* /root/.npm /root/.node-gyp \
+      /usr/lib/node_modules/npm/man /usr/lib/node_modules/npm/doc /usr/lib/node_modules/npm/html
+
+# Work in app dir by default.
+WORKDIR /app
+
+# Export default app port, not enough for all processes but it should do
+# for now.
+EXPOSE 3000
+
+# Switch to weak user.
+USER stf
+
+# Show help by default.
+CMD stf --help
diff --git a/crowdstf/docker/armv7l/README.md b/crowdstf/docker/armv7l/README.md
new file mode 100644
index 0000000..76ec142
--- /dev/null
+++ b/crowdstf/docker/armv7l/README.md
@@ -0,0 +1,95 @@
+# [openstf/stf-armv7l](https://hub.docker.com/r/openstf/stf-armv7l/)
+
+Functionally equivalent to the [openstf/stf](https://hub.docker.com/r/openstf/stf/) Docker image, but runs on `armv7l`.
+
+## Manual build
+
+To build the image, run the following commands from the repo root directory (**not** this directory) on an `armv7l` machine:
+
+```bash
+ARCH=armhf docker/armv7l/mkimage-alpine.sh
+docker build -f docker/armv7l/Dockerfile -t openstf/stf-armv7l:latest .
+```
+
+Note that the build is very, very I/O and CPU-heavy. Don't run it daily on your microSD-backed board or the card will die.
+
+## Nightly build
+
+The following instructions have only been tested on a [Scaleway C1](https://www.scaleway.com/) running Arch Linux with the latest Docker-enabled kernel (currently 4.4.2).
+
+This folder includes [systemd](https://www.freedesktop.org/wiki/Software/systemd/) unit files for an automatic nightly build. The build takes such a long time that there's no point in doing it in real time via hooks.
+
+Once Scaleway tooling becomes a bit easier to approach we might automate the build server provisioning, but honestly it isn't that much work as you'll see.
+
+First, copy the unit files into your build machine's `/etc/systemd/system/` folder. Note that you may have to modify the docker image name inside the unit files if you're planning on pushing the nightly images.
+
+```bash
+cp stf-armv7l-* /etc/systemd/system/
+```
+
+Alternatively `scp` can be much easier depending on your setup:
+
+```bash
+scp stf-armv7l-* root@a.b.c.d:/etc/systemd/system
+```
+
+If you're upgrading the units or otherwise tweaking them, don't forget to let systemd know:
+
+```bash
+systemctl daemon-reload
+```
+
+Let's also make sure that docker and other deps are installed:
+
+```bash
+pacman -Sy
+pacman -S docker git
+```
+
+Some of the unit files require a dedicated build user to exist, so let's create that:
+
+```bash
+useradd --system --create-home -G docker stf-armv7l
+```
+
+Before enabling docker, you may wish to change the storage driver to `overlay`:
+
+```bash
+systemctl edit docker
+```
+
+Enter the following contents. Note that I also tend to always lock down docker's `--bip` so that it can't accidentally conflict with any other range (which I've personally experienced in a complex network). Feel free to remove it if you wish.
+
+```systemd
+[Service]
+ExecStart=
+ExecStart=/usr/bin/docker daemon -H fd:// -s overlay --bip 192.168.255.1/24
+```
+
+Now, enable and start `docker`:
+
+```bash
+systemctl enable docker
+systemctl start docker
+```
+
+If you want to push the built images, you should login to docker:
+
+```bash
+docker login
+```
+
+Now all you need to do is enable and start the timer.
+
+```bash
+systemctl enable stf-armv7l-publish-latest.timer
+systemctl start stf-armv7l-publish-latest.timer
+```
+
+You can also publish tags and other branches manually by:
+
+```bash
+systemctl start stf-armv7l-publish@v1.1.2
+```
+
+Note that the command won't return for a long long time. You can check progress by running `journalctl -f` if necessary.
diff --git a/crowdstf/docker/armv7l/mkimage-alpine.sh b/crowdstf/docker/armv7l/mkimage-alpine.sh
new file mode 100755
index 0000000..26d7209
--- /dev/null
+++ b/crowdstf/docker/armv7l/mkimage-alpine.sh
@@ -0,0 +1,91 @@
+#!/bin/sh
+
+# Originally from:
+# https://github.com/docker/docker/blob/master/contrib/mkimage-alpine.sh
+
+set -e
+
+[ $(id -u) -eq 0 ] || {
+	printf >&2 '%s requires root\n' "$0"
+	exit 1
+}
+
+usage() {
+	printf >&2 '%s: [-r release] [-m mirror] [-s] [-i image]\n' "$0"
+	exit 1
+}
+
+tmp() {
+	TMP=$(mktemp -d ${TMPDIR:-/var/tmp}/alpine-docker-XXXXXXXXXX)
+	ROOTFS=$(mktemp -d ${TMPDIR:-/var/tmp}/alpine-docker-rootfs-XXXXXXXXXX)
+	# This needs to be done or overlayfs won't be happy with our imported image.
+	chmod 755 $ROOTFS
+	trap "rm -rf $TMP $ROOTFS" EXIT TERM INT
+}
+
+apkv() {
+	curl -sSL $MAINREPO/$ARCH/APKINDEX.tar.gz | tar -Oxz |
+		grep --text '^P:apk-tools-static$' -A1 | tail -n1 | cut -d: -f2
+}
+
+getapk() {
+	curl -sSL $MAINREPO/$ARCH/apk-tools-static-$(apkv).apk |
+		tar -xz -C $TMP sbin/apk.static
+}
+
+mkbase() {
+	$TMP/sbin/apk.static --repository $MAINREPO --update-cache --allow-untrusted \
+		--root $ROOTFS --initdb add alpine-base
+}
+
+conf() {
+	printf '%s\n' $MAINREPO > $ROOTFS/etc/apk/repositories
+	printf '%s\n' $ADDITIONALREPO >> $ROOTFS/etc/apk/repositories
+}
+
+pack() {
+	local id
+	id=$(tar --numeric-owner -C $ROOTFS -c . | docker import - $IMAGE)
+	docker run -i --rm $IMAGE printf '%s with id=%s created!\n' $IMAGE $id
+}
+
+save() {
+	[ $SAVE -eq 1 ] || return 0
+
+	tar --numeric-owner -C $ROOTFS -c . | xz > rootfs.tar.xz
+}
+
+while getopts "hr:m:si:" opt; do
+	case $opt in
+		r)
+			REL=$OPTARG
+			;;
+		m)
+			MIRROR=$OPTARG
+			;;
+		s)
+			SAVE=1
+			;;
+		i)
+			IMAGE=$OPTARG
+			;;
+		*)
+			usage
+			;;
+	esac
+done
+
+REL=${REL:-edge}
+MIRROR=${MIRROR:-http://nl.alpinelinux.org/alpine}
+SAVE=${SAVE:-0}
+MAINREPO=$MIRROR/$REL/main
+ADDITIONALREPO=$MIRROR/$REL/community
+ARCH=${ARCH:-$(uname -m)}
+IMAGE=${IMAGE:-alpine:$REL}
+
+tmp
+getapk
+mkbase
+conf
+pack
+save
diff --git a/crowdstf/docker/armv7l/stf-armv7l-baseimage@.service b/crowdstf/docker/armv7l/stf-armv7l-baseimage@.service
new file mode 100644
index 0000000..f2a7e74
--- /dev/null
+++ b/crowdstf/docker/armv7l/stf-armv7l-baseimage@.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=Build openstf/stf %I base image for armv7l
+After=docker.service
+Requires=docker.service
+ConditionArchitecture=arm
+Requires=stf-armv7l-clone@%i.service
+After=stf-armv7l-clone@%i.service
+
+[Service]
+Type=oneshot
+WorkingDirectory=/home/stf-armv7l/repo-%i
+Environment="ARCH=armhf"
+ExecStart=/bin/sh docker/armv7l/mkimage-alpine.sh -r edge -i alpine-stf-armv7l-%i:edge
+ExecStart=/usr/bin/sed -i.bak 's/FROM alpine:edge/FROM alpine-stf-armv7l-%i:edge/' docker/armv7l/Dockerfile
diff --git a/crowdstf/docker/armv7l/stf-armv7l-build@.service b/crowdstf/docker/armv7l/stf-armv7l-build@.service
new file mode 100644
index 0000000..aa9949b
--- /dev/null
+++ b/crowdstf/docker/armv7l/stf-armv7l-build@.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=Build openstf/stf %I for armv7l
+After=docker.service
+Requires=docker.service
+ConditionArchitecture=arm
+Requires=stf-armv7l-baseimage@%i.service
+After=stf-armv7l-baseimage@%i.service
+
+[Service]
+Type=oneshot
+User=stf-armv7l
+WorkingDirectory=/home/stf-armv7l/repo-%i
+ExecStart=/usr/bin/docker build -f docker/armv7l/Dockerfile -t openstf/stf-armv7l:%i .
diff --git a/crowdstf/docker/armv7l/stf-armv7l-clone@.service b/crowdstf/docker/armv7l/stf-armv7l-clone@.service
new file mode 100644
index 0000000..8403432
--- /dev/null
+++ b/crowdstf/docker/armv7l/stf-armv7l-clone@.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=Clone openstf/stf %I for armv7l
+After=docker.service
+Requires=docker.service
+
+[Service]
+Type=oneshot
+User=stf-armv7l
+ExecStart=/usr/bin/rm -rf /home/stf-armv7l/repo-%i
+ExecStart=/usr/bin/git -C /home/stf-armv7l clone --depth 1 -b "%I" https://github.com/openstf/stf.git repo-%i
diff --git a/crowdstf/docker/armv7l/stf-armv7l-publish-latest.service b/crowdstf/docker/armv7l/stf-armv7l-publish-latest.service
new file mode 100644
index 0000000..57f59be
--- /dev/null
+++ b/crowdstf/docker/armv7l/stf-armv7l-publish-latest.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=Publish openstf/stf master as latest for armv7l
+After=docker.service
+Requires=docker.service
+ConditionArchitecture=arm
+Requires=stf-armv7l-publish@master.service
+After=stf-armv7l-publish@master.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/docker tag openstf/stf-armv7l:master openstf/stf-armv7l:latest
+ExecStart=/usr/bin/docker push openstf/stf-armv7l:latest
diff --git a/crowdstf/docker/armv7l/stf-armv7l-publish-latest.timer b/crowdstf/docker/armv7l/stf-armv7l-publish-latest.timer
new file mode 100644
index 0000000..fd1427f
--- /dev/null
+++ b/crowdstf/docker/armv7l/stf-armv7l-publish-latest.timer
@@ -0,0 +1,9 @@
+[Unit]
+Description=Run stf-armv7l-publish-latest.service daily
+
+[Timer]
+OnCalendar=*-*-* 18:00:00
+Persistent=true
+
+[Install]
+WantedBy=timers.target
diff --git a/crowdstf/docker/armv7l/stf-armv7l-publish@.service b/crowdstf/docker/armv7l/stf-armv7l-publish@.service
new file mode 100644
index 0000000..f1de9e8
--- /dev/null
+++ b/crowdstf/docker/armv7l/stf-armv7l-publish@.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=Publish openstf/stf %I for armv7l
+After=docker.service
+Requires=docker.service
+ConditionArchitecture=arm
+Requires=stf-armv7l-build@%i.service
+After=stf-armv7l-build@%i.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/docker push openstf/stf-armv7l:%i
diff --git a/crowdstf/docker/extras/README.md b/crowdstf/docker/extras/README.md
new file mode 100644
index 0000000..be24508
--- /dev/null
+++ b/crowdstf/docker/extras/README.md
@@ -0,0 +1,31 @@
+# Docker build extras
+
+Optional utilities for builders.
+
+## docker-cleanup-dangling-images
+
+Daily cleanup of dangling (untagged) images. If you don't clean up old images you may eventually run out of disk space.
+
+First, copy the unit files into your build machine's `/etc/systemd/system/` folder.
+
+```bash
+cp docker-cleanup-dangling-images.{service,timer} /etc/systemd/system/
+```
+
+Alternatively `scp` can be much easier depending on your setup:
+
+```bash
+scp docker-cleanup-dangling-images.{service,timer} root@a.b.c.d:/etc/systemd/system
+```
+
+Now all you need to do is enable and start the timer.
+
+```bash
+systemctl enable --now docker-cleanup-dangling-images.timer
+```
+
+You can also trigger the cleanup job manually:
+
+```bash
+systemctl start docker-cleanup-dangling-images
+```
diff --git a/crowdstf/docker/extras/docker-cleanup-dangling-images.service b/crowdstf/docker/extras/docker-cleanup-dangling-images.service
new file mode 100644
index 0000000..00384ac
--- /dev/null
+++ b/crowdstf/docker/extras/docker-cleanup-dangling-images.service
@@ -0,0 +1,8 @@
+[Unit]
+Description=Clean up dangling Docker images
+After=docker.service
+Requires=docker.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/env sh -c '/usr/bin/docker images -q --filter "dangling=true" | xargs -t --no-run-if-empty /usr/bin/docker rmi'
diff --git a/crowdstf/docker/extras/docker-cleanup-dangling-images.timer b/crowdstf/docker/extras/docker-cleanup-dangling-images.timer
new file mode 100644
index 0000000..f520138
--- /dev/null
+++ b/crowdstf/docker/extras/docker-cleanup-dangling-images.timer
@@ -0,0 +1,9 @@
+[Unit]
+Description=Run docker-cleanup-dangling-images.service daily
+
+[Timer]
+OnCalendar=*-*-* 10:00:00
+Persistent=true
+
+[Install]
+WantedBy=timers.target
diff --git a/crowdstf/gulpfile.js b/crowdstf/gulpfile.js
new file mode 100644
index 0000000..3152495
--- /dev/null
+++ b/crowdstf/gulpfile.js
@@ -0,0 +1,259 @@
+var path = require('path')
+
+var gulp = require('gulp')
+var gutil = require('gulp-util')
+var jsonlint = require('gulp-jsonlint')
+var eslint = require('gulp-eslint')
+var EslintCLIEngine = require('eslint').CLIEngine
+var webpack = require('webpack')
+var webpackConfig = require('./webpack.config').webpack
+var webpackStatusConfig = require('./res/common/status/webpack.config')
+var gettext = require('gulp-angular-gettext')
+var jade = require('gulp-jade')
+var del = require('del')
+// var protractor = require('gulp-protractor')
+var protractor = require('./res/test/e2e/helpers/gulp-protractor-adv')
+var protractorConfig = './res/test/protractor.conf'
+var karma = require('karma').server
+var karmaConfig = '/res/test/karma.conf.js'
+var stream = require('stream')
+var run = require('gulp-run')
+
+gulp.task('jsonlint', function() {
+  return gulp.src([
+      '.bowerrc'
+    , '.yo-rc.json'
+    , '*.json'
+    ])
+    .pipe(jsonlint())
+    .pipe(jsonlint.reporter())
+})
+
+// Try to use eslint-cli directly instead of eslint-gulp
+// since it doesn't support cache yet
+gulp.task('eslint', function() {
+  return gulp.src([
+      'lib/**/*.js'
+    , 'res/**/*.js'
+    , '!res/bower_components/**'
+    , '*.js'
+  ])
+    // eslint() attaches the lint output to the "eslint" property
+    // of the file object so it can be used by other modules.
+    .pipe(eslint())
+    // eslint.format() outputs the lint results to the console.
+    // Alternatively use eslint.formatEach() (see Docs).
+    .pipe(eslint.format())
+    // To have the process exit with an error code (1) on
+    // lint error, return the stream and pipe to failAfterError last.
+    .pipe(eslint.failAfterError())
+})
+
+gulp.task('eslint-cli', function(done) {
+  var cli = new EslintCLIEngine({
+    cache: true
+  , fix: false
+  })
+
+  var report = cli.executeOnFiles([
+    'lib/**/*.js'
+    , 'res/app/**/*.js'
+    , 'res/auth/**/*.js'
+    , 'res/common/**/*.js'
+    , 'res/test/**/*.js'
+    , 'res/web_modules/**/*.js'
+    , '*.js'
+  ])
+  var formatter = cli.getFormatter()
+  console.log(formatter(report.results))
+
+  if (report.errorCount > 0) {
+    done(new gutil.PluginError('eslint-cli', new Error('ESLint error')))
+  }
+  else {
+    done()
+  }
+})
+
+
+gulp.task('lint', ['jsonlint', 'eslint-cli'])
+gulp.task('test', ['lint', 'run:checkversion'])
+gulp.task('build', ['clean', 'webpack:build'])
+
+gulp.task('run:checkversion', function() {
+  gutil.log('Checking STF version...')
+  return run('./bin/stf -V').exec()
+})
+
+gulp.task('karma_ci', function(done) {
+  karma.start({
+    configFile: path.join(__dirname, karmaConfig)
+  , singleRun: true
+  }, done)
+})
+
+gulp.task('karma', function(done) {
+  karma.start({
+    configFile: path.join(__dirname, karmaConfig)
+  }, done)
+})
+
+if (gutil.env.multi) {
+  protractorConfig = './res/test/protractor-multi.conf'
+}
+else if (gutil.env.appium) {
+  protractorConfig = './res/test/protractor-appium.conf'
+}
+
+gulp.task('webdriver-update', protractor.webdriverUpdate)
+gulp.task('webdriver-standalone', protractor.webdriverStandalone)
+gulp.task('protractor-explorer', function(callback) {
+  protractor.protractorExplorer({
+    url: require(protractorConfig).config.baseUrl
+  }, callback)
+})
+
+gulp.task('protractor', ['webdriver-update'], function(callback) {
+  gulp.src(['./res/test/e2e/**/*.js'])
+    .pipe(protractor.protractor({
+      configFile: protractorConfig
+    , debug: gutil.env.debug
+    , suite: gutil.env.suite
+    }))
+    .on('error', function(e) {
+      console.log(e)
+
+      /* eslint no-console: 0 */
+    })
+    .on('end', callback)
+})
+
+// For piping strings
+function fromString(filename, string) {
+  var src = new stream.Readable({objectMode: true})
+  src._read = function() {
+    this.push(new gutil.File({
+      cwd: ''
+    , base: ''
+    , path: filename
+    , contents: new Buffer(string)
+    }))
+    this.push(null)
+  }
+  return src
+}
+
+
+// For production
+gulp.task('webpack:build', function(callback) {
+  var myConfig = Object.create(webpackConfig)
+  myConfig.plugins = myConfig.plugins.concat(
+    new webpack.DefinePlugin({
+      'process.env': {
+        NODE_ENV: JSON.stringify('production')
+      }
+    })
+  )
+  myConfig.devtool = false
+
+  webpack(myConfig, function(err, stats) {
+    if (err) {
+      throw new gutil.PluginError('webpack:build', err)
+    }
+
+    gutil.log('[webpack:build]', stats.toString({
+      colors: true
+    }))
+
+    // Save stats to a json file
+    // Can be analyzed in http://webpack.github.io/analyse/
+    fromString('stats.json', JSON.stringify(stats.toJson()))
+      .pipe(gulp.dest('./tmp/'))
+
+    callback()
+  })
+})
+
+gulp.task('webpack:others', function(callback) {
+  var myConfig = Object.create(webpackStatusConfig)
+  myConfig.plugins = myConfig.plugins.concat(
+    new webpack.DefinePlugin({
+      'process.env': {
+        NODE_ENV: JSON.stringify('production')
+      }
+    })
+  )
+  myConfig.devtool = false
+
+  webpack(myConfig, function(err, stats) {
+    if (err) {
+      throw new gutil.PluginError('webpack:others', err)
+    }
+
+    gutil.log('[webpack:others]', stats.toString({
+      colors: true
+    }))
+    callback()
+  })
+})
+
+gulp.task('translate', [
+  'translate:extract'
+, 'translate:push'
+, 'translate:pull'
+, 'translate:compile'
+])
+
+gulp.task('jade', function() {
+  return gulp.src([
+      './res/**/*.jade'
+    , '!./res/bower_components/**'
+    ])
+    .pipe(jade({
+      locals: {
+        // So res/views/docs.jade doesn't complain
+        markdownFile: {
+          parseContent: function() {
+          }
+        }
+      }
+    }))
+    .pipe(gulp.dest('./tmp/html/'))
+})
+
+gulp.task('translate:extract', ['jade'], function() {
+  return gulp.src([
+      './tmp/html/**/*.html'
+    , './res/**/*.js'
+    , '!./res/bower_components/**'
+    , '!./res/build/**'
+    ])
+    .pipe(gettext.extract('stf.pot'))
+    .pipe(gulp.dest('./res/common/lang/po/'))
+})
+
+gulp.task('translate:compile', function() {
+  return gulp.src('./res/common/lang/po/**/*.po')
+    .pipe(gettext.compile({
+      format: 'json'
+    }))
+    .pipe(gulp.dest('./res/common/lang/translations/'))
+})
+
+gulp.task('translate:push', function() {
+  gutil.log('Pushing translation source to Transifex...')
+  return run('tx push -s').exec()
+})
+
+gulp.task('translate:pull', function() {
+  gutil.log('Pulling translations from Transifex...')
+  return run('tx pull').exec()
+})
+
+gulp.task('clean', function(cb) {
+  del([
+    './tmp'
+    , './res/build'
+    , '.eslintcache'
+  ], cb)
+})
diff --git a/crowdstf/lib/cli.js b/crowdstf/lib/cli.js
new file mode 100644
index 0000000..0ba93d9
--- /dev/null
+++ b/crowdstf/lib/cli.js
@@ -0,0 +1,1366 @@
+var util = require('util')
+var os = require('os')
+
+var program = require('commander')
+var Promise = require('bluebird')
+var ip = require('my-local-ip')
+
+var pkg = require('../package')
+var cliutil = require('./util/cliutil')
+var logger = require('./util/logger')
+
+Promise.longStackTraces()
+
+program
+  .version(pkg.version)
+
+program
+  .command('provider [serial...]')
+  .description('start provider')
+  .option('-s, --connect-sub <endpoint>'
+    , 'sub endpoint'
+    , cliutil.list)
+  .option('-p, --connect-push <endpoint>'
+    , 'push endpoint'
+    , cliutil.list)
+  .option('-n, --name <name>'
+    , 'name (or os.hostname())'
+    , String
+    , os.hostname())
+  .option('--min-port <port>'
+    , 'minimum port number for worker use'
+    , Number
+    , 7400)
+  .option('--max-port <port>'
+    , 'maximum port number for worker use'
+    , Number
+    , 7700)
+  .option('--public-ip <ip>'
+    , 'public ip for global access'
+    , String
+    , ip())
+  .option('-t, --group-timeout <seconds>'
+    , 'group timeout'
+    , Number
+    , 900)
+  .option('-r, --storage-url <url>'
+    , 'URL to storage client'
+    , String)
+  .option('--heartbeat-interval <ms>'
+    , 'heartbeat interval'
+    , Number
+    , 10000)
+  .option('--adb-host <host>'
+    , 'ADB host (defaults to 127.0.0.1)'
+    , String
+    , '127.0.0.1')
+  .option('--adb-port <port>'
+    , 'ADB port (defaults to 5037)'
+    , Number
+    , 5037)
+  .option('-R, --allow-remote'
+    , 'Whether to allow remote devices to be set up')
+  .option('--screen-ws-url-pattern <pattern>'
+    , 'screen WebSocket URL pattern'
+    , String
+    , 'ws://${publicIp}:${publicPort}')
+  .option('--connect-url-pattern <pattern>'
+    , 'adb connect URL pattern'
+    , String
+    , '${publicIp}:${publicPort}')
+  .option('--vnc-initial-size <size>'
+    , 'initial VNC size'
+    , cliutil.size
+    , [600, 800])
+  .option('--mute-master'
+    , 'whether to mute master volume when devices are being used')
+  .option('--lock-rotation'
+    , 'whether to lock rotation when devices are being used')
+  .action(function(serials, options) {
+    if (!options.connectSub) {
+      this.missingArgument('--connect-sub')
+    }
+    if (!options.connectPush) {
+      this.missingArgument('--connect-push')
+    }
+    if (!options.storageUrl) {
+      this.missingArgument('--storage-url')
+    }
+
+    require('./units/provider')({
+      name: options.name
+    , killTimeout: 10000
+    , ports: cliutil.range(options.minPort, options.maxPort)
+    , filter: function(device) {
+        return serials.length === 0 || serials.indexOf(device.id) !== -1
+      }
+    , allowRemote: options.allowRemote
+    , fork: function(device, ports) {
+        var fork = require('child_process').fork
+        return fork(__filename, [
+          'device', device.id
+        , '--provider', options.name
+        , '--connect-sub', options.connectSub.join(',')
+        , '--connect-push', options.connectPush.join(',')
+        , '--screen-port', ports.shift()
+        , '--connect-port', ports.shift()
+        , '--vnc-port', ports.shift()
+        , '--public-ip', options.publicIp
+        , '--group-timeout', options.groupTimeout
+        , '--storage-url', options.storageUrl
+        , '--adb-host', options.adbHost
+        , '--adb-port', options.adbPort
+        , '--screen-ws-url-pattern', options.screenWsUrlPattern
+        , '--connect-url-pattern', options.connectUrlPattern
+        , '--heartbeat-interval', options.heartbeatInterval
+        , '--vnc-initial-size', options.vncInitialSize.join('x')
+        ]
+        .concat(options.muteMaster ? ['--mute-master'] : [])
+        .concat(options.lockRotation ? ['--lock-rotation'] : []))
+      }
+    , endpoints: {
+        sub: options.connectSub
+      , push: options.connectPush
+      }
+    , adbHost: options.adbHost
+    , adbPort: options.adbPort
+    })
+  })
+
+program
+  .command('device <serial>')
+  .description('start device worker')
+  .option('-n, --provider <name>'
+    , 'provider name'
+    , String)
+  .option('-s, --connect-sub <endpoint>'
+    , 'sub endpoint'
+    , cliutil.list)
+  .option('-p, --connect-push <endpoint>'
+    , 'push endpoint'
+    , cliutil.list)
+  .option('--screen-port <port>'
+    , 'port allocated to the screen websocket'
+    , Number)
+  .option('--connect-port <port>'
+    , 'port allocated to adb connect'
+    , Number)
+  .option('--vnc-port <port>'
+    , 'port allocated to vnc'
+    , Number)
+  .option('--vnc-initial-size <size>'
+    , 'initial VNC size'
+    , cliutil.size
+    , [600, 800])
+  .option('--connect-url-pattern <pattern>'
+    , 'adb connect URL pattern'
+    , String
+    , '${publicIp}:${publicPort}')
+  .option('--public-ip <ip>'
+    , 'public ip for global access'
+    , String
+    , ip())
+  .option('-t, --group-timeout <seconds>'
+    , 'group timeout'
+    , Number
+    , 900)
+  .option('-r, --storage-url <url>'
+    , 'URL to storage client'
+    , String)
+  .option('--adb-host <host>'
+    , 'ADB host (defaults to 127.0.0.1)'
+    , String
+    , '127.0.0.1')
+  .option('--adb-port <port>'
+    , 'ADB port (defaults to 5037)'
+    , Number
+    , 5037)
+  .option('--screen-ws-url-pattern <pattern>'
+    , 'screen WebSocket URL pattern'
+    , String
+    , 'ws://${publicIp}:${publicPort}')
+  .option('--heartbeat-interval <ms>'
+    , 'heartbeat interval'
+    , Number
+    , 10000)
+  .option('--mute-master'
+    , 'whether to mute master volume when devices are being used')
+  .option('--lock-rotation'
+    , 'whether to lock rotation when devices are being used')
+  .action(function(serial, options) {
+    if (!options.connectSub) {
+      this.missingArgument('--connect-sub')
+    }
+    if (!options.connectPush) {
+      this.missingArgument('--connect-push')
+    }
+    if (!options.provider) {
+      this.missingArgument('--provider')
+    }
+    if (!options.screenPort) {
+      this.missingArgument('--screen-port')
+    }
+    if (!options.connectPort) {
+      this.missingArgument('--connect-port')
+    }
+    if (!options.vncPort) {
+      this.missingArgument('--vnc-port')
+    }
+    if (!options.storageUrl) {
+      this.missingArgument('--storage-url')
+    }
+
+    require('./units/device')({
+      serial: serial
+    , provider: options.provider
+    , publicIp: options.publicIp
+    , endpoints: {
+        sub: options.connectSub
+      , push: options.connectPush
+      }
+    , groupTimeout: options.groupTimeout * 1000 // change to ms
+    , storageUrl: options.storageUrl
+    , adbHost: options.adbHost
+    , adbPort: options.adbPort
+    , screenWsUrlPattern: options.screenWsUrlPattern
+    , screenPort: options.screenPort
+    , connectUrlPattern: options.connectUrlPattern
+    , connectPort: options.connectPort
+    , vncPort: options.vncPort
+    , vncInitialSize: options.vncInitialSize
+    , heartbeatInterval: options.heartbeatInterval
+    , muteMaster: options.muteMaster
+    , lockRotation: options.lockRotation
+    })
+  })
+
+program
+  .command('processor <name>')
+  .description('start processor')
+  .option('-a, --connect-app-dealer <endpoint>'
+    , 'app dealer endpoint'
+    , cliutil.list)
+  .option('-d, --connect-dev-dealer <endpoint>'
+    , 'device dealer endpoint'
+    , cliutil.list)
+  .action(function(name, options) {
+    if (!options.connectAppDealer) {
+      this.missingArgument('--connect-app-dealer')
+    }
+    if (!options.connectDevDealer) {
+      this.missingArgument('--connect-dev-dealer')
+    }
+
+    require('./units/processor')({
+      name: name
+    , endpoints: {
+        appDealer: options.connectAppDealer
+      , devDealer: options.connectDevDealer
+      }
+    })
+  })
+
+program
+  .command('reaper <name>')
+  .description('start reaper')
+  .option('-p, --connect-push <endpoint>'
+    , 'push endpoint'
+    , cliutil.list)
+  .option('-s, --connect-sub <endpoint>'
+    , 'sub endpoint'
+    , cliutil.list)
+  .option('-t, --heartbeat-timeout <ms>'
+    , 'consider devices with heartbeat older than this value dead'
+    , Number
+    , 30000)
+  .action(function(name, options) {
+    require('./units/reaper')({
+      name: name
+    , heartbeatTimeout: options.heartbeatTimeout
+    , endpoints: {
+        push: options.connectPush
+      , sub: options.connectSub
+      }
+    })
+  })
+
+program
+  .command('triproxy <name>')
+  .description('start triproxy')
+  .option('-u, --bind-pub <endpoint>'
+    , 'pub endpoint'
+    , String
+    , 'tcp://*:7111')
+  .option('-d, --bind-dealer <endpoint>'
+    , 'dealer endpoint'
+    , String
+    , 'tcp://*:7112')
+  .option('-p, --bind-pull <endpoint>'
+    , 'pull endpoint'
+    , String
+    , 'tcp://*:7113')
+  .action(function(name, options) {
+    require('./units/triproxy')({
+      name: name
+    , endpoints: {
+        pub: options.bindPub
+      , dealer: options.bindDealer
+      , pull: options.bindPull
+      }
+    })
+  })
+
+program
+  .command('auth-ldap')
+  .description('start LDAP auth client')
+  .option('-p, --port <port>'
+    , 'port (or $PORT)'
+    , Number
+    , process.env.PORT || 7120)
+  .option('-s, --secret <secret>'
+    , 'secret (or $SECRET)'
+    , String
+    , process.env.SECRET)
+  .option('-i, --ssid <ssid>'
+    , 'session SSID (or $SSID)'
+    , String
+    , process.env.SSID || 'ssid')
+  .option('-a, --app-url <url>'
+    , 'URL to app'
+    , String)
+  .option('-u, --ldap-url <url>'
+    , 'LDAP server URL (or $LDAP_URL)'
+    , String
+    , process.env.LDAP_URL)
+  .option('-t, --ldap-timeout <timeout>'
+    , 'LDAP timeout (or $LDAP_TIMEOUT)'
+    , Number
+    , process.env.LDAP_TIMEOUT || 1000)
+  .option('--ldap-bind-dn <dn>'
+    , 'LDAP bind DN (or $LDAP_BIND_DN)'
+    , String
+    , process.env.LDAP_BIND_DN)
+  .option('--ldap-bind-credentials <credentials>'
+    , 'LDAP bind credentials (or $LDAP_BIND_CREDENTIALS)'
+    , String
+    , process.env.LDAP_BIND_CREDENTIALS)
+  .option('--ldap-search-dn <dn>'
+    , 'LDAP search DN (or $LDAP_SEARCH_DN)'
+    , String
+    , process.env.LDAP_SEARCH_DN)
+  .option('--ldap-search-scope <scope>'
+    , 'LDAP search scope (or $LDAP_SEARCH_SCOPE)'
+    , String
+    , process.env.LDAP_SEARCH_SCOPE || 'sub')
+  .option('--ldap-search-class <class>'
+    , 'LDAP search objectClass (or $LDAP_SEARCH_CLASS)'
+    , String
+    , process.env.LDAP_SEARCH_CLASS || 'top')
+  .option('--ldap-search-field <name>'
+    , 'LDAP search field (or $LDAP_SEARCH_FIELD)'
+    , String
+    , process.env.LDAP_SEARCH_FIELD)
+  .action(function(options) {
+    if (!options.secret) {
+      this.missingArgument('--secret')
+    }
+    if (!options.appUrl) {
+      this.missingArgument('--app-url')
+    }
+
+    require('./units/auth/ldap')({
+      port: options.port
+    , secret: options.secret
+    , ssid: options.ssid
+    , appUrl: options.appUrl
+    , ldap: {
+        url: options.ldapUrl
+      , timeout: options.ldapTimeout
+      , bind: {
+          dn: options.ldapBindDn
+        , credentials: options.ldapBindCredentials
+        }
+      , search: {
+          dn: options.ldapSearchDn
+        , scope: options.ldapSearchScope
+        , objectClass: options.ldapSearchClass
+        , field: options.ldapSearchField
+        }
+      }
+    })
+  })
+
+program
+  .command('auth-oauth2')
+  .description('start OAuth 2.0 auth client')
+  .option('-p, --port <port>'
+    , 'port (or $PORT)'
+    , Number
+    , process.env.PORT || 7120)
+  .option('-s, --secret <secret>'
+    , 'secret (or $SECRET)'
+    , String
+    , process.env.SECRET)
+  .option('-i, --ssid <ssid>'
+    , 'session SSID (or $SSID)'
+    , String
+    , process.env.SSID || 'ssid')
+  .option('-a, --app-url <url>'
+    , 'URL to app'
+    , String)
+  .option('--oauth-authorization-url <url>'
+    , 'OAuth 2.0 authorization URL (or $OAUTH_AUTHORIZATION_URL)'
+    , String
+    , process.env.OAUTH_AUTHORIZATION_URL)
+  .option('--oauth-token-url <url>'
+    , 'OAuth 2.0 token URL (or $OAUTH_TOKEN_URL)'
+    , String
+    , process.env.OAUTH_TOKEN_URL)
+  .option('--oauth-userinfo-url <url>'
+    , 'OAuth 2.0 token URL (or $OAUTH_USERINFO_URL)'
+    , String
+    , process.env.OAUTH_USERINFO_URL)
+  .option('--oauth-client-id <id>'
+    , 'OAuth 2.0 client ID (or $OAUTH_CLIENT_ID)'
+    , String
+    , process.env.OAUTH_CLIENT_ID)
+  .option('--oauth-client-secret <value>'
+    , 'OAuth 2.0 client secret (or $OAUTH_CLIENT_SECRET)'
+    , String
+    , process.env.OAUTH_CLIENT_SECRET)
+  .option('--oauth-callback-url <url>'
+    , 'OAuth 2.0 callback URL (or $OAUTH_CALLBACK_URL)'
+    , String
+    , process.env.OAUTH_CALLBACK_URL)
+  .option('--oauth-scope <scope>'
+    , 'Space-separated OAuth 2.0 scope (or $OAUTH_SCOPE)'
+    , String
+    , process.env.OAUTH_SCOPE)
+  .action(function(options) {
+    if (!options.secret) {
+      this.missingArgument('--secret')
+    }
+    if (!options.appUrl) {
+      this.missingArgument('--app-url')
+    }
+    if (!options.oauthAuthorizationUrl) {
+      this.missingArgument('--oauth-authorization-url')
+    }
+    if (!options.oauthTokenUrl) {
+      this.missingArgument('--oauth-token-url')
+    }
+    if (!options.oauthUserinfoUrl) {
+      this.missingArgument('--oauth-userinfo-url')
+    }
+    if (!options.oauthClientId) {
+      this.missingArgument('--oauth-client-id')
+    }
+    if (!options.oauthClientSecret) {
+      this.missingArgument('--oauth-client-secret')
+    }
+    if (!options.oauthCallbackUrl) {
+      this.missingArgument('--oauth-callback-url')
+    }
+    if (!options.oauthScope) {
+      this.missingArgument('--oauth-scope')
+    }
+
+    require('./units/auth/oauth2')({
+      port: options.port
+    , secret: options.secret
+    , ssid: options.ssid
+    , appUrl: options.appUrl
+    , oauth: {
+        authorizationURL: options.oauthAuthorizationUrl
+      , tokenURL: options.oauthTokenUrl
+      , userinfoURL: options.oauthUserinfoUrl
+      , clientID: options.oauthClientId
+      , clientSecret: options.oauthClientSecret
+      , callbackURL: options.oauthCallbackUrl
+      , scope: options.oauthScope.split(/\s+/)
+      }
+    })
+  })
+
+  program
+    .command('auth-saml2')
+    .description('start SAML 2.0 auth client')
+    .option('-p, --port <port>'
+      , 'port (or $PORT)'
+      , Number
+      , process.env.PORT || 7120)
+    .option('-s, --secret <secret>'
+      , 'secret (or $SECRET)'
+      , String
+      , process.env.SECRET)
+    .option('-i, --ssid <ssid>'
+      , 'session SSID (or $SSID)'
+      , String
+      , process.env.SSID || 'ssid')
+    .option('-a, --app-url <url>'
+      , 'URL to app'
+      , String)
+    .option('--saml-id-provider-entry-point-url <url>'
+      , 'SAML 2.0 identity provider URL (or $SAML_ID_PROVIDER_ENTRY_POINT_URL)'
+      , String
+      , process.env.SAML_ID_PROVIDER_ENTRY_POINT_URL)
+    .option('--saml-id-provider-issuer <issuer>'
+      , 'SAML 2.0 identity provider issuer (or $SAML_ID_PROVIDER_ISSUER)'
+      , String
+      , process.env.SAML_ID_PROVIDER_ISSUER)
+    .option('--saml-id-provider-cert-path <path>'
+      , 'SAML 2.0 identity provider certificate file path (or $SAML_ID_PROVIDER_CERT_PATH)'
+      , String
+      , process.env.SAML_ID_PROVIDER_CERT_PATH)
+    .action(function(options) {
+      if (!options.secret) {
+        this.missingArgument('--secret')
+      }
+      if (!options.appUrl) {
+        this.missingArgument('--app-url')
+      }
+      if (!options.samlIdProviderEntryPointUrl) {
+        this.missingArgument('--saml-id-provider-entry-point-url')
+      }
+      if (!options.samlIdProviderIssuer) {
+        this.missingArgument('--saml-id-provider-issuer')
+      }
+
+      require('./units/auth/saml2')({
+        port: options.port
+      , secret: options.secret
+      , ssid: options.ssid
+      , appUrl: options.appUrl
+      , saml: {
+          entryPoint: options.samlIdProviderEntryPointUrl
+        , issuer: options.samlIdProviderIssuer
+        , certPath: options.samlIdProviderCertPath
+        }
+      })
+    })
+
+program
+  .command('auth-mock')
+  .description('start mock auth client')
+  .option('-p, --port <port>'
+    , 'port (or $PORT)'
+    , Number
+    , process.env.PORT || 7120)
+  .option('-s, --secret <secret>'
+    , 'secret (or $SECRET)'
+    , String
+    , process.env.SECRET)
+  .option('-i, --ssid <ssid>'
+    , 'session SSID (or $SSID)'
+    , String
+    , process.env.SSID || 'ssid')
+  .option('-a, --app-url <url>'
+    , 'URL to app'
+    , String)
+  .option('--use-basic-auth'
+    , 'Whether to use basic authentication for login or not')
+  .option('--basic-auth-username <username>'
+    , 'Basic Auth Username (or $BASIC_AUTH_USERNAME)'
+    , String
+    , process.env.BASIC_AUTH_USERNAME || 'username')
+  .option('--basic-auth-password <password>'
+    , 'Basic Auth Password (or $BASIC_AUTH_PASSWORD)'
+    , String
+    , process.env.BASIC_AUTH_PASSWORD || 'password')
+  .action(function(options) {
+    if (!options.secret) {
+      this.missingArgument('--secret')
+    }
+    if (!options.appUrl) {
+      this.missingArgument('--app-url')
+    }
+
+    require('./units/auth/mock')({
+      port: options.port
+    , secret: options.secret
+    , ssid: options.ssid
+    , appUrl: options.appUrl
+    , mock: {
+        useBasicAuth: options.useBasicAuth
+      , basicAuth: {
+          username: options.basicAuthUsername
+        , password: options.basicAuthPassword
+        }
+      }
+    })
+  })
+
+program
+  .command('auth-openid')
+  .description('start openid auth client')
+  .option('-p, --port <port>'
+    , 'port (or $PORT)'
+    , Number
+    , process.env.PORT || 7120)
+  .option('-s, --secret <secret>'
+    , 'secret (or $SECRET)'
+    , String
+    , process.env.SECRET)
+  .option('-a, --app-url <url>'
+    , 'URL to app'
+    , String)
+  .option('--openid-identifier-url <openidIdentifierUrl>'
+    , 'openidIdentifierUrl'
+    , String
+    , process.env.OPENID_IDENTIFIER_URL)
+  .action(function(options) {
+    if (!options.openidIdentifierUrl) {
+        this.missingArgument('--openid-identifier-url')
+    }
+    if (!options.secret) {
+        this.missingArgument('--secret')
+    }
+    if (!options.appUrl) {
+        this.missingArgument('--app-url')
+    }
+
+    require('./units/auth/openid')({
+      port: options.port
+    , secret: options.secret
+    , appUrl: options.appUrl
+    , openid: {
+        identifierUrl: options.openidIdentifierUrl
+      }
+    })
+  })
+
+program
+  .command('notify-hipchat')
+  .description('start HipChat notifier')
+  .option('-t, --token <token>'
+    , 'HipChat v2 API token (or $HIPCHAT_TOKEN)'
+    , String
+    , process.env.HIPCHAT_TOKEN)
+  .option('-r, --room <room>'
+    , 'HipChat room (or $HIPCHAT_ROOM)'
+    , String
+    , process.env.HIPCHAT_ROOM)
+  .option('-p, --priority <level>'
+    , 'minimum log level'
+    , Number
+    , logger.Level.IMPORTANT)
+  .option('-n, --notify-priority <level>'
+    , 'minimum log level to cause a notification'
+    , Number
+    , logger.Level.WARNING)
+  .option('-s, --connect-sub <endpoint>'
+    , 'sub endpoint'
+    , cliutil.list)
+  .action(function(options) {
+    if (!options.token) {
+      this.missingArgument('--token')
+    }
+    if (!options.room) {
+      this.missingArgument('--room')
+    }
+    if (!options.connectSub) {
+      this.missingArgument('--connect-sub')
+    }
+
+    require('./units/notify/hipchat')({
+      token: options.token
+    , room: options.room
+    , priority: options.priority
+    , notifyPriority: options.notifyPriority
+    , endpoints: {
+        sub: options.connectSub
+      }
+    })
+  })
+
+program
+  .command('notify-slack')
+  .description('start Slack notifier')
+  .option('-t, --token <token>'
+    , 'Slack API token (or $SLACK_TOKEN)'
+    , String
+    , process.env.SLACK_TOKEN)
+  .option('-c, --channel #<channel>'
+    , 'Slack channel (or $SLACK_CHANNEL)'
+    , String
+    , process.env.SLACK_CHANNEL)
+  .option('-p, --priority <level>'
+    , 'minimum log level'
+    , Number
+    , logger.Level.IMPORTANT)
+  .option('-s, --connect-sub <endpoint>'
+    , 'sub endpoint'
+    , cliutil.list)
+  .action(function(options) {
+    if (!options.token) {
+      this.missingArgument('--token')
+    }
+    if (!options.channel) {
+      this.missingArgument('--channel')
+    }
+    if (!options.connectSub) {
+      this.missingArgument('--connect-sub')
+    }
+
+    require('./units/notify/slack')({
+      token: options.token
+      , channel: options.channel
+      , priority: options.priority
+      , endpoints: {
+        sub: options.connectSub
+      }
+    })
+  })
+
+program
+  .command('log-rethinkdb')
+  .description('start a rethinkdb log recorder')
+  .option('-p, --priority <level>'
+    , 'minimum log level'
+    , Number
+    , logger.Level.DEBUG)
+  .option('-s, --connect-sub <endpoint>'
+    , 'sub endpoint'
+    , cliutil.list)
+  .action(function(options) {
+    if (!options.connectSub) {
+      this.missingArgument('--connect-sub')
+    }
+
+    require('./units/log/rethinkdb')({
+      priority: options.priority
+    , endpoints: {
+        sub: options.connectSub
+      }
+    })
+  })
+
+program
+  .command('poorxy')
+  .description('start a poor reverse proxy for local development')
+  .option('-p, --port <port>'
+    , 'port (or $PORT)'
+    , Number
+    , process.env.PORT || 7100)
+  .option('-u, --app-url <url>'
+    , 'URL to app'
+    , String)
+  .option('-a, --auth-url <url>'
+    , 'URL to auth client'
+    , String)
+  .option('-w, --websocket-url <url>'
+    , 'URL to websocket client'
+    , String)
+  .option('-r, --storage-url <url>'
+    , 'URL to storage client'
+    , String)
+  .option('--storage-plugin-image-url <url>'
+    , 'URL to image storage plugin'
+    , String)
+  .option('--storage-plugin-apk-url <url>'
+    , 'URL to apk storage plugin'
+    , String)
+  .action(function(options) {
+    if (!options.appUrl) {
+      this.missingArgument('--app-url')
+    }
+    if (!options.authUrl) {
+      this.missingArgument('--auth-url')
+    }
+    if (!options.websocketUrl) {
+      this.missingArgument('--websocket-url')
+    }
+    if (!options.storageUrl) {
+      this.missingArgument('--storage-url')
+    }
+    if (!options.storagePluginImageUrl) {
+      this.missingArgument('--storage-plugin-image-url')
+    }
+    if (!options.storagePluginApkUrl) {
+      this.missingArgument('--storage-plugin-apk-url')
+    }
+
+    require('./units/poorxy')({
+      port: options.port
+    , appUrl: options.appUrl
+    , authUrl: options.authUrl
+    , websocketUrl: options.websocketUrl
+    , storageUrl: options.storageUrl
+    , storagePluginImageUrl: options.storagePluginImageUrl
+    , storagePluginApkUrl: options.storagePluginApkUrl
+    })
+  })
+
+program
+  .command('app')
+  .description('start app')
+  .option('-p, --port <port>'
+    , 'port (or $PORT)'
+    , Number
+    , process.env.PORT || 7105)
+  .option('-s, --secret <secret>'
+    , 'secret (or $SECRET)'
+    , String
+    , process.env.SECRET)
+  .option('-i, --ssid <ssid>'
+    , 'session SSID (or $SSID)'
+    , String
+    , process.env.SSID || 'ssid')
+  .option('-a, --auth-url <url>'
+    , 'URL to auth client'
+    , String)
+  .option('-w, --websocket-url <url>'
+    , 'URL to websocket client'
+    , String)
+  .option('--user-profile-url <url>'
+    , 'URL to external user profile page'
+    , String)
+  .action(function(options) {
+    if (!options.secret) {
+      this.missingArgument('--secret')
+    }
+    if (!options.authUrl) {
+      this.missingArgument('--auth-url')
+    }
+    if (!options.websocketUrl) {
+      this.missingArgument('--websocket-url')
+    }
+
+    require('./units/app')({
+      port: options.port
+    , secret: options.secret
+    , ssid: options.ssid
+    , authUrl: options.authUrl
+    , websocketUrl: options.websocketUrl
+    , userProfileUrl: options.userProfileUrl
+    })
+  })
+
+program
+  .command('websocket')
+  .description('start websocket')
+  .option('-p, --port <port>'
+    , 'port (or $PORT)'
+    , Number
+    , process.env.PORT || 7110)
+  .option('-s, --secret <secret>'
+    , 'secret (or $SECRET)'
+    , String
+    , process.env.SECRET)
+  .option('-i, --ssid <ssid>'
+    , 'session SSID (or $SSID)'
+    , String
+    , process.env.SSID || 'ssid')
+  .option('-r, --storage-url <url>'
+    , 'URL to storage client'
+    , String)
+  .option('-u, --connect-sub <endpoint>'
+    , 'sub endpoint'
+    , cliutil.list)
+  .option('-c, --connect-push <endpoint>'
+    , 'push endpoint'
+    , cliutil.list)
+  .action(function(options) {
+    if (!options.secret) {
+      this.missingArgument('--secret')
+    }
+    if (!options.storageUrl) {
+      this.missingArgument('--storage-url')
+    }
+    if (!options.connectSub) {
+      this.missingArgument('--connect-sub')
+    }
+    if (!options.connectPush) {
+      this.missingArgument('--connect-push')
+    }
+
+    require('./units/websocket')({
+      port: options.port
+    , secret: options.secret
+    , ssid: options.ssid
+    , storageUrl: options.storageUrl
+    , endpoints: {
+        sub: options.connectSub
+      , push: options.connectPush
+      }
+    })
+  })
+
+program
+  .command('storage-temp')
+  .description('start temp storage')
+  .option('-p, --port <port>'
+    , 'port (or $PORT)'
+    , Number
+    , process.env.PORT || 7100)
+  .option('--save-dir <dir>'
+    , 'where to save files'
+    , String
+    , os.tmpdir())
+  .action(function(options) {
+    require('./units/storage/temp')({
+      port: options.port
+    , saveDir: options.saveDir
+    })
+  })
+
+program
+  .command('storage-s3')
+  .description('start s3 storage')
+  .option('-p, --port <port>'
+    , 'port (or $PORT)'
+    , Number
+    , process.env.PORT || 7100)
+  .option('--bucket <bucketname>'
+    , 'your s3 bucket name'
+    , String)
+  .option('--profile <name>'
+    , 'your aws credentials profile name'
+    , String)
+  .option('--endpoint <endpoint>'
+    , 'your buckets endpoint'
+    , String)
+  .action(function(options) {
+    if (!options.profile) {
+      this.missingArgument('--profile')
+    }
+
+    if (!options.endpoint) {
+      this.missingArgument('--endpoint')
+    }
+
+    require('./units/storage/s3')({
+      port: options.port
+    , profile: options.profile
+    , bucket: options.bucket
+    , endpoint: options.endpoint
+    })
+  })
+
+program
+  .command('storage-plugin-image')
+  .description('start storage image plugin')
+  .option('-p, --port <port>'
+    , 'port (or $PORT)'
+    , Number
+    , process.env.PORT || 7100)
+  .option('-r, --storage-url <url>'
+    , 'URL to storage client'
+    , String)
+  .option('-c, --concurrency <num>'
+    , 'maximum number of simultaneous transformations'
+    , Number)
+  .option('--cache-dir <dir>'
+    , 'where to cache images'
+    , String
+    , os.tmpdir())
+  .action(function(options) {
+    if (!options.storageUrl) {
+      this.missingArgument('--storage-url')
+    }
+
+    require('./units/storage/plugins/image')({
+      port: options.port
+    , storageUrl: options.storageUrl
+    , cacheDir: options.cacheDir
+    , concurrency: options.concurrency || os.cpus().length
+    })
+  })
+
+program
+  .command('storage-plugin-apk')
+  .description('start storage apk plugin')
+  .option('-p, --port <port>'
+    , 'port (or $PORT)'
+    , Number
+    , process.env.PORT || 7100)
+  .option('-r, --storage-url <url>'
+    , 'URL to storage client'
+    , String)
+  .option('--cache-dir <dir>'
+    , 'where to cache images'
+    , String
+    , os.tmpdir())
+  .action(function(options) {
+    if (!options.storageUrl) {
+      this.missingArgument('--storage-url')
+    }
+
+    require('./units/storage/plugins/apk')({
+      port: options.port
+    , storageUrl: options.storageUrl
+    , cacheDir: options.cacheDir
+    })
+  })
+
+program
+  .command('migrate')
+  .description('migrates the database to the latest version')
+  .action(function() {
+    var log = logger.createLogger('cli:migrate')
+    var db = require('./db')
+
+    db.setup()
+      .then(function() {
+        process.exit(0)
+      })
+      .catch(function(err) {
+        log.fatal('Migration had an error:', err.stack)
+        process.exit(1)
+      })
+  })
+
+program
+  .command('generate-fake-device [model]')
+  .description('generates a fake device for testing')
+  .option('-n, --number <n>'
+    , 'how many devices to create (defaults to 1)'
+    , Number
+    , 1)
+  .action(function(model, options) {
+    var log = logger.createLogger('cli:generate-fake-device')
+    var fake = require('./util/fakedevice')
+    var n = options.number
+
+    function nextDevice() {
+      return fake.generate(model)
+        .then(function(serial) {
+          log.info('Created fake device "%s"', serial)
+
+          if (--n) {
+            return nextDevice()
+          }
+        })
+    }
+
+    nextDevice()
+      .then(function() {
+        process.exit(0)
+      })
+      .catch(function(err) {
+        log.fatal('Fake device creation had an error:', err.stack)
+        process.exit(1)
+      })
+  })
+
+program
+  .command('local [serial...]')
+  .description('start everything locally')
+  .option('--bind-app-pub <endpoint>'
+    , 'app pub endpoint'
+    , String
+    , 'tcp://127.0.0.1:7111')
+  .option('--bind-app-dealer <endpoint>'
+    , 'app dealer endpoint'
+    , String
+    , 'tcp://127.0.0.1:7112')
+  .option('--bind-app-pull <endpoint>'
+    , 'app pull endpoint'
+    , String
+    , 'tcp://127.0.0.1:7113')
+  .option('--bind-dev-pub <endpoint>'
+    , 'device pub endpoint'
+    , String
+    , 'tcp://127.0.0.1:7114')
+  .option('--bind-dev-dealer <endpoint>'
+    , 'device dealer endpoint'
+    , String
+    , 'tcp://127.0.0.1:7115')
+  .option('--bind-dev-pull <endpoint>'
+    , 'device pull endpoint'
+    , String
+    , 'tcp://127.0.0.1:7116')
+  .option('--auth-type <mock|ldap|oauth2|saml2|openid>'
+    , 'auth type'
+    , String
+    , 'mock')
+  .option('-a, --auth-url <url>'
+    , 'URL to auth client'
+    , String)
+  .option('--auth-port <port>'
+    , 'auth port'
+    , Number
+    , 7120)
+  .option('--auth-secret <secret>'
+    , 'auth secret'
+    , String
+    , 'kute kittykat')
+  .option('--auth-options <json>'
+    , 'array of options to pass to the auth implementation'
+    , String
+    , '[]')
+  .option('--poorxy-port <port>'
+    , 'poorxy port'
+    , Number
+    , 7100)
+  .option('--app-port <port>'
+    , 'app port'
+    , Number
+    , 7105)
+  .option('--websocket-port <port>'
+    , 'websocket port'
+    , Number
+    , 7110)
+  .option('--storage-type <temp|s3>'
+    , 'storage type'
+    , String
+    , 'temp')
+  .option('--storage-port <port>'
+    , 'storage port'
+    , Number
+    , 7102)
+  .option('--storage-options <json>'
+    , 'array of options to pass to the storage implementation'
+    , String
+    , '[]')
+  .option('--storage-plugin-image-port <port>'
+    , 'storage image plugin port'
+    , Number
+    , 7103)
+  .option('--storage-plugin-apk-port <port>'
+    , 'storage apk plugin port'
+    , Number
+    , 7104)
+  .option('--provider <name>'
+    , 'provider name (or os.hostname())'
+    , String
+    , os.hostname())
+  .option('--provider-min-port <port>'
+    , 'minimum port number for worker use'
+    , Number
+    , 7400)
+  .option('--provider-max-port <port>'
+    , 'maximum port number for worker use'
+    , Number
+    , 7700)
+  .option('-t, --group-timeout <seconds>'
+    , 'group timeout'
+    , Number
+    , 900)
+  .option('--public-ip <ip>'
+    , 'public ip for global access'
+    , String
+    , 'localhost')
+  .option('--adb-host <host>'
+    , 'ADB host (defaults to 127.0.0.1)'
+    , String
+    , '127.0.0.1')
+  .option('--adb-port <port>'
+    , 'ADB port (defaults to 5037)'
+    , Number
+    , 5037)
+  .option('-R, --allow-remote'
+    , 'Whether to allow remote devices to be set up')
+  .option('--user-profile-url <url>'
+    , 'URL to external user profile page'
+    , String)
+  .option('--vnc-initial-size <size>'
+    , 'initial VNC size'
+    , cliutil.size
+    , [600, 800])
+  .option('--mute-master'
+    , 'whether to mute master volume when devices are being used')
+  .option('--lock-rotation'
+    , 'whether to lock rotation when devices are being used')
+  .action(function(serials, options) {
+    var log = logger.createLogger('cli:local')
+    var procutil = require('./util/procutil')
+
+    // Each forked process waits for signals to stop, and so we run over the
+    // default limit of 10. So, it's not a leak, but a refactor wouldn't hurt.
+    process.setMaxListeners(20)
+
+    function run() {
+      var procs = [
+        // app triproxy
+        procutil.fork(__filename, [
+            'triproxy', 'app001'
+          , '--bind-pub', options.bindAppPub
+          , '--bind-dealer', options.bindAppDealer
+          , '--bind-pull', options.bindAppPull
+          ])
+
+        // device triproxy
+      , procutil.fork(__filename, [
+            'triproxy', 'dev001'
+          , '--bind-pub', options.bindDevPub
+          , '--bind-dealer', options.bindDevDealer
+          , '--bind-pull', options.bindDevPull
+          ])
+
+        // processor one
+      , procutil.fork(__filename, [
+            'processor', 'proc001'
+          , '--connect-app-dealer', options.bindAppDealer
+          , '--connect-dev-dealer', options.bindDevDealer
+          ])
+
+        // processor two
+      , procutil.fork(__filename, [
+            'processor', 'proc002'
+          , '--connect-app-dealer', options.bindAppDealer
+          , '--connect-dev-dealer', options.bindDevDealer
+          ])
+
+        // reaper one
+      , procutil.fork(__filename, [
+            'reaper', 'reaper001'
+          , '--connect-push', options.bindDevPull
+          , '--connect-sub', options.bindAppPub
+          ])
+
+        // provider
+      , procutil.fork(__filename, [
+            'provider'
+          , '--name', options.provider
+          , '--min-port', options.providerMinPort
+          , '--max-port', options.providerMaxPort
+          , '--connect-sub', options.bindDevPub
+          , '--connect-push', options.bindDevPull
+          , '--group-timeout', options.groupTimeout
+          , '--public-ip', options.publicIp
+          , '--storage-url'
+          , util.format('http://localhost:%d/', options.poorxyPort)
+          , '--adb-host', options.adbHost
+          , '--adb-port', options.adbPort
+          , '--vnc-initial-size', options.vncInitialSize.join('x')
+          ]
+          .concat(options.allowRemote ? ['--allow-remote'] : [])
+          .concat(options.muteMaster ? ['--mute-master'] : [])
+          .concat(options.lockRotation ? ['--lock-rotation'] : [])
+          .concat(serials))
+
+        // auth
+      , procutil.fork(__filename, [
+            util.format('auth-%s', options.authType)
+          , '--port', options.authPort
+          , '--secret', options.authSecret
+          , '--app-url', util.format(
+              'http://%s:%d/'
+            , options.publicIp
+            , options.poorxyPort
+            )
+          ].concat(JSON.parse(options.authOptions)))
+
+        // app
+      , procutil.fork(__filename, [
+            'app'
+          , '--port', options.appPort
+          , '--secret', options.authSecret
+          , '--auth-url', options.authUrl || util.format(
+              'http://%s:%d/auth/%s/'
+            , options.publicIp
+            , options.poorxyPort
+            , {
+              oauth2: 'oauth'
+            , saml2: 'saml'
+            }[options.authType] || options.authType
+            )
+          , '--websocket-url', util.format(
+              'http://%s:%d/'
+            , options.publicIp
+            , options.websocketPort
+            )
+          ].concat((function() {
+            var extra = []
+            if (options.userProfileUrl) {
+              extra.push('--user-profile-url', options.userProfileUrl)
+            }
+            return extra
+          })()))
+
+        // websocket
+      , procutil.fork(__filename, [
+            'websocket'
+          , '--port', options.websocketPort
+          , '--secret', options.authSecret
+          , '--storage-url'
+          , util.format('http://localhost:%d/', options.poorxyPort)
+          , '--connect-sub', options.bindAppPub
+          , '--connect-push', options.bindAppPull
+          ])
+
+        // storage
+      , procutil.fork(__filename, [
+            util.format('storage-%s', options.storageType)
+          , '--port', options.storagePort
+          ].concat(JSON.parse(options.storageOptions)))
+
+        // image processor
+      , procutil.fork(__filename, [
+            'storage-plugin-image'
+          , '--port', options.storagePluginImagePort
+          , '--storage-url'
+          , util.format('http://localhost:%d/', options.poorxyPort)
+          ])
+
+        // apk processor
+      , procutil.fork(__filename, [
+            'storage-plugin-apk'
+          , '--port', options.storagePluginApkPort
+          , '--storage-url'
+          , util.format('http://localhost:%d/', options.poorxyPort)
+          ])
+
+        // poorxy
+      , procutil.fork(__filename, [
+            'poorxy'
+          , '--port', options.poorxyPort
+          , '--app-url'
+          , util.format('http://localhost:%d/', options.appPort)
+          , '--auth-url'
+          , util.format('http://localhost:%d/', options.authPort)
+          , '--websocket-url'
+          , util.format('http://localhost:%d/', options.websocketPort)
+          , '--storage-url'
+          , util.format('http://localhost:%d/', options.storagePort)
+          , '--storage-plugin-image-url'
+          , util.format('http://localhost:%d/', options.storagePluginImagePort)
+          , '--storage-plugin-apk-url'
+          , util.format('http://localhost:%d/', options.storagePluginApkPort)
+          ])
+      ]
+
+      function shutdown() {
+        log.info('Shutting down all child processes')
+        procs.forEach(function(proc) {
+          proc.cancel()
+        })
+        return Promise.settle(procs)
+      }
+
+      process.on('SIGINT', function() {
+        log.info('Received SIGINT, waiting for processes to terminate')
+      })
+
+      process.on('SIGTERM', function() {
+        log.info('Received SIGTERM, waiting for processes to terminate')
+      })
+
+      return Promise.all(procs)
+        .then(function() {
+          process.exit(0)
+        })
+        .catch(function(err) {
+          log.fatal('Child process had an error', err.stack)
+          return shutdown()
+            .then(function() {
+              process.exit(1)
+            })
+        })
+    }
+
+    procutil.fork(__filename, ['migrate'])
+      .done(run)
+  })
+
+program
+  .command('doctor')
+  .description('diagnose issues before starting')
+  .option('--devices'
+    , 'diagnose devices connected to stf')
+  .action(function(options) {
+    require('./util/doctor').run(options)
+  })
+
+program.parse(process.argv)
diff --git a/crowdstf/lib/db/api.js b/crowdstf/lib/db/api.js
new file mode 100644
index 0000000..59c801f
--- /dev/null
+++ b/crowdstf/lib/db/api.js
@@ -0,0 +1,332 @@
+var r = require('rethinkdb')
+var util = require('util')
+
+var db = require('./')
+var wireutil = require('../wire/util')
+
+var dbapi = Object.create(null)
+
+dbapi.DuplicateSecondaryIndexError = function DuplicateSecondaryIndexError() {
+  Error.call(this)
+  this.name = 'DuplicateSecondaryIndexError'
+  Error.captureStackTrace(this, DuplicateSecondaryIndexError)
+}
+
+util.inherits(dbapi.DuplicateSecondaryIndexError, Error)
+
+dbapi.close = function(options) {
+  return db.close(options)
+}
+
+dbapi.saveUserAfterLogin = function(user) {
+  return db.run(r.table('users').get(user.email).update({
+      name: user.name
+    , ip: user.ip
+    , lastLoggedInAt: r.now()
+    }))
+    .then(function(stats) {
+      if (stats.skipped) {
+        return db.run(r.table('users').insert({
+          email: user.email
+        , name: user.name
+        , ip: user.ip
+        , group: wireutil.makePrivateChannel()
+        , lastLoggedInAt: r.now()
+        , createdAt: r.now()
+        , forwards: []
+        , settings: {}
+        }))
+      }
+      return stats
+    })
+}
+
+dbapi.loadUser = function(email) {
+  return db.run(r.table('users').get(email))
+}
+
+dbapi.updateUserSettings = function(email, changes) {
+  return db.run(r.table('users').get(email).update({
+    settings: changes
+  }))
+}
+
+dbapi.resetUserSettings = function(email) {
+  return db.run(r.table('users').get(email).update({
+    settings: r.literal({})
+  }))
+}
+
+dbapi.insertUserAdbKey = function(email, key) {
+  return db.run(r.table('users').get(email).update({
+    adbKeys: r.row('adbKeys').default([]).append({
+      title: key.title
+    , fingerprint: key.fingerprint
+    })
+  }))
+}
+
+dbapi.deleteUserAdbKey = function(email, fingerprint) {
+  return db.run(r.table('users').get(email).update({
+    adbKeys: r.row('adbKeys').default([]).filter(function(key) {
+      return key('fingerprint').ne(fingerprint)
+    })
+  }))
+}
+
+dbapi.lookupUsersByAdbKey = function(fingerprint) {
+  return db.run(r.table('users').getAll(fingerprint, {
+    index: 'adbKeys'
+  }))
+}
+
+dbapi.lookupUserByAdbFingerprint = function(fingerprint) {
+  return db.run(r.table('users').getAll(fingerprint, {
+      index: 'adbKeys'
+    })
+    .pluck('email', 'name', 'group'))
+    .then(function(cursor) {
+      return cursor.toArray()
+    })
+    .then(function(groups) {
+      switch (groups.length) {
+        case 1:
+          return groups[0]
+        case 0:
+          return null
+        default:
+          throw new Error('Found multiple users for same ADB fingerprint')
+      }
+    })
+}
+
+dbapi.lookupUserByVncAuthResponse = function(response, serial) {
+  return db.run(r.table('vncauth').getAll([response, serial], {
+      index: 'responsePerDevice'
+    })
+    .eqJoin('userId', r.table('users'))('right')
+    .pluck('email', 'name', 'group'))
+    .then(function(cursor) {
+      return cursor.toArray()
+    })
+    .then(function(groups) {
+      switch (groups.length) {
+        case 1:
+          return groups[0]
+        case 0:
+          return null
+        default:
+          throw new Error('Found multiple users with the same VNC response')
+      }
+    })
+}
+
+dbapi.loadGroup = function(email) {
+  return db.run(r.table('devices').getAll(email, {
+    index: 'owner'
+  }))
+}
+
+dbapi.saveDeviceLog = function(serial, entry) {
+  return db.run(r.table('logs').insert({
+      serial: entry.serial
+    , timestamp: r.epochTime(entry.timestamp)
+    , priority: entry.priority
+    , tag: entry.tag
+    , pid: entry.pid
+    , message: entry.message
+    }
+  , {
+      durability: 'soft'
+    }))
+}
+
+dbapi.saveDeviceInitialState = function(serial, device) {
+  var data = {
+    present: false
+  , presenceChangedAt: r.now()
+  , provider: device.provider
+  , owner: null
+  , status: device.status
+  , statusChangedAt: r.now()
+  , ready: false
+  , reverseForwards: []
+  }
+  return db.run(r.table('devices').get(serial).update(data))
+    .then(function(stats) {
+      if (stats.skipped) {
+        data.serial = serial
+        data.createdAt = r.now()
+        return db.run(r.table('devices').insert(data))
+      }
+      return stats
+    })
+}
+
+dbapi.saveDeviceStatus = function(serial, status) {
+  return db.run(r.table('devices').get(serial).update({
+    status: status
+  , statusChangedAt: r.now()
+  }))
+}
+
+dbapi.setDeviceOwner = function(serial, owner) {
+  return db.run(r.table('devices').get(serial).update({
+    owner: owner
+  }))
+}
+
+dbapi.unsetDeviceOwner = function(serial) {
+  return db.run(r.table('devices').get(serial).update({
+    owner: null
+  }))
+}
+
+dbapi.setDevicePresent = function(serial) {
+  return db.run(r.table('devices').get(serial).update({
+    present: true
+  , presenceChangedAt: r.now()
+  }))
+}
+
+dbapi.setDeviceAbsent = function(serial) {
+  return db.run(r.table('devices').get(serial).update({
+    present: false
+  , presenceChangedAt: r.now()
+  }))
+}
+
+dbapi.setDeviceAirplaneMode = function(serial, enabled) {
+  return db.run(r.table('devices').get(serial).update({
+    airplaneMode: enabled
+  }))
+}
+
+dbapi.setDeviceBattery = function(serial, battery) {
+  return db.run(r.table('devices').get(serial).update({
+      battery: {
+        status: battery.status
+      , health: battery.health
+      , source: battery.source
+      , level: battery.level
+      , scale: battery.scale
+      , temp: battery.temp
+      , voltage: battery.voltage
+      }
+    }
+  , {
+      durability: 'soft'
+    }))
+}
+
+dbapi.setDeviceBrowser = function(serial, browser) {
+  return db.run(r.table('devices').get(serial).update({
+    browser: {
+      selected: browser.selected
+    , apps: browser.apps
+    }
+  }))
+}
+
+dbapi.setDeviceConnectivity = function(serial, connectivity) {
+  return db.run(r.table('devices').get(serial).update({
+    network: {
+      connected: connectivity.connected
+    , type: connectivity.type
+    , subtype: connectivity.subtype
+    , failover: !!connectivity.failover
+    , roaming: !!connectivity.roaming
+    }
+  }))
+}
+
+dbapi.setDevicePhoneState = function(serial, state) {
+  return db.run(r.table('devices').get(serial).update({
+    network: {
+      state: state.state
+    , manual: state.manual
+    , operator: state.operator
+    }
+  }))
+}
+
+dbapi.setDeviceRotation = function(serial, rotation) {
+  return db.run(r.table('devices').get(serial).update({
+    display: {
+      rotation: rotation
+    }
+  }))
+}
+
+dbapi.setDeviceNote = function(serial, note) {
+  return db.run(r.table('devices').get(serial).update({
+    notes: note
+  }))
+}
+
+dbapi.setDeviceReverseForwards = function(serial, forwards) {
+  return db.run(r.table('devices').get(serial).update({
+    reverseForwards: forwards
+  }))
+}
+
+dbapi.setDeviceReady = function(serial, channel) {
+  return db.run(r.table('devices').get(serial).update({
+    channel: channel
+  , ready: true
+  , owner: null
+  , reverseForwards: []
+  }))
+}
+
+dbapi.saveDeviceIdentity = function(serial, identity) {
+  return db.run(r.table('devices').get(serial).update({
+    platform: identity.platform
+  , manufacturer: identity.manufacturer
+  , operator: identity.operator
+  , model: identity.model
+  , version: identity.version
+  , abi: identity.abi
+  , sdk: identity.sdk
+  , display: identity.display
+  , phone: identity.phone
+  , product: identity.product
+  }))
+}
+
+dbapi.loadDevices = function() {
+  return db.run(r.table('devices'))
+}
+
+dbapi.loadPresentDevices = function() {
+  return db.run(r.table('devices').getAll(true, {
+    index: 'present'
+  }))
+}
+
+dbapi.loadDevice = function(serial) {
+  return db.run(r.table('devices').get(serial))
+}
+
+dbapi.saveUserAccessToken = function(email, token) {
+  return db.run(r.table('accessTokens').insert({
+    email: email
+  , id: token.id
+  , title: token.title
+  , jwt: token.jwt
+  }))
+}
+
+dbapi.removeUserAccessToken = function(email, title) {
+  return db.run(r.table('accessTokens').getAll(email, {
+    index: 'email'
+  }).filter({title: title}).delete())
+}
+
+dbapi.loadAccessTokens = function(email) {
+  return db.run(r.table('accessTokens').getAll(email, {
+    index: 'email'
+  }))
+}
+
+module.exports = dbapi
diff --git a/crowdstf/lib/db/index.js b/crowdstf/lib/db/index.js
new file mode 100644
index 0000000..a8986e8
--- /dev/null
+++ b/crowdstf/lib/db/index.js
@@ -0,0 +1,119 @@
+var r = require('rethinkdb')
+var Promise = require('bluebird')
+
+var setup = require('./setup')
+var logger = require('../util/logger')
+var lifecycle = require('../util/lifecycle')
+var srv = require('../util/srv')
+
+var db = module.exports = Object.create(null)
+var log = logger.createLogger('db')
+
+function connect() {
+  var options = {
+    // These environment variables are exposed when we --link to a
+    // RethinkDB container.
+    url: process.env.RETHINKDB_PORT_28015_TCP || 'tcp://127.0.0.1:28015'
+  , db: process.env.RETHINKDB_ENV_DATABASE || 'stf'
+  , authKey: process.env.RETHINKDB_ENV_AUTHKEY
+  }
+
+  return srv.resolve(options.url)
+    .then(function(records) {
+      function next() {
+        var record = records.shift()
+
+        if (!record) {
+          throw new Error('No hosts left to try')
+        }
+
+        log.info('Connecting to %s:%d', record.name, record.port)
+
+        return r.connect({
+            host: record.name
+          , port: record.port
+          , db: options.db
+          , authKey: options.authKey
+          })
+          .catch(r.Error.RqlDriverError, function() {
+            log.info('Unable to connect to %s:%d', record.name, record.port)
+            return next()
+          })
+      }
+
+      return next()
+    })
+}
+
+// Export connection as a Promise
+db.connect = (function() {
+  var connection
+  var queue = []
+
+  lifecycle.observe(function() {
+    if (connection) {
+      return connection.close()
+    }
+  })
+
+  function createConnection() {
+    return connect()
+      .then(function(conn) {
+        connection = conn
+
+        conn.on('close', function closeListener() {
+          log.warn('Connection closed')
+          connection = null
+          conn.removeListener('close', closeListener)
+          createConnection()
+        })
+
+        queue.splice(0).forEach(function(resolver) {
+          resolver.resolve(conn)
+        })
+
+        return conn
+      })
+      .catch(function(err) {
+        log.fatal(err.message)
+        lifecycle.fatal()
+      })
+  }
+
+  createConnection()
+
+  return function() {
+    return new Promise(function(resolve, reject) {
+      if (connection) {
+        resolve(connection)
+      }
+      else {
+        queue.push({
+          resolve: resolve
+        , reject: reject
+        })
+      }
+    })
+  }
+})()
+
+// Close connection, we don't really care if it hasn't been created yet or not
+db.close = function() {
+  return db.connect().then(function(conn) {
+    return conn.close()
+  })
+}
+
+// Small utility for running queries without having to acquire a connection
+db.run = function(q, options) {
+  return db.connect().then(function(conn) {
+    return q.run(conn, options)
+  })
+}
+
+// Sets up the database
+db.setup = function() {
+  return db.connect().then(function(conn) {
+    return setup(conn)
+  })
+}
diff --git a/crowdstf/lib/db/setup.js b/crowdstf/lib/db/setup.js
new file mode 100644
index 0000000..aee1c7d
--- /dev/null
+++ b/crowdstf/lib/db/setup.js
@@ -0,0 +1,98 @@
+var r = require('rethinkdb')
+var Promise = require('bluebird')
+
+var logger = require('../util/logger')
+var tables = require('./tables')
+
+module.exports = function(conn) {
+  var log = logger.createLogger('db:setup')
+
+  function alreadyExistsError(err) {
+    return err.msg && err.msg.indexOf('already exists') !== -1
+  }
+
+  function noMasterAvailableError(err) {
+    return err.msg && err.msg.indexOf('No master available') !== -1
+  }
+
+  function createDatabase() {
+    return r.dbCreate(conn.db).run(conn)
+      .then(function() {
+        log.info('Database "%s" created', conn.db)
+      })
+      .catch(alreadyExistsError, function() {
+        log.info('Database "%s" already exists', conn.db)
+        return Promise.resolve()
+      })
+  }
+
+  function createIndex(table, index, options) {
+    var args = [index]
+    var rTable = r.table(table)
+
+    if (options) {
+      if (options.indexFunction) {
+        args.push(options.indexFunction)
+      }
+      if (options.options) {
+        args.push(options.options)
+      }
+    }
+
+    return rTable.indexCreate.apply(rTable, args).run(conn)
+      .then(function() {
+        log.info('Index "%s"."%s" created', table, index)
+      })
+      .catch(alreadyExistsError, function() {
+        log.info('Index "%s"."%s" already exists', table, index)
+        return Promise.resolve()
+      })
+      .then(function() {
+        log.info('Waiting for index "%s"."%s"', table, index)
+        return r.table(table).indexWait(index).run(conn)
+      })
+      .then(function() {
+        log.info('Index "%s"."%s" is ready', table, index)
+        return Promise.resolve()
+      })
+      .catch(noMasterAvailableError, function() {
+        return Promise.delay(1000).then(function() {
+          return createIndex(table, index, options)
+        })
+      })
+  }
+
+  function createTable(table, options) {
+    var tableOptions = {
+      primaryKey: options.primaryKey
+    }
+    return r.tableCreate(table, tableOptions).run(conn)
+      .then(function() {
+        log.info('Table "%s" created', table)
+      })
+      .catch(alreadyExistsError, function() {
+        log.info('Table "%s" already exists', table)
+        return Promise.resolve()
+      })
+      .catch(noMasterAvailableError, function() {
+        return Promise.delay(1000).then(function() {
+          return createTable(table, options)
+        })
+      })
+      .then(function() {
+        if (options.indexes) {
+          return Promise.all(Object.keys(options.indexes).map(function(index) {
+            return createIndex(table, index, options.indexes[index])
+          }))
+        }
+      })
+  }
+
+  return createDatabase()
+    .then(function() {
+      return Promise.all(Object.keys(tables).map(function(table) {
+        return createTable(table, tables[table])
+      }))
+    })
+    .return(conn)
+}
diff --git a/crowdstf/lib/db/tables.js b/crowdstf/lib/db/tables.js
new file mode 100644
index 0000000..231f597
--- /dev/null
+++ b/crowdstf/lib/db/tables.js
@@ -0,0 +1,57 @@
+var r = require('rethinkdb')
+
+module.exports = {
+  users: {
+    primaryKey: 'email'
+  , indexes: {
+      adbKeys: {
+        indexFunction: function(user) {
+          return user('adbKeys')('fingerprint')
+        }
+      , options: {
+          multi: true
+        }
+      }
+    }
+  }
+, accessTokens: {
+    primaryKey: 'id'
+  , indexes: {
+      email: null
+    }
+  }
+, vncauth: {
+    primaryKey: 'password'
+  , indexes: {
+      response: null
+    , responsePerDevice: {
+        indexFunction: function(row) {
+          return [row('response'), row('deviceId')]
+        }
+      }
+    }
+  }
+, devices: {
+    primaryKey: 'serial'
+  , indexes: {
+      owner: {
+        indexFunction: function(device) {
+          return r.branch(
+            device('present')
+          , device('owner')('email')
+          , r.literal()
+          )
+        }
+      }
+    , present: null
+    , providerChannel: {
+        indexFunction: function(device) {
+          return device('provider')('channel')
+        }
+      }
+    }
+  }
+, logs: {
+    primaryKey: 'id'
+  }
+}
diff --git a/crowdstf/lib/units/app/index.js b/crowdstf/lib/units/app/index.js
new file mode 100644
index 0000000..e355bd9
--- /dev/null
+++ b/crowdstf/lib/units/app/index.js
@@ -0,0 +1,228 @@
+var http = require('http')
+var url = require('url')
+var fs = require('fs')
+
+var express = require('express')
+var validator = require('express-validator')
+var cookieSession = require('cookie-session')
+var bodyParser = require('body-parser')
+var serveFavicon = require('serve-favicon')
+var serveStatic = require('serve-static')
+var csrf = require('csurf')
+var Promise = require('bluebird')
+var compression = require('compression')
+
+var logger = require('../../util/logger')
+var pathutil = require('../../util/pathutil')
+var dbapi = require('../../db/api')
+var datautil = require('../../util/datautil')
+
+var auth = require('./middleware/auth')
+var deviceIconMiddleware = require('./middleware/device-icons')
+var browserIconMiddleware = require('./middleware/browser-icons')
+var appstoreIconMiddleware = require('./middleware/appstore-icons')
+
+var markdownServe = require('markdown-serve')
+
+module.exports = function(options) {
+  var log = logger.createLogger('app')
+  var app = express()
+  var server = http.createServer(app)
+
+  app.use('/static/wiki', markdownServe.middleware({
+    rootDirectory: pathutil.root('node_modules/stf-wiki')
+  , view: 'docs'
+  }))
+
+  app.set('view engine', 'jade')
+  app.set('views', pathutil.resource('app/views'))
+  app.set('strict routing', true)
+  app.set('case sensitive routing', true)
+  app.set('trust proxy', true)
+
+  if (fs.existsSync(pathutil.resource('build'))) {
+    log.info('Using pre-built resources')
+    app.use(compression())
+    app.use('/static/app/build/entry',
+      serveStatic(pathutil.resource('build/entry')))
+    app.use('/static/app/build', serveStatic(pathutil.resource('build'), {
+      maxAge: '10d'
+    }))
+  }
+  else {
+    log.info('Using webpack')
+    // Keep webpack-related requires here, as our prebuilt package won't
+    // have them at all.
+    var webpackServerConfig = require('./../../../webpack.config').webpackServer
+    app.use('/static/app/build',
+      require('./middleware/webpack')(webpackServerConfig))
+  }
+
+  app.use('/static/bower_components',
+    serveStatic(pathutil.resource('bower_components')))
+  app.use('/static/app/data', serveStatic(pathutil.resource('data')))
+  app.use('/static/app/status', serveStatic(pathutil.resource('common/status')))
+  app.use('/static/app/browsers', browserIconMiddleware())
+  app.use('/static/app/appstores', appstoreIconMiddleware())
+  app.use('/static/app/devices', deviceIconMiddleware())
+  app.use('/static/app', serveStatic(pathutil.resource('app')))
+
+  app.use('/static/logo',
+    serveStatic(pathutil.resource('common/logo')))
+  app.use(serveFavicon(pathutil.resource(
+    'common/logo/exports/STF-128.png')))
+
+  app.use(cookieSession({
+    name: options.ssid
+  , keys: [options.secret]
+  }))
+
+  app.use(auth({
+    secret: options.secret
+  , authUrl: options.authUrl
+  }))
+
+  // This needs to be before the csrf() middleware or we'll get nasty
+  // errors in the logs. The dummy endpoint is a hack used to enable
+  // autocomplete on some text fields.
+  app.all('/app/api/v1/dummy', function(req, res) {
+    res.send('OK')
+  })
+
+  app.use(bodyParser.json())
+  app.use(csrf())
+  app.use(validator())
+
+  app.use(function(req, res, next) {
+    res.cookie('XSRF-TOKEN', req.csrfToken())
+    next()
+  })
+
+  app.get('/', function(req, res) {
+    res.render('index')
+  })
+
+  app.get('/app/api/v1/state.js', function(req, res) {
+    var state = {
+      config: {
+        websocketUrl: (function() {
+          var wsUrl = url.parse(options.websocketUrl, true)
+          wsUrl.query.uip = req.ip
+          return url.format(wsUrl)
+        })()
+      }
+    , user: req.user
+    }
+
+    if (options.userProfileUrl) {
+      state.config.userProfileUrl = (function() {
+        return options.userProfileUrl
+      })()
+    }
+
+    res.type('application/javascript')
+    res.send('var GLOBAL_APPSTATE = ' + JSON.stringify(state))
+  })
+
+  app.get('/app/api/v1/user', function(req, res) {
+    res.json({
+      success: true
+    , user: req.user
+    })
+  })
+
+  app.get('/app/api/v1/group', function(req, res) {
+    dbapi.loadGroup(req.user.email)
+      .then(function(cursor) {
+        return Promise.promisify(cursor.toArray, cursor)()
+          .then(function(list) {
+            list.forEach(function(device) {
+              datautil.normalize(device, req.user)
+            })
+            res.json({
+              success: true
+            , devices: list
+            })
+          })
+      })
+      .catch(function(err) {
+        log.error('Failed to load group: ', err.stack)
+        res.json(500, {
+          success: false
+        })
+      })
+  })
+
+  app.get('/app/api/v1/devices', function(req, res) {
+    dbapi.loadDevices()
+      .then(function(cursor) {
+        return Promise.promisify(cursor.toArray, cursor)()
+          .then(function(list) {
+            list.forEach(function(device) {
+              datautil.normalize(device, req.user)
+            })
+
+            res.json({
+              success: true
+            , devices: list
+            })
+          })
+      })
+      .catch(function(err) {
+        log.error('Failed to load device list: ', err.stack)
+        res.json(500, {
+          success: false
+        })
+      })
+  })
+
+  app.get('/app/api/v1/devices/:serial', function(req, res) {
+    dbapi.loadDevice(req.params.serial)
+      .then(function(device) {
+        if (device) {
+          datautil.normalize(device, req.user)
+          res.json({
+            success: true
+          , device: device
+          })
+        }
+        else {
+          res.json(404, {
+            success: false
+          })
+        }
+      })
+      .catch(function(err) {
+        log.error('Failed to load device "%s": ', req.params.serial, err.stack)
+        res.json(500, {
+          success: false
+        })
+      })
+  })
+
+  app.get('/app/api/v1/accessTokens', function(req, res) {
+    dbapi.loadAccessTokens(req.user.email)
+      .then(function(cursor) {
+        return Promise.promisify(cursor.toArray, cursor)()
+          .then(function(list) {
+            var titles = []
+            list.forEach(function(token) {
+              titles.push(token.title)
+            })
+            res.json({
+              success: true
+            , titles: titles
+            })
+          })
+      })
+      .catch(function(err) {
+        log.error('Failed to load tokens: ', err.stack)
+        res.json(500, {
+          success: false
+        })
+      })
+  })
+
+  server.listen(options.port)
+  log.info('Listening on port %d', options.port)
+}
diff --git a/crowdstf/lib/units/app/middleware/appstore-icons.js b/crowdstf/lib/units/app/middleware/appstore-icons.js
new file mode 100644
index 0000000..e23783a
--- /dev/null
+++ b/crowdstf/lib/units/app/middleware/appstore-icons.js
@@ -0,0 +1,12 @@
+var serveStatic = require('serve-static')
+
+var pathutil = require('../../../util/pathutil')
+
+module.exports = function() {
+  return serveStatic(
+    pathutil.root('node_modules/stf-appstore-db/dist')
+    , {
+      maxAge: '30d'
+    }
+  )
+}
diff --git a/crowdstf/lib/units/app/middleware/auth.js b/crowdstf/lib/units/app/middleware/auth.js
new file mode 100644
index 0000000..e960f4f
--- /dev/null
+++ b/crowdstf/lib/units/app/middleware/auth.js
@@ -0,0 +1,50 @@
+var jwtutil = require('../../../util/jwtutil')
+var urlutil = require('../../../util/urlutil')
+
+var dbapi = require('../../../db/api')
+
+module.exports = function(options) {
+  return function(req, res, next) {
+    if (req.query.jwt) {
+      // Coming from auth client
+      var data = jwtutil.decode(req.query.jwt, options.secret)
+      var redir = urlutil.removeParam(req.url, 'jwt')
+      if (data) {
+        // Redirect once to get rid of the token
+        dbapi.saveUserAfterLogin({
+            name: data.name
+          , email: data.email
+          , ip: req.ip
+          })
+          .then(function() {
+            req.session.jwt = data
+            res.redirect(redir)
+          })
+          .catch(next)
+      }
+      else {
+        // Invalid token, forward to auth client
+        res.redirect(options.authUrl)
+      }
+    }
+    else if (req.session && req.session.jwt) {
+      dbapi.loadUser(req.session.jwt.email)
+        .then(function(user) {
+          if (user) {
+            // Continue existing session
+            req.user = user
+            next()
+          }
+          else {
+            // We no longer have the user in the database
+            res.redirect(options.authUrl)
+          }
+        })
+        .catch(next)
+    }
+    else {
+      // No session, forward to auth client
+      res.redirect(options.authUrl)
+    }
+  }
+}
diff --git a/crowdstf/lib/units/app/middleware/browser-icons.js b/crowdstf/lib/units/app/middleware/browser-icons.js
new file mode 100644
index 0000000..4a4808b
--- /dev/null
+++ b/crowdstf/lib/units/app/middleware/browser-icons.js
@@ -0,0 +1,12 @@
+var serveStatic = require('serve-static')
+
+var pathutil = require('../../../util/pathutil')
+
+module.exports = function() {
+  return serveStatic(
+    pathutil.root('node_modules/stf-browser-db/dist')
+  , {
+      maxAge: '30d'
+    }
+  )
+}
diff --git a/crowdstf/lib/units/app/middleware/device-icons.js b/crowdstf/lib/units/app/middleware/device-icons.js
new file mode 100644
index 0000000..e9b81e2
--- /dev/null
+++ b/crowdstf/lib/units/app/middleware/device-icons.js
@@ -0,0 +1,12 @@
+var serveStatic = require('serve-static')
+
+var pathutil = require('../../../util/pathutil')
+
+module.exports = function() {
+  return serveStatic(
+    pathutil.root('node_modules/stf-device-db/dist')
+  , {
+      maxAge: '30d'
+    }
+  )
+}
diff --git a/crowdstf/lib/units/app/middleware/webpack.js b/crowdstf/lib/units/app/middleware/webpack.js
new file mode 100644
index 0000000..e8d4a68
--- /dev/null
+++ b/crowdstf/lib/units/app/middleware/webpack.js
@@ -0,0 +1,108 @@
+var path = require('path')
+var url = require('url')
+
+var webpack = require('webpack')
+var mime = require('mime')
+var Promise = require('bluebird')
+var _ = require('lodash')
+var MemoryFileSystem = require('memory-fs')
+
+var logger = require('../../../util/logger')
+var lifecycle = require('../../../util/lifecycle')
+var globalOptions = require('../../../../webpack.config').webpack
+
+// Similar to webpack-dev-middleware, but integrates with our custom
+// lifecycle, behaves more like normal express middleware, and removes
+// all unnecessary features.
+module.exports = function(localOptions) {
+  var log = logger.createLogger('middleware:webpack')
+  var options = _.defaults(localOptions || {}, globalOptions)
+
+  var compiler = webpack(options)
+  var fs = compiler.outputFileSystem = new MemoryFileSystem()
+
+  var valid = false
+  var queue = []
+
+  log.info('Creating bundle')
+  var watching = compiler.watch(options.watchDelay, function(err) {
+    if (err) {
+      log.fatal('Webpack had an error', err.stack)
+      lifecycle.fatal()
+    }
+  })
+
+  lifecycle.observe(function() {
+    if (watching.watcher) {
+      watching.watcher.close()
+    }
+  })
+
+  function doneListener(stats) {
+    process.nextTick(function() {
+      if (valid) {
+        log.info(stats.toString(options.stats))
+
+        if (stats.hasErrors()) {
+          log.error('Bundle has errors')
+        }
+        else if (stats.hasWarnings()) {
+          log.warn('Bundle has warnings')
+        }
+        else {
+          log.info('Bundle is now valid')
+        }
+
+        queue.forEach(function(resolver) {
+          resolver.resolve()
+        })
+      }
+    })
+
+    valid = true
+  }
+
+  function invalidate() {
+    if (valid) {
+      log.info('Bundle is now invalid')
+      valid = false
+    }
+  }
+
+  compiler.plugin('done', doneListener)
+  compiler.plugin('invalid', invalidate)
+  compiler.plugin('compile', invalidate)
+
+  function bundle() {
+    if (valid) {
+      return Promise.resolve()
+    }
+
+    log.info('Waiting for bundle to finish')
+    var resolver = Promise.defer()
+    queue.push(resolver)
+    return resolver.promise
+  }
+
+  return function(req, res, next) {
+    var parsedUrl = url.parse(req.url)
+
+    var target = path.join(
+      compiler.outputPath
+    , parsedUrl.pathname
+    )
+
+    bundle()
+      .then(function() {
+        try {
+          var body = fs.readFileSync(target)
+          res.set('Content-Type', mime.lookup(target))
+          res.end(body)
+        }
+        catch (err) {
+          return next()
+        }
+      })
+      .catch(next)
+  }
+}
diff --git a/crowdstf/lib/units/auth/ldap.js b/crowdstf/lib/units/auth/ldap.js
new file mode 100644
index 0000000..3d36f4a
--- /dev/null
+++ b/crowdstf/lib/units/auth/ldap.js
@@ -0,0 +1,130 @@
+var http = require('http')
+
+var express = require('express')
+var validator = require('express-validator')
+var cookieSession = require('cookie-session')
+var bodyParser = require('body-parser')
+var serveStatic = require('serve-static')
+var csrf = require('csurf')
+var Promise = require('bluebird')
+
+var logger = require('../../util/logger')
+var requtil = require('../../util/requtil')
+var ldaputil = require('../../util/ldaputil')
+var jwtutil = require('../../util/jwtutil')
+var pathutil = require('../../util/pathutil')
+var urlutil = require('../../util/urlutil')
+var lifecycle = require('../../util/lifecycle')
+
+module.exports = function(options) {
+  var log = logger.createLogger('auth-ldap')
+  var app = express()
+  var server = Promise.promisifyAll(http.createServer(app))
+
+  lifecycle.observe(function() {
+    log.info('Waiting for client connections to end')
+    return server.closeAsync()
+      .catch(function() {
+        // Okay
+      })
+  })
+
+  app.set('view engine', 'jade')
+  app.set('views', pathutil.resource('auth/ldap/views'))
+  app.set('strict routing', true)
+  app.set('case sensitive routing', true)
+
+  app.use(cookieSession({
+    name: options.ssid
+  , keys: [options.secret]
+  }))
+  app.use(bodyParser.json())
+  app.use(csrf())
+  app.use(validator())
+  app.use('/static/bower_components',
+    serveStatic(pathutil.resource('bower_components')))
+  app.use('/static/auth/ldap', serveStatic(pathutil.resource('auth/ldap')))
+
+  app.use(function(req, res, next) {
+    res.cookie('XSRF-TOKEN', req.csrfToken())
+    next()
+  })
+
+  app.get('/', function(req, res) {
+    res.redirect('/auth/ldap/')
+  })
+
+  app.get('/auth/ldap/', function(req, res) {
+    res.render('index')
+  })
+
+  app.post('/auth/api/v1/ldap', function(req, res) {
+    var log = logger.createLogger('auth-ldap')
+    log.setLocalIdentifier(req.ip)
+    switch (req.accepts(['json'])) {
+      case 'json':
+        requtil.validate(req, function() {
+            req.checkBody('username').notEmpty()
+            req.checkBody('password').notEmpty()
+          })
+          .then(function() {
+            return ldaputil.login(
+              options.ldap
+            , req.body.username
+            , req.body.password
+            )
+          })
+          .then(function(user) {
+            log.info('Authenticated "%s"', ldaputil.email(user))
+            var token = jwtutil.encode({
+              payload: {
+                email: ldaputil.email(user)
+              , name: user.cn
+              }
+            , secret: options.secret
+            , header: {
+                exp: Date.now() + 24 * 3600
+              }
+            })
+            res.status(200)
+              .json({
+                success: true
+              , redirect: urlutil.addParams(options.appUrl, {
+                  jwt: token
+                })
+              })
+          })
+          .catch(requtil.ValidationError, function(err) {
+            res.status(400)
+              .json({
+                success: false
+              , error: 'ValidationError'
+              , validationErrors: err.errors
+              })
+          })
+          .catch(ldaputil.InvalidCredentialsError, function(err) {
+            log.warn('Authentication failure for "%s"', err.user)
+            res.status(400)
+              .json({
+                success: false
+              , error: 'InvalidCredentialsError'
+              })
+          })
+          .catch(function(err) {
+            log.error('Unexpected error', err.stack)
+            res.status(500)
+              .json({
+                success: false
+              , error: 'ServerError'
+              })
+          })
+        break
+      default:
+        res.send(406)
+        break
+    }
+  })
+
+  server.listen(options.port)
+  log.info('Listening on port %d', options.port)
+}
diff --git a/crowdstf/lib/units/auth/mock.js b/crowdstf/lib/units/auth/mock.js
new file mode 100644
index 0000000..21ff14b
--- /dev/null
+++ b/crowdstf/lib/units/auth/mock.js
@@ -0,0 +1,141 @@
+var http = require('http')
+
+var express = require('express')
+var validator = require('express-validator')
+var cookieSession = require('cookie-session')
+var bodyParser = require('body-parser')
+var serveStatic = require('serve-static')
+var csrf = require('csurf')
+var Promise = require('bluebird')
+var basicAuth = require('basic-auth')
+
+var logger = require('../../util/logger')
+var requtil = require('../../util/requtil')
+var jwtutil = require('../../util/jwtutil')
+var pathutil = require('../../util/pathutil')
+var urlutil = require('../../util/urlutil')
+var lifecycle = require('../../util/lifecycle')
+
+module.exports = function(options) {
+  var log = logger.createLogger('auth-mock')
+  var app = express()
+  var server = Promise.promisifyAll(http.createServer(app))
+
+  lifecycle.observe(function() {
+    log.info('Waiting for client connections to end')
+    return server.closeAsync()
+      .catch(function() {
+        // Okay
+      })
+  })
+
+  // BasicAuth Middleware
+  var basicAuthMiddleware = function(req, res, next) {
+    function unauthorized(res) {
+      res.set('WWW-Authenticate', 'Basic realm=Authorization Required')
+      return res.send(401)
+    }
+
+    var user = basicAuth(req)
+
+    if (!user || !user.name || !user.pass) {
+      return unauthorized(res)
+    }
+
+    if (user.name === options.mock.basicAuth.username &&
+        user.pass === options.mock.basicAuth.password) {
+      return next()
+    }
+    else {
+      return unauthorized(res)
+    }
+  }
+
+  app.set('view engine', 'jade')
+  app.set('views', pathutil.resource('auth/mock/views'))
+  app.set('strict routing', true)
+  app.set('case sensitive routing', true)
+
+  app.use(cookieSession({
+    name: options.ssid
+  , keys: [options.secret]
+  }))
+  app.use(bodyParser.json())
+  app.use(csrf())
+  app.use(validator())
+  app.use('/static/bower_components',
+    serveStatic(pathutil.resource('bower_components')))
+  app.use('/static/auth/mock', serveStatic(pathutil.resource('auth/mock')))
+
+  app.use(function(req, res, next) {
+    res.cookie('XSRF-TOKEN', req.csrfToken())
+    next()
+  })
+
+  if (options.mock.useBasicAuth) {
+    app.use(basicAuthMiddleware)
+  }
+
+  app.get('/', function(req, res) {
+    res.redirect('/auth/mock/')
+  })
+
+  app.get('/auth/mock/', function(req, res) {
+    res.render('index')
+  })
+
+  app.post('/auth/api/v1/mock', function(req, res) {
+    var log = logger.createLogger('auth-mock')
+    log.setLocalIdentifier(req.ip)
+    switch (req.accepts(['json'])) {
+      case 'json':
+        requtil.validate(req, function() {
+            req.checkBody('name').notEmpty()
+            req.checkBody('email').isEmail()
+          })
+          .then(function() {
+            log.info('Authenticated "%s"', req.body.email)
+            var token = jwtutil.encode({
+              payload: {
+                email: req.body.email
+              , name: req.body.name
+              }
+            , secret: options.secret
+            , header: {
+                exp: Date.now() + 24 * 3600
+              }
+            })
+            res.status(200)
+              .json({
+                success: true
+              , redirect: urlutil.addParams(options.appUrl, {
+                  jwt: token
+                })
+              })
+          })
+          .catch(requtil.ValidationError, function(err) {
+            res.status(400)
+              .json({
+                success: false
+              , error: 'ValidationError'
+              , validationErrors: err.errors
+              })
+          })
+          .catch(function(err) {
+            log.error('Unexpected error', err.stack)
+            res.status(500)
+              .json({
+                success: false
+              , error: 'ServerError'
+              })
+          })
+        break
+      default:
+        res.send(406)
+        break
+    }
+  })
+
+  server.listen(options.port)
+  log.info('Listening on port %d', options.port)
+}
diff --git a/crowdstf/lib/units/auth/oauth2/index.js b/crowdstf/lib/units/auth/oauth2/index.js
new file mode 100644
index 0000000..f8cd83c
--- /dev/null
+++ b/crowdstf/lib/units/auth/oauth2/index.js
@@ -0,0 +1,57 @@
+var http = require('http')
+
+var express = require('express')
+var passport = require('passport')
+
+var logger = require('../../../util/logger')
+var urlutil = require('../../../util/urlutil')
+var jwtutil = require('../../../util/jwtutil')
+var Strategy = require('./strategy')
+
+module.exports = function(options) {
+  var log = logger.createLogger('auth-oauth2')
+  var app = express()
+  var server = http.createServer(app)
+
+  app.set('strict routing', true)
+  app.set('case sensitive routing', true)
+
+  function verify(accessToken, refreshToken, profile, done) {
+    done(null, profile)
+  }
+
+  passport.use(new Strategy(options.oauth, verify))
+
+  app.use(passport.initialize())
+  app.use(passport.authenticate('oauth2', {
+    failureRedirect: '/auth/oauth/'
+  , session: false
+  }))
+
+  app.get(
+    '/auth/oauth/callback'
+  , function(req, res) {
+      if (req.user.email) {
+        res.redirect(urlutil.addParams(options.appUrl, {
+          jwt: jwtutil.encode({
+            payload: {
+              email: req.user.email
+            , name: req.user.email.split('@', 1).join('')
+            }
+          , secret: options.secret
+          , header: {
+              exp: Date.now() + 24 * 3600
+            }
+          })
+        }))
+      }
+      else {
+        log.warn('Missing email in profile', req.user)
+        res.redirect('/auth/oauth/')
+      }
+    }
+  )
+
+  server.listen(options.port)
+  log.info('Listening on port %d', options.port)
+}
diff --git a/crowdstf/lib/units/auth/oauth2/strategy.js b/crowdstf/lib/units/auth/oauth2/strategy.js
new file mode 100644
index 0000000..76a6f95
--- /dev/null
+++ b/crowdstf/lib/units/auth/oauth2/strategy.js
@@ -0,0 +1,31 @@
+var util = require('util')
+
+var oauth2 = require('passport-oauth2')
+
+function Strategy(options, verify) {
+  oauth2.Strategy.call(this, options, verify)
+  if (!options.authorizationURL) {
+    throw new TypeError('OAuth2Strategy requires a userinfoURL option')
+  }
+  this._userinfoURL = options.userinfoURL
+  this._oauth2.useAuthorizationHeaderforGET(true)
+}
+
+util.inherits(Strategy, oauth2.Strategy)
+
+Strategy.prototype.userProfile = function(accessToken, callback) {
+  this._oauth2.get(this._userinfoURL, accessToken, function(err, data) {
+    if (err) {
+      return callback(err)
+    }
+
+    try {
+      return callback(null, JSON.parse(data))
+    }
+    catch (err) {
+      return callback(err)
+    }
+  })
+}
+
+module.exports = Strategy
diff --git a/crowdstf/lib/units/auth/openid.js b/crowdstf/lib/units/auth/openid.js
new file mode 100644
index 0000000..88b799d
--- /dev/null
+++ b/crowdstf/lib/units/auth/openid.js
@@ -0,0 +1,75 @@
+var http = require('http')
+
+var openid = require('openid')
+var express = require('express')
+var urljoin = require('url-join')
+
+var logger = require('../../util/logger')
+var jwtutil = require('../../util/jwtutil')
+var urlutil = require('../../util/urlutil')
+
+module.exports = function(options) {
+  var extensions = [new openid.SimpleRegistration({
+    email: true
+  , fullname: true
+  })]
+
+  var relyingParty = new openid.RelyingParty(
+    urljoin(options.appUrl, '/auth/openid/verify')
+  , null  // Realm (optional, specifies realm for OpenID authentication)
+  , false // Use stateless verification
+  , false // Strict mode
+  , extensions)
+
+  var log = logger.createLogger('auth-openid')
+  var app = express()
+
+  app.set('strict routing', true)
+  app.set('case sensitive routing', true)
+
+  app.get('/', function(req, res) {
+    res.redirect('/auth/openid/')
+  })
+
+  app.get('/auth/openid/', function(req, res) {
+    log.info('openid identifier url: %s', options.openid.identifierUrl)
+    relyingParty.authenticate(options.openid.identifierUrl, false, function(err, authUrl) {
+      if (err) {
+        res.send('Authentication failed')
+      }
+      else if (!authUrl) {
+        res.send('Authentication failed')
+      }
+      else {
+        log.info('redirect to authUrl: %s', options.openid.identifierUrl)
+        res.redirect(authUrl)
+      }
+    })
+  })
+
+  app.get('/auth/openid/verify', function(req, res) {
+    log.setLocalIdentifier(req.ip)
+
+    relyingParty.verifyAssertion(req, function(err, result) {
+      log.info('openid verify assertion')
+      if (err || !result.authenticated) {
+        res.send('Authentication failed')
+        return
+      }
+      var email = req.query['openid.sreg.email']
+      var name = req.query['openid.sreg.fullname']
+      log.info('Authenticated "%s:%s"', name, email)
+      var token = jwtutil.encode({
+        payload: {
+          email: email
+        , name: name
+        }
+      , secret: options.secret
+      })
+      res.redirect(urlutil.addParams(options.appUrl, {jwt: token}))
+    })
+  })
+
+  http.createServer(app).listen(options.port)
+  log.info('Listening on port %d', options.port)
+}
diff --git a/crowdstf/lib/units/auth/saml2.js b/crowdstf/lib/units/auth/saml2.js
new file mode 100644
index 0000000..f3c273f
--- /dev/null
+++ b/crowdstf/lib/units/auth/saml2.js
@@ -0,0 +1,80 @@
+var fs = require('fs')
+var http = require('http')
+
+var express = require('express')
+var passport = require('passport')
+var SamlStrategy = require('passport-saml').Strategy
+var bodyParser = require('body-parser')
+var _ = require('lodash')
+
+var logger = require('../../util/logger')
+var urlutil = require('../../util/urlutil')
+var jwtutil = require('../../util/jwtutil')
+
+module.exports = function(options) {
+  var log = logger.createLogger('auth-saml2')
+  var app = express()
+  var server = http.createServer(app)
+
+  app.set('strict routing', true)
+  app.set('case sensitive routing', true)
+  app.use(bodyParser.urlencoded({extended: false}))
+  app.use(passport.initialize())
+
+  passport.serializeUser(function(user, done) {
+    done(null, user)
+  })
+  passport.deserializeUser(function(user, done) {
+    done(null, user)
+  })
+
+  var verify = function(profile, done) {
+    return done(null, profile)
+  }
+
+  var samlConfig = {
+    path: '/auth/saml/callback'
+  , entryPoint: options.saml.entryPoint
+  , issuer: options.saml.issuer
+  }
+
+  if (options.saml.certPath) {
+    samlConfig = _.merge(samlConfig, {
+      cert: fs.readFileSync(options.saml.certPath).toString()
+    })
+  }
+
+  passport.use(new SamlStrategy(samlConfig, verify))
+
+  app.use(passport.authenticate('saml', {
+    failureRedirect: '/auth/saml/'
+  , session: false
+  }))
+
+  app.post(
+    '/auth/saml/callback'
+  , function(req, res) {
+      if (req.user.email) {
+        res.redirect(urlutil.addParams(options.appUrl, {
+          jwt: jwtutil.encode({
+            payload: {
+              email: req.user.email
+            , name: req.user.email.split('@', 1).join('')
+            }
+          , secret: options.secret
+          , header: {
+              exp: Date.now() + 24 * 3600
+            }
+          })
+        }))
+      }
+      else {
+        log.warn('Missing email in profile', req.user)
+        res.redirect('/auth/saml/')
+      }
+    }
+  )
+
+  server.listen(options.port)
+  log.info('Listening on port %d', options.port)
+}
diff --git a/crowdstf/lib/units/device/index.js b/crowdstf/lib/units/device/index.js
new file mode 100644
index 0000000..08be946
--- /dev/null
+++ b/crowdstf/lib/units/device/index.js
@@ -0,0 +1,58 @@
+var syrup = require('stf-syrup')
+
+var logger = require('../../util/logger')
+var lifecycle = require('../../util/lifecycle')
+
+module.exports = function(options) {
+  // Show serial number in logs
+  logger.setGlobalIdentifier(options.serial)
+
+  var log = logger.createLogger('device')
+
+  return syrup.serial()
+    // We want to send logs before anything else starts happening
+    .dependency(require('./plugins/logger'))
+    .define(function(options) {
+      var log = logger.createLogger('device')
+      log.info('Preparing device')
+      return syrup.serial()
+        .dependency(require('./plugins/heartbeat'))
+        .dependency(require('./plugins/solo'))
+        .dependency(require('./plugins/screen/stream'))
+        .dependency(require('./plugins/screen/capture'))
+        .dependency(require('./plugins/vnc'))
+        .dependency(require('./plugins/service'))
+        .dependency(require('./plugins/browser'))
+        .dependency(require('./plugins/store'))
+        .dependency(require('./plugins/clipboard'))
+        .dependency(require('./plugins/logcat'))
+        .dependency(require('./plugins/mute'))
+        .dependency(require('./plugins/shell'))
+        .dependency(require('./plugins/touch'))
+        .dependency(require('./plugins/install'))
+        .dependency(require('./plugins/forward'))
+        .dependency(require('./plugins/group'))
+        .dependency(require('./plugins/cleanup'))
+        .dependency(require('./plugins/reboot'))
+        .dependency(require('./plugins/connect'))
+        .dependency(require('./plugins/account'))
+        .dependency(require('./plugins/ringer'))
+        .dependency(require('./plugins/wifi'))
+        .dependency(require('./plugins/sd'))
+        .dependency(require('./plugins/filesystem'))
+        .define(function(options, heartbeat, solo) {
+          if (process.send) {
+            // Only if we have a parent process
+            process.send('ready')
+          }
+          log.info('Fully operational')
+          return solo.poke()
+        })
+        .consume(options)
+    })
+    .consume(options)
+    .catch(function(err) {
+      log.fatal('Setup had an error', err.stack)
+      lifecycle.fatal()
+    })
+}
diff --git a/crowdstf/lib/units/device/plugins/account.js b/crowdstf/lib/units/device/plugins/account.js
new file mode 100644
index 0000000..025ad74
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/account.js
@@ -0,0 +1,324 @@
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+
+module.exports = syrup.serial()
+  .dependency(require('./service'))
+  .dependency(require('./util/identity'))
+  .dependency(require('./touch'))
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .dependency(require('../support/adb'))
+  .define(function(options, service, identity, touch, router, push, adb) {
+    var log = logger.createLogger('device:plugins:account')
+
+    function checkAccount(type, account) {
+      return service.getAccounts({type: type})
+        .timeout(30000)
+        .then(function(accounts) {
+          if (accounts.indexOf(account) >= 0) {
+            return true
+          }
+          throw new Error('The account is not added')
+        })
+    }
+
+    router.on(wire.AccountCheckMessage, function(channel, message) {
+      var reply = wireutil.reply(options.serial)
+
+      log.info('Checking if account "%s" is added', message.account)
+      checkAccount(message.type, message.account)
+        .then(function() {
+            push.send([
+              channel
+            , reply.okay()
+            ])
+        })
+        .catch(function(err) {
+          log.error('Account check failed', err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+
+    router.on(wire.AccountGetMessage, function(channel, message) {
+      var reply = wireutil.reply(options.serial)
+
+      log.info('Getting account(s)')
+
+      service.getAccounts(message)
+        .timeout(30000)
+        .then(function(accounts) {
+          push.send([
+            channel
+          , reply.okay('success', accounts)
+          ])
+        })
+        .catch(function(err) {
+          log.error('Account get failed', err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+
+    router.on(wire.AccountRemoveMessage, function(channel, message) {
+      var reply = wireutil.reply(options.serial)
+
+      log.info('Removing "%s" account(s)', message.type)
+
+      service.removeAccount(message)
+        .timeout(30000)
+        .then(function() {
+          push.send([
+            channel
+          , reply.okay()
+          ])
+        })
+        .catch(function(err) {
+          log.error('Account removal failed', err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+
+    router.on(wire.AccountAddMenuMessage, function(channel) {
+      var reply = wireutil.reply(options.serial)
+
+      log.info('Showing add account menu for Google Account')
+
+      service.addAccountMenu()
+        .timeout(30000)
+        .then(function() {
+          push.send([
+            channel
+          , reply.okay()
+          ])
+        })
+        .catch(function(err) {
+          log.error('Add account menu failed', err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+
+    router.on(wire.AccountAddMessage, function(channel, message) {
+      var reply = wireutil.reply(options.serial)
+      var type = 'com.google'
+      var account = message.user + '@gmail.com'
+
+      log.info('Adding Google Account automatedly')
+
+      var version = identity.version.substring(0, 3)
+
+      function automation() {
+        switch (version) {
+          case '2.3': // tested: 2.3.3-2.3.6
+            return service.pressKey('dpad_down').delay(1000)
+              .then(function() {
+                return service.pressKey('dpad_down')
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('enter')
+              }).delay(2000)
+              .then(function() {
+                return service.pressKey('dpad_down')
+              }).delay(2000)
+              .then(function() {
+                return service.pressKey('enter')
+              }).delay(2000)
+              .then(function() {
+                return service.type(message.user)
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('switch_charset')
+              }).delay(100)
+              .then(function() {
+                return service.pressKey('switch_charset')
+              }).delay(100)
+              .then(function() {
+                return service.pressKey('enter')
+              }).delay(1000)
+              .then(function() {
+                return service.type(message.password)
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('enter')
+              })
+          case '4.0': // tested: 4.0.3 and 4.0.4
+            return service.pressKey('tab').delay(1000)
+              .then(function() {
+                return service.pressKey('enter')
+              }).delay(2000)
+              .then(function() {
+                return service.type(message.user)
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('switch_charset')
+              }).delay(100)
+              .then(function() {
+                return service.pressKey('switch_charset')
+              }).delay(100)
+              .then(function() {
+                return service.pressKey('enter')
+              }).delay(1000)
+              .then(function() {
+                return service.type(message.password)
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('enter')
+              })
+          case '4.1': // tested: 4.1.1 and 4.1.2
+            return service.pressKey('tab').delay(1000)
+              .then(function() {
+                return service.pressKey('enter')
+              }).delay(2000)
+              .then(function() {
+                return service.type(message.user)
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('switch_charset')
+              }).delay(100)
+              .then(function() {
+                return service.pressKey('switch_charset')
+              }).delay(100)
+              .then(function() {
+                return service.pressKey('enter')
+              }).delay(1000)
+              .then(function() {
+                return service.type(message.password)
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('enter')
+              })
+          case '4.2': // tested: 4.2.2
+            return service.pressKey('tab').delay(1000)
+              .then(function() {
+                return service.pressKey('enter')
+              }).delay(2000)
+              .then(function() {
+                return service.type(message.user)
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('switch_charset')
+              }).delay(100)
+              .then(function() {
+                return service.pressKey('switch_charset')
+              }).delay(100)
+              .then(function() {
+                return service.pressKey('enter')
+              }).delay(1000)
+              .then(function() {
+                return service.type(message.password)
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('enter')
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('tab')
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('tab')
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('tab')
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('enter')
+              })
+          // case '4.3': // tested: 4.3
+          // case '4.4': // tested: 4.4.2
+          default:
+            return service.pressKey('tab').delay(1000)
+              .then(function() {
+                return service.pressKey('enter')
+              }).delay(2000)
+              .then(function() {
+                return service.type(message.user)
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('switch_charset')
+              }).delay(100)
+              .then(function() {
+                return service.pressKey('switch_charset')
+              }).delay(100)
+              .then(function() {
+                return service.pressKey('enter')
+              }).delay(1000)
+              .then(function() {
+                return service.type(message.password)
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('enter')
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('tab')
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('tab')
+              }).delay(1000)
+              .then(function() {
+                return service.pressKey('enter')
+              })
+        }
+      }
+
+      // First check if the account is already added so we don't continue
+      return checkAccount(type, account)
+        .then(function() {
+          push.send([
+            channel
+          , reply.fail('Add account failed: account was already added')
+          ])
+        })
+        .catch(function() {
+          return adb.clear(options.serial, 'com.google.android.gsf.login')
+            .catch(function() {
+              // The package name is different in 2.3, so let's try the old name
+              // if the new name fails.
+              return adb.clear(options.serial, 'com.google.android.gsf')
+            })
+            .then(function() {
+              return service.addAccountMenu()
+            })
+            .delay(5000)
+            .then(function() {
+              // Just in case the add account menu has any button focused
+              return touch.tap({x: 0, y: 0.9})
+            })
+            .delay(500)
+            .then(function() {
+              return automation()
+            })
+            .delay(3000)
+            .then(function() {
+              return service.pressKey('home')
+            })
+            .then(function() {
+              return checkAccount(type, account)
+            })
+            .then(function() {
+              push.send([
+                channel
+              , reply.okay()
+              ])
+            })
+            .catch(function(err) {
+              log.error('Add account failed', err.stack)
+              push.send([
+                channel
+              , reply.fail('Add account failed: ' + err.message)
+              ])
+            })
+        })
+    })
+  })
diff --git a/crowdstf/lib/units/device/plugins/browser.js b/crowdstf/lib/units/device/plugins/browser.js
new file mode 100644
index 0000000..afb397e
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/browser.js
@@ -0,0 +1,156 @@
+var syrup = require('stf-syrup')
+
+var browsers = require('stf-browser-db')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+
+var mapping = (function() {
+  var list = Object.create(null)
+  Object.keys(browsers).forEach(function(id) {
+    var browser = browsers[id]
+    if (browser.platforms.android) {
+      list[browser.platforms.android.package] = id
+    }
+  })
+  return list
+})()
+
+module.exports = syrup.serial()
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .dependency(require('../support/adb'))
+  .dependency(require('./service'))
+  .define(function(options, router, push, adb, service) {
+    var log = logger.createLogger('device:plugins:browser')
+
+    function pkg(component) {
+      return component.split('/', 1)[0]
+    }
+
+    function appReducer(acc, app) {
+      var packageName = pkg(app.component)
+      var browserId = mapping[packageName]
+
+      if (!browserId) {
+        log.warn('Unmapped browser "%s"', packageName)
+        return acc
+      }
+
+      acc.push({
+        id: app.component
+      , type: browserId
+      , name: browsers[browserId].name
+      , selected: app.selected
+      , system: app.system
+      })
+
+      return acc
+    }
+
+    function compareIgnoreCase(a, b) {
+      var la = (a || '').toLowerCase()
+      var lb = (b || '').toLowerCase()
+
+      if (la === lb) {
+        return 0
+      }
+      else if (la < lb) {
+        return -1
+      }
+      else {
+        return 1
+      }
+    }
+
+    function updateBrowsers(data) {
+      log.info('Updating browser list')
+      push.send([
+        wireutil.global
+      , wireutil.envelope(new wire.DeviceBrowserMessage(
+          options.serial
+        , data.selected
+        , data.apps.reduce(appReducer, []).sort(function(appA, appB) {
+            return compareIgnoreCase(appA.name, appB.name)
+          })
+        ))
+      ])
+    }
+
+    function loadBrowsers() {
+      log.info('Loading browser list')
+      return service.getBrowsers()
+        .then(updateBrowsers)
+    }
+
+    function ensureHttpProtocol(url) {
+      // Check for '://' because a protocol-less URL might include
+      // a username:password combination.
+      return (url.indexOf('://') === -1 ? 'http://' : '') + url
+    }
+
+    service.on('browserPackageChange', updateBrowsers)
+
+    router.on(wire.BrowserOpenMessage, function(channel, message) {
+      message.url = ensureHttpProtocol(message.url)
+
+      if (message.browser) {
+        log.info('Opening "%s" in "%s"', message.url, message.browser)
+      }
+      else {
+        log.info('Opening "%s"', message.url)
+      }
+
+      var reply = wireutil.reply(options.serial)
+      adb.startActivity(options.serial, {
+          action: 'android.intent.action.VIEW'
+        , component: message.browser
+        , data: message.url
+        })
+        .then(function() {
+          push.send([
+            channel
+          , reply.okay()
+          ])
+        })
+        .catch(function(err) {
+          if (message.browser) {
+            log.error(
+              'Failed to open "%s" in "%s"'
+            , message.url
+            , message.browser
+            , err.stack
+            )
+          }
+          else {
+            log.error('Failed to open "%s"', message.url, err.stack)
+          }
+          push.send([
+            channel
+          , reply.fail()
+          ])
+        })
+    })
+
+    router.on(wire.BrowserClearMessage, function(channel, message) {
+      log.info('Clearing "%s"', message.browser)
+      var reply = wireutil.reply(options.serial)
+      adb.clear(options.serial, pkg(message.browser))
+        .then(function() {
+          push.send([
+            channel
+          , reply.okay()
+          ])
+        })
+        .catch(function(err) {
+          log.error('Failed to clear "%s"', message.browser, err.stack)
+          push.send([
+            channel
+          , reply.fail()
+          ])
+        })
+    })
+
+    return loadBrowsers()
+  })
diff --git a/crowdstf/lib/units/device/plugins/cleanup.js b/crowdstf/lib/units/device/plugins/cleanup.js
new file mode 100644
index 0000000..00b4a33
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/cleanup.js
@@ -0,0 +1,45 @@
+var syrup = require('stf-syrup')
+var Promise = require('bluebird')
+var _ = require('lodash')
+
+var logger = require('../../../util/logger')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/adb'))
+  .dependency(require('../resources/service'))
+  .dependency(require('./group'))
+  .define(function(options, adb, service, group) {
+    var log = logger.createLogger('device:plugins:cleanup')
+    var plugin = Object.create(null)
+
+    function listPackages() {
+      return adb.getPackages(options.serial)
+    }
+
+    function uninstallPackage(pkg) {
+      log.info('Cleaning up package "%s"', pkg)
+      return adb.uninstall(options.serial, pkg)
+        .catch(function(err) {
+          log.warn('Unable to clean up package "%s"', pkg, err)
+          return true
+        })
+    }
+
+    return listPackages()
+      .then(function(initialPackages) {
+        initialPackages.push(service.pkg)
+
+        plugin.removePackages = function() {
+          return listPackages()
+            .then(function(currentPackages) {
+              var remove = _.difference(currentPackages, initialPackages)
+              return Promise.map(remove, uninstallPackage)
+            })
+        }
+
+        group.on('leave', function() {
+          plugin.removePackages()
+        })
+      })
+      .return(plugin)
+  })
diff --git a/crowdstf/lib/units/device/plugins/clipboard.js b/crowdstf/lib/units/device/plugins/clipboard.js
new file mode 100644
index 0000000..275a971
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/clipboard.js
@@ -0,0 +1,51 @@
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .dependency(require('./service'))
+  .define(function(options, router, push, service) {
+    var log = logger.createLogger('device:plugins:clipboard')
+
+    router.on(wire.PasteMessage, function(channel, message) {
+      log.info('Pasting "%s" to clipboard', message.text)
+      var reply = wireutil.reply(options.serial)
+      service.paste(message.text)
+        .then(function() {
+          push.send([
+            channel
+          , reply.okay()
+          ])
+        })
+        .catch(function(err) {
+          log.error('Paste failed', err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+
+    router.on(wire.CopyMessage, function(channel) {
+      log.info('Copying clipboard contents')
+      var reply = wireutil.reply(options.serial)
+      service.copy()
+        .then(function(content) {
+          push.send([
+            channel
+          , reply.okay(content)
+          ])
+        })
+        .catch(function(err) {
+          log.error('Copy failed', err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+  })
diff --git a/crowdstf/lib/units/device/plugins/connect.js b/crowdstf/lib/units/device/plugins/connect.js
new file mode 100644
index 0000000..d058b17
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/connect.js
@@ -0,0 +1,174 @@
+var util = require('util')
+
+var syrup = require('stf-syrup')
+var Promise = require('bluebird')
+
+var logger = require('../../../util/logger')
+var grouputil = require('../../../util/grouputil')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+var lifecycle = require('../../../util/lifecycle')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/adb'))
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .dependency(require('./group'))
+  .dependency(require('./solo'))
+  .dependency(require('./util/urlformat'))
+  .define(function(options, adb, router, push, group, solo, urlformat) {
+    var log = logger.createLogger('device:plugins:connect')
+    var plugin = Object.create(null)
+    var activeServer = null
+
+    plugin.port = options.connectPort
+    plugin.url = urlformat(options.connectUrlPattern, plugin.port)
+
+    plugin.start = function() {
+      return new Promise(function(resolve, reject) {
+        if (plugin.isRunning()) {
+          return resolve(plugin.url)
+        }
+
+        var server = adb.createTcpUsbBridge(options.serial, {
+          auth: function(key) {
+            var resolver = Promise.defer()
+
+            function notify() {
+              group.get()
+                .then(function(currentGroup) {
+                  push.send([
+                    solo.channel
+                  , wireutil.envelope(new wire.JoinGroupByAdbFingerprintMessage(
+                      options.serial
+                    , key.fingerprint
+                    , key.comment
+                    , currentGroup.group
+                    ))
+                  ])
+                })
+                .catch(grouputil.NoGroupError, function() {
+                  push.send([
+                    solo.channel
+                  , wireutil.envelope(new wire.JoinGroupByAdbFingerprintMessage(
+                      options.serial
+                    , key.fingerprint
+                    , key.comment
+                    ))
+                  ])
+                })
+            }
+
+            function joinListener(group, identifier) {
+              if (identifier !== key.fingerprint) {
+                resolver.reject(new Error('Somebody else took the device'))
+              }
+            }
+
+            function autojoinListener(identifier, joined) {
+              if (identifier === key.fingerprint) {
+                if (joined) {
+                  resolver.resolve()
+                }
+                else {
+                  resolver.reject(new Error('Device is already in use'))
+                }
+              }
+            }
+
+            group.on('join', joinListener)
+            group.on('autojoin', autojoinListener)
+            router.on(wire.AdbKeysUpdatedMessage, notify)
+
+            notify()
+
+            return resolver.promise
+              .timeout(120000)
+              .finally(function() {
+                group.removeListener('join', joinListener)
+                group.removeListener('autojoin', autojoinListener)
+                router.removeListener(wire.AdbKeysUpdatedMessage, notify)
+              })
+          }
+        })
+
+        server.on('listening', function() {
+          resolve(plugin.url)
+        })
+
+        server.on('connection', function(conn) {
+          log.info('New remote ADB connection from %s', conn.remoteAddress)
+          conn.on('userActivity', function() {
+            group.keepalive()
+          })
+        })
+
+        server.on('error', reject)
+
+        log.info(util.format('Listening on port %d', plugin.port))
+        server.listen(plugin.port)
+
+        activeServer = server
+        lifecycle.share('Remote ADB', activeServer)
+      })
+    }
+
+    plugin.stop = Promise.method(function() {
+      if (plugin.isRunning()) {
+        activeServer.close()
+        activeServer.end()
+      }
+    })
+
+    plugin.end = Promise.method(function() {
+      if (plugin.isRunning()) {
+        activeServer.end()
+      }
+    })
+
+    plugin.isRunning = function() {
+      return !!activeServer
+    }
+
+    lifecycle.observe(plugin.stop)
+    group.on('leave', plugin.end)
+
+    router
+      .on(wire.ConnectStartMessage, function(channel) {
+        var reply = wireutil.reply(options.serial)
+        plugin.start()
+          .then(function(url) {
+            push.send([
+              channel
+            , reply.okay(url)
+            ])
+          })
+          .catch(function(err) {
+            log.error('Unable to start remote connect service', err.stack)
+            push.send([
+              channel
+            , reply.fail(err.message)
+            ])
+          })
+      })
+      .on(wire.ConnectStopMessage, function(channel) {
+        var reply = wireutil.reply(options.serial)
+        plugin.end()
+          .then(function() {
+            push.send([
+              channel
+            , reply.okay()
+            ])
+          })
+          .catch(function(err) {
+            log.error('Failed to stop connect service', err.stack)
+            push.send([
+              channel
+            , reply.fail(err.message)
+            ])
+          })
+      })
+
+    return plugin.start()
+      .return(plugin)
+  })
diff --git a/crowdstf/lib/units/device/plugins/filesystem.js b/crowdstf/lib/units/device/plugins/filesystem.js
new file mode 100644
index 0000000..8825aa1
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/filesystem.js
@@ -0,0 +1,74 @@
+var syrup = require('stf-syrup')
+var path = require('path')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/adb'))
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .dependency(require('../support/storage'))
+  .define(function(options, adb, router, push, storage) {
+    var log = logger.createLogger('device:plugins:filesystem')
+    var plugin = Object.create(null)
+
+    plugin.retrieve = function(file) {
+      log.info('Retrieving file "%s"', file)
+
+      return adb.stat(options.serial, file)
+        .then(function(stats) {
+          return adb.pull(options.serial, file)
+            .then(function(transfer) {
+              // We may have add new storage plugins for various file types
+              // in the future, and add proper detection for the mimetype.
+              // But for now, let's just use application/octet-stream for
+              // everything like it's 2001.
+              return storage.store('blob', transfer, {
+                filename: path.basename(file)
+              , contentType: 'application/octet-stream'
+              , knownLength: stats.size
+              })
+            })
+        })
+    }
+
+    router.on(wire.FileSystemGetMessage, function(channel, message) {
+      var reply = wireutil.reply(options.serial)
+      plugin.retrieve(message.file)
+        .then(function(file) {
+          push.send([
+            channel
+          , reply.okay('success', file)
+          ])
+        })
+        .catch(function(err) {
+          log.warn('Unable to retrieve "%s"', message.file, err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+
+    router.on(wire.FileSystemListMessage, function(channel, message) {
+      var reply = wireutil.reply(options.serial)
+      adb.readdir(options.serial, message.dir)
+        .then(function(files) {
+          push.send([
+            channel
+          , reply.okay('success', files)
+          ])
+        })
+        .catch(function(err) {
+          log.warn('Unable to list directory "%s"', message.dir, err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+
+    return plugin
+  })
diff --git a/crowdstf/lib/units/device/plugins/forward/index.js b/crowdstf/lib/units/device/plugins/forward/index.js
new file mode 100644
index 0000000..4b48165
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/forward/index.js
@@ -0,0 +1,193 @@
+var net = require('net')
+
+var Promise = require('bluebird')
+var syrup = require('stf-syrup')
+var _ = require('lodash')
+
+var wire = require('../../../../wire')
+var logger = require('../../../../util/logger')
+var lifecycle = require('../../../../util/lifecycle')
+var streamutil = require('../../../../util/streamutil')
+var wireutil = require('../../../../wire/util')
+
+var ForwardManager = require('./util/manager')
+
+module.exports = syrup.serial()
+  .dependency(require('../../support/adb'))
+  .dependency(require('../../support/router'))
+  .dependency(require('../../support/push'))
+  .dependency(require('../../resources/minirev'))
+  .dependency(require('../group'))
+  .define(function(options, adb, router, push, minirev, group) {
+    var log = logger.createLogger('device:plugins:forward')
+    var plugin = Object.create(null)
+    var manager = new ForwardManager()
+
+    function startService() {
+      log.info('Launching reverse port forwarding service')
+      return adb.shell(options.serial, [
+          'exec'
+        , minirev.bin
+        ])
+        .timeout(10000)
+        .then(function(out) {
+          lifecycle.share('Forward shell', out)
+          streamutil.talk(log, 'Forward shell says: "%s"', out)
+        })
+    }
+
+    function connectService(times) {
+      function tryConnect(times, delay) {
+        return adb.openLocal(options.serial, 'localabstract:minirev')
+          .timeout(10000)
+          .catch(function(err) {
+            if (/closed/.test(err.message) && times > 1) {
+              return Promise.delay(delay)
+                .then(function() {
+                  return tryConnect(times - 1, delay * 2)
+                })
+            }
+            return Promise.reject(err)
+          })
+      }
+      log.info('Connecting to reverse port forwarding service')
+      return tryConnect(times, 100)
+    }
+
+    function awaitServer() {
+      return connectService(5)
+        .then(function(conn) {
+          conn.end()
+          return true
+        })
+    }
+
+    plugin.createForward = function(id, forward) {
+      log.info(
+        'Creating reverse port forward "%s" from ":%d" to "%s:%d"'
+      , id
+      , forward.devicePort
+      , forward.targetHost
+      , forward.targetPort
+      )
+      return connectService(1)
+        .then(function(out) {
+          var header = new Buffer(4)
+          header.writeUInt16LE(0, 0)
+          header.writeUInt16LE(forward.devicePort, 2)
+          out.write(header)
+          return manager.add(id, out, forward)
+        })
+    }
+
+    plugin.removeForward = function(id) {
+      log.info('Removing reverse port forward "%s"', id)
+      manager.remove(id)
+      return Promise.resolve()
+    }
+
+    plugin.connect = function(options) {
+      var resolver = Promise.defer()
+
+      var conn = net.connect({
+        host: options.targetHost
+      , port: options.targetPort
+      })
+
+      function connectListener() {
+        resolver.resolve(conn)
+      }
+
+      function errorListener(err) {
+        resolver.reject(err)
+      }
+
+      conn.on('connect', connectListener)
+      conn.on('error', errorListener)
+
+      return resolver.promise.finally(function() {
+        conn.removeListener('connect', connectListener)
+        conn.removeListener('error', errorListener)
+      })
+    }
+
+    plugin.reset = function() {
+      manager.removeAll()
+    }
+
+    group.on('leave', plugin.reset)
+
+    var pushForwards = _.debounce(
+      function() {
+        push.send([
+          wireutil.global
+        , wireutil.envelope(new wire.ReverseForwardsEvent(
+            options.serial
+          , manager.listAll()
+          ))
+        ])
+      }
+    , 200
+    )
+
+    manager.on('add', pushForwards)
+    manager.on('remove', pushForwards)
+
+    return startService()
+      .then(awaitServer)
+      .then(function() {
+        router
+          .on(wire.ForwardTestMessage, function(channel, message) {
+            var reply = wireutil.reply(options.serial)
+            plugin.connect(message)
+              .then(function(conn) {
+                conn.end()
+                push.send([
+                  channel
+                , reply.okay('success')
+                ])
+              })
+              .catch(function() {
+                push.send([
+                  channel
+                , reply.fail('fail_connect')
+                ])
+              })
+          })
+          .on(wire.ForwardCreateMessage, function(channel, message) {
+            var reply = wireutil.reply(options.serial)
+            plugin.createForward(message.id, message)
+              .then(function() {
+                push.send([
+                  channel
+                , reply.okay('success')
+                ])
+              })
+              .catch(function(err) {
+                log.error('Reverse port forwarding failed', err.stack)
+                push.send([
+                  channel
+                , reply.fail('fail_forward')
+                ])
+              })
+          })
+          .on(wire.ForwardRemoveMessage, function(channel, message) {
+            var reply = wireutil.reply(options.serial)
+            plugin.removeForward(message.id)
+              .then(function() {
+                push.send([
+                  channel
+                , reply.okay('success')
+                ])
+              })
+              .catch(function(err) {
+                log.error('Reverse port unforwarding failed', err.stack)
+                push.send([
+                  channel
+                , reply.fail('fail')
+                ])
+              })
+          })
+      })
+      .return(plugin)
+  })
diff --git a/crowdstf/lib/units/device/plugins/forward/util/manager.js b/crowdstf/lib/units/device/plugins/forward/util/manager.js
new file mode 100644
index 0000000..dea09e8
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/forward/util/manager.js
@@ -0,0 +1,173 @@
+var util = require('util')
+var events = require('events')
+var net = require('net')
+
+var ForwardReader = require('./reader')
+var ForwardWriter = require('./writer')
+
+// Handles a single connection
+function DestHandler(id, conn, options) {
+  var dest = net.connect({
+      host: options.targetHost
+      , port: options.targetPort
+    })
+
+  var writer = dest.pipe(new ForwardWriter(id))
+
+  // We can't just pipe to conn because we don't want to end it
+  // when the dest closes. Instead we'll send a special packet
+  // to it (which is handled by the writer).
+  function maybePipeManually() {
+    var chunk
+    while ((chunk = writer.read())) {
+      if (!conn.write(chunk)) {
+        break
+      }
+    }
+  }
+
+  function readableListener() {
+    maybePipeManually()
+  }
+
+  function drainListener() {
+    maybePipeManually()
+  }
+
+  function endListener() {
+    conn.removeListener('drain', drainListener)
+    writer.removeListener('readable', readableListener)
+    this.emit('end')
+  }
+
+  function errorListener() {
+    writer.end()
+  }
+
+  writer.on('end', endListener.bind(this))
+  writer.on('readable', readableListener)
+  dest.on('error', errorListener)
+  conn.on('drain', drainListener)
+
+  this.end = function() {
+    dest.end()
+  }
+
+  this.write = function(chunk) {
+    dest.write(chunk)
+  }
+
+  events.EventEmitter.call(this)
+}
+
+util.inherits(DestHandler, events.EventEmitter)
+
+// Handles a single port
+function ForwardHandler(conn, options) {
+  var destHandlersById = Object.create(null)
+
+  function endListener() {
+    this.emit('end')
+  }
+
+  function packetEndListener(id) {
+    delete destHandlersById[id]
+  }
+
+  function packetListener(id, packet) {
+    var dest = destHandlersById[id]
+
+    if (packet) {
+      if (!dest) {
+        // Let's create a new connection
+        dest = destHandlersById[id] = new DestHandler(id, conn, options)
+        dest.on('end', packetEndListener.bind(null, id))
+      }
+
+      dest.write(packet)
+    }
+    else {
+      // It's a simulated fin packet
+      if (dest) {
+        dest.end()
+      }
+    }
+  }
+
+  function readableListener() {
+    // No-op but must exist so that we get the 'end' event.
+  }
+
+  conn.pipe(new ForwardReader())
+    .on('end', endListener.bind(this))
+    .on('packet', packetListener)
+    .on('readable', readableListener)
+
+  this.options = options
+
+  this.end = function() {
+    conn.end()
+  }
+
+  events.EventEmitter.call(this)
+}
+
+util.inherits(ForwardHandler, events.EventEmitter)
+
+// Handles multiple ports
+function ForwardManager() {
+  var handlersById = Object.create(null)
+
+  this.has = function(id) {
+    return !!handlersById[id]
+  }
+
+  this.add = function(id, conn, options) {
+    function endListener() {
+      delete handlersById[id]
+      this.emit('remove', id, options)
+    }
+
+    if (this.has(id)) {
+      this.remove(id)
+    }
+
+    var handler = new ForwardHandler(conn, options)
+    handler.on('end', endListener.bind(this))
+
+    handlersById[id] = handler
+
+    this.emit('add', id, options)
+  }
+
+  this.remove = function(id) {
+    var handler = handlersById[id]
+    if (handler) {
+      handler.end()
+    }
+  }
+
+  this.removeAll = function() {
+    Object.keys(handlersById).forEach(function(id) {
+      handlersById[id].end()
+    })
+  }
+
+  this.listAll = function() {
+    return Object.keys(handlersById).map(function(id) {
+      var handler = handlersById[id]
+      return {
+        id: id
+      , devicePort: handler.options.devicePort
+      , targetHost: handler.options.targetHost
+      , targetPort: handler.options.targetPort
+      }
+    })
+  }
+
+  events.EventEmitter.call(this)
+}
+
+util.inherits(ForwardManager, events.EventEmitter)
+
+module.exports = ForwardManager
diff --git a/crowdstf/lib/units/device/plugins/forward/util/reader.js b/crowdstf/lib/units/device/plugins/forward/util/reader.js
new file mode 100644
index 0000000..411069f
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/forward/util/reader.js
@@ -0,0 +1,75 @@
+var util = require('util')
+var stream = require('stream')
+
+var HEADER_SIZE = 4
+
+function ForwardReader() {
+  stream.Transform.call(this)
+  this._header = new Buffer(HEADER_SIZE)
+  this._needLength = -HEADER_SIZE
+  this._target = 0
+}
+
+util.inherits(ForwardReader, stream.Transform)
+
+ForwardReader.prototype._transform = function(chunk, encoding, done) {
+  var cursor = 0
+
+  while (cursor < chunk.length) {
+    var diff = chunk.length - cursor
+
+    // Do we need a header?
+    if (this._needLength < 0) {
+      // Still missing a header?
+      if (chunk.length < -this._needLength) {
+        // Save what we're received so far.
+        chunk.copy(
+          this._header
+        , HEADER_SIZE + this._needLength
+        , cursor
+        , cursor + -this._needLength
+        )
+        break
+      }
+
+      // Combine previous and current chunk in case the header was split.
+      chunk.copy(
+        this._header
+      , HEADER_SIZE + this._needLength
+      , cursor
+      , cursor + -this._needLength
+      )
+
+      cursor += -this._needLength
+
+      this._target = this._header.readUInt16LE(0)
+      this._needLength = this._header.readUInt16LE(2)
+
+      if (this._needLength === 0) {
+        // This is a fin packet
+        this.emit('packet', this._target, null)
+        this._needLength = -HEADER_SIZE
+      }
+    }
+    // Do we have a full data packet?
+    else if (diff >= this._needLength) {
+      this.emit(
+        'packet'
+      , this._target
+      , chunk.slice(cursor, cursor + this._needLength)
+      )
+      cursor += this._needLength
+      this._needLength = -HEADER_SIZE
+    }
+    // We have a partial data packet.
+    else {
+      this.emit('packet', this._target, chunk.slice(cursor, cursor + diff))
+      this._needLength -= diff
+      cursor += diff
+    }
+  }
+
+  done()
+}
+
+module.exports = ForwardReader
diff --git a/crowdstf/lib/units/device/plugins/forward/util/writer.js b/crowdstf/lib/units/device/plugins/forward/util/writer.js
new file mode 100644
index 0000000..02e740c
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/forward/util/writer.js
@@ -0,0 +1,45 @@
+var util = require('util')
+var stream = require('stream')
+
+var HEADER_SIZE = 4
+var MAX_PACKET_SIZE = 0xFFFF
+
+function ForwardWriter(target) {
+  stream.Transform.call(this)
+  this._target = target
+}
+
+util.inherits(ForwardWriter, stream.Transform)
+
+ForwardWriter.prototype._transform = function(fullChunk, encoding, done) {
+  var chunk = fullChunk
+  var header, length
+
+  do {
+    length = Math.min(MAX_PACKET_SIZE, chunk.length)
+
+    header = new Buffer(HEADER_SIZE)
+    header.writeUInt16LE(this._target, 0)
+    header.writeUInt16LE(length, 2)
+
+    this.push(header)
+    this.push(chunk.slice(0, length))
+
+    chunk = chunk.slice(length)
+  }
+  while (chunk.length)
+
+  done()
+}
+
+ForwardWriter.prototype._flush = function(done) {
+  var header = new Buffer(HEADER_SIZE)
+  header.writeUInt16LE(this._target, 0)
+  header.writeUInt16LE(0, 2)
+
+  this.push(header)
+
+  done()
+}
+
+module.exports = ForwardWriter
diff --git a/crowdstf/lib/units/device/plugins/group.js b/crowdstf/lib/units/device/plugins/group.js
new file mode 100644
index 0000000..50e62b3
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/group.js
@@ -0,0 +1,180 @@
+var events = require('events')
+
+var Promise = require('bluebird')
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+var grouputil = require('../../../util/grouputil')
+var lifecycle = require('../../../util/lifecycle')
+
+module.exports = syrup.serial()
+  .dependency(require('./solo'))
+  .dependency(require('./util/identity'))
+  .dependency(require('./service'))
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .dependency(require('../support/sub'))
+  .dependency(require('../support/channels'))
+  .define(function(options, solo, ident, service, router, push, sub, channels) {
+    var log = logger.createLogger('device:plugins:group')
+    var currentGroup = null
+    var plugin = new events.EventEmitter()
+
+    plugin.get = Promise.method(function() {
+      if (!currentGroup) {
+        throw new grouputil.NoGroupError()
+      }
+
+      return currentGroup
+    })
+
+    plugin.join = function(newGroup, timeout, identifier) {
+      return plugin.get()
+        .then(function() {
+          if (currentGroup.group !== newGroup.group) {
+            throw new grouputil.AlreadyGroupedError()
+          }
+
+          return currentGroup
+        })
+        .catch(grouputil.NoGroupError, function() {
+          currentGroup = newGroup
+
+          log.important('Now owned by "%s"', currentGroup.email)
+          log.info('Subscribing to group channel "%s"', currentGroup.group)
+
+          channels.register(currentGroup.group, {
+            timeout: timeout || options.groupTimeout
+          , alias: solo.channel
+          })
+
+          sub.subscribe(currentGroup.group)
+
+          push.send([
+            wireutil.global
+          , wireutil.envelope(new wire.JoinGroupMessage(
+              options.serial
+            , currentGroup
+            ))
+          ])
+
+          plugin.emit('join', currentGroup, identifier)
+
+          return currentGroup
+        })
+    }
+
+    plugin.keepalive = function() {
+      if (currentGroup) {
+        channels.keepalive(currentGroup.group)
+      }
+    }
+
+    plugin.leave = function(reason) {
+      return plugin.get()
+        .then(function(group) {
+          log.important('No longer owned by "%s"', group.email)
+          log.info('Unsubscribing from group channel "%s"', group.group)
+
+          channels.unregister(group.group)
+          sub.unsubscribe(group.group)
+
+          push.send([
+            wireutil.global
+          , wireutil.envelope(new wire.LeaveGroupMessage(
+              options.serial
+            , group
+            , reason
+            ))
+          ])
+
+          currentGroup = null
+          plugin.emit('leave', group)
+
+          return group
+        })
+    }
+
+    plugin.on('join', function() {
+      service.wake()
+      service.acquireWakeLock()
+    })
+
+    plugin.on('leave', function() {
+      service.pressKey('home')
+      service.thawRotation()
+      service.releaseWakeLock()
+    })
+
+    router
+      .on(wire.GroupMessage, function(channel, message) {
+        var reply = wireutil.reply(options.serial)
+        grouputil.match(ident, message.requirements)
+          .then(function() {
+            return plugin.join(message.owner, message.timeout)
+          })
+          .then(function() {
+            push.send([
+              channel
+            , reply.okay()
+            ])
+          })
+          .catch(grouputil.RequirementMismatchError, function(err) {
+            push.send([
+              channel
+            , reply.fail(err.message)
+            ])
+          })
+          .catch(grouputil.AlreadyGroupedError, function(err) {
+            push.send([
+              channel
+            , reply.fail(err.message)
+            ])
+          })
+      })
+      .on(wire.AutoGroupMessage, function(channel, message) {
+        return plugin.join(message.owner, message.timeout, message.identifier)
+          .then(function() {
+            plugin.emit('autojoin', message.identifier, true)
+          })
+          .catch(grouputil.AlreadyGroupedError, function() {
+            plugin.emit('autojoin', message.identifier, false)
+          })
+      })
+      .on(wire.UngroupMessage, function(channel, message) {
+        var reply = wireutil.reply(options.serial)
+        grouputil.match(ident, message.requirements)
+          .then(function() {
+            return plugin.leave('ungroup_request')
+          })
+          .then(function() {
+            push.send([
+              channel
+            , reply.okay()
+            ])
+          })
+          .catch(grouputil.NoGroupError, function(err) {
+            push.send([
+              channel
+            , reply.fail(err.message)
+            ])
+          })
+      })
+
+    channels.on('timeout', function(channel) {
+      if (currentGroup && channel === currentGroup.group) {
+        plugin.leave('automatic_timeout')
+      }
+    })
+
+    lifecycle.observe(function() {
+      return plugin.leave('device_absent')
+        .catch(grouputil.NoGroupError, function() {
+          return true
+        })
+    })
+
+    return plugin
+  })
diff --git a/crowdstf/lib/units/device/plugins/heartbeat.js b/crowdstf/lib/units/device/plugins/heartbeat.js
new file mode 100644
index 0000000..0e585f8
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/heartbeat.js
@@ -0,0 +1,24 @@
+var syrup = require('stf-syrup')
+
+var lifecycle = require('../../../util/lifecycle')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/push'))
+  .define(function(options, push) {
+    function beat() {
+      push.send([
+        wireutil.global
+      , wireutil.envelope(new wire.DeviceHeartbeatMessage(
+          options.serial
+        ))
+      ])
+    }
+
+    var timer = setInterval(beat, options.heartbeatInterval)
+
+    lifecycle.observe(function() {
+      clearInterval(timer)
+    })
+  })
diff --git a/crowdstf/lib/units/device/plugins/install.js b/crowdstf/lib/units/device/plugins/install.js
new file mode 100644
index 0000000..894fbe2
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/install.js
@@ -0,0 +1,223 @@
+var stream = require('stream')
+var url = require('url')
+var util = require('util')
+
+var syrup = require('stf-syrup')
+var request = require('request')
+var Promise = require('bluebird')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+var promiseutil = require('../../../util/promiseutil')
+
+// The error codes are available at https://github.com/android/
+// platform_frameworks_base/blob/master/core/java/android/content/
+// pm/PackageManager.java
+function InstallationError(err) {
+  return err.code && /^INSTALL_/.test(err.code)
+}
+
+module.exports = syrup.serial()
+  .dependency(require('../support/adb'))
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .define(function(options, adb, router, push) {
+    var log = logger.createLogger('device:plugins:install')
+
+    router.on(wire.InstallMessage, function(channel, message) {
+      var manifest = JSON.parse(message.manifest)
+      var pkg = manifest.package
+
+      log.info('Installing package "%s" from "%s"', pkg, message.href)
+
+      var reply = wireutil.reply(options.serial)
+
+      function sendProgress(data, progress) {
+        push.send([
+          channel
+        , reply.progress(data, progress)
+        ])
+      }
+
+      function pushApp() {
+        var req = request({
+          url: url.resolve(options.storageUrl, message.href)
+        })
+
+        // We need to catch the Content-Length on the fly or we risk
+        // losing some of the initial chunks.
+        var contentLength = null
+        req.on('response', function(res) {
+          contentLength = parseInt(res.headers['content-length'], 10)
+        })
+
+        var source = new stream.Readable().wrap(req)
+        var target = '/data/local/tmp/_app.apk'
+
+        return adb.push(options.serial, source, target)
+          .timeout(10000)
+          .then(function(transfer) {
+            var resolver = Promise.defer()
+
+            function progressListener(stats) {
+              if (contentLength) {
+                // Progress 0% to 70%
+                sendProgress(
+                  'pushing_app'
+                , 50 * Math.max(0, Math.min(
+                    50
+                  , stats.bytesTransferred / contentLength
+                  ))
+                )
+              }
+            }
+
+            function errorListener(err) {
+              resolver.reject(err)
+            }
+
+            function endListener() {
+              resolver.resolve(target)
+            }
+
+            transfer.on('progress', progressListener)
+            transfer.on('error', errorListener)
+            transfer.on('end', endListener)
+
+            return resolver.promise.finally(function() {
+              transfer.removeListener('progress', progressListener)
+              transfer.removeListener('error', errorListener)
+              transfer.removeListener('end', endListener)
+            })
+          })
+      }
+
+      // Progress 0%
+      sendProgress('pushing_app', 0)
+      pushApp()
+        .then(function(apk) {
+          var start = 50
+          var end = 90
+          var guesstimate = start
+
+          sendProgress('installing_app', guesstimate)
+          return promiseutil.periodicNotify(
+              adb.installRemote(options.serial, apk)
+                .timeout(60000 * 5)
+                .catch(function(err) {
+                  switch (err.code) {
+                  case 'INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES':
+                  case 'INSTALL_FAILED_VERSION_DOWNGRADE':
+                    log.info(
+                      'Uninstalling "%s" first due to inconsistent certificates'
+                    , pkg
+                    )
+                    return adb.uninstall(options.serial, pkg)
+                      .timeout(15000)
+                      .then(function() {
+                        return adb.installRemote(options.serial, apk)
+                          .timeout(60000 * 5)
+                      })
+                  default:
+                    return Promise.reject(err)
+                  }
+                })
+            , 250
+            )
+            .progressed(function() {
+              guesstimate = Math.min(
+                end
+              , guesstimate + 1.5 * (end - guesstimate) / (end - start)
+              )
+              sendProgress('installing_app', guesstimate)
+            })
+        })
+        .then(function() {
+          if (message.launch) {
+            if (manifest.application.launcherActivities.length) {
+              var activityName = manifest.application.launcherActivities[0].name
+
+              // According to the AndroidManifest.xml documentation the dot is
+              // required, but actually it isn't.
+              if (activityName.indexOf('.') === -1) {
+                activityName = util.format('.%s', activityName)
+              }
+
+              var launchActivity = {
+                action: 'android.intent.action.MAIN'
+              , component: util.format(
+                  '%s/%s'
+                , pkg
+                , activityName
+                )
+              , category: ['android.intent.category.LAUNCHER']
+              , flags: 0x10200000
+              }
+
+              log.info(
+                'Launching activity with action "%s" on component "%s"'
+              , launchActivity.action
+              , launchActivity.component
+              )
+              // Progress 90%
+              sendProgress('launching_app', 90)
+              return adb.startActivity(options.serial, launchActivity)
+                .timeout(30000)
+            }
+          }
+        })
+        .then(function() {
+          push.send([
+            channel
+          , reply.okay('INSTALL_SUCCEEDED')
+          ])
+        })
+        .catch(Promise.TimeoutError, function(err) {
+          log.error('Installation of package "%s" failed', pkg, err.stack)
+          push.send([
+            channel
+          , reply.fail('INSTALL_ERROR_TIMEOUT')
+          ])
+        })
+        .catch(InstallationError, function(err) {
+          log.important(
+            'Tried to install package "%s", got "%s"'
+          , pkg
+          , err.code
+          )
+          push.send([
+            channel
+          , reply.fail(err.code)
+          ])
+        })
+        .catch(function(err) {
+          log.error('Installation of package "%s" failed', pkg, err.stack)
+          push.send([
+            channel
+          , reply.fail('INSTALL_ERROR_UNKNOWN')
+          ])
+        })
+    })
+
+    router.on(wire.UninstallMessage, function(channel, message) {
+      log.info('Uninstalling "%s"', message.packageName)
+
+      var reply = wireutil.reply(options.serial)
+
+      adb.uninstall(options.serial, message.packageName)
+        .then(function() {
+          push.send([
+            channel
+          , reply.okay('success')
+          ])
+        })
+        .catch(function(err) {
+          log.error('Uninstallation failed', err.stack)
+          push.send([
+            channel
+          , reply.fail('fail')
+          ])
+        })
+    })
+  })
diff --git a/crowdstf/lib/units/device/plugins/logcat.js b/crowdstf/lib/units/device/plugins/logcat.js
new file mode 100644
index 0000000..c63f208
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/logcat.js
@@ -0,0 +1,141 @@
+var syrup = require('stf-syrup')
+var Promise = require('bluebird')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+var lifecycle = require('../../../util/lifecycle')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/adb'))
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .dependency(require('./group'))
+  .define(function(options, adb, router, push, group) {
+    var log = logger.createLogger('device:plugins:logcat')
+    var plugin = Object.create(null)
+    var activeLogcat = null
+
+    plugin.start = function(filters) {
+      return group.get()
+        .then(function(group) {
+          return plugin.stop()
+            .then(function() {
+              log.info('Starting logcat')
+              return adb.openLogcat(options.serial, {
+                clear: true
+              })
+            })
+            .timeout(10000)
+            .then(function(logcat) {
+              activeLogcat = logcat
+
+              function entryListener(entry) {
+                push.send([
+                  group.group
+                , wireutil.envelope(new wire.DeviceLogcatEntryMessage(
+                    options.serial
+                  , entry.date.getTime() / 1000
+                  , entry.pid
+                  , entry.tid
+                  , entry.priority
+                  , entry.tag
+                  , entry.message
+                  ))
+                ])
+              }
+
+              logcat.on('entry', entryListener)
+
+              return plugin.reset(filters)
+            })
+        })
+    }
+
+    plugin.stop = Promise.method(function() {
+      if (plugin.isRunning()) {
+        log.info('Stopping logcat')
+        activeLogcat.end()
+        activeLogcat = null
+      }
+    })
+
+    plugin.reset = Promise.method(function(filters) {
+      if (plugin.isRunning()) {
+        activeLogcat
+          .resetFilters()
+
+        if (filters.length) {
+          activeLogcat.excludeAll()
+          filters.forEach(function(filter) {
+            activeLogcat.include(filter.tag, filter.priority)
+          })
+        }
+      }
+      else {
+        throw new Error('Logcat is not running')
+      }
+    })
+
+    plugin.isRunning = function() {
+      return !!activeLogcat
+    }
+
+    lifecycle.observe(plugin.stop)
+    group.on('leave', plugin.stop)
+
+    router
+      .on(wire.LogcatStartMessage, function(channel, message) {
+        var reply = wireutil.reply(options.serial)
+        plugin.start(message.filters)
+          .then(function() {
+            push.send([
+              channel
+            , reply.okay('success')
+            ])
+          })
+          .catch(function(err) {
+            log.error('Unable to open logcat', err.stack)
+            push.send([
+              channel
+            , reply.fail('fail')
+            ])
+          })
+      })
+      .on(wire.LogcatApplyFiltersMessage, function(channel, message) {
+        var reply = wireutil.reply(options.serial)
+        plugin.reset(message.filters)
+          .then(function() {
+            push.send([
+              channel
+            , reply.okay('success')
+            ])
+          })
+          .catch(function(err) {
+            log.error('Failed to apply logcat filters', err.stack)
+            push.send([
+              channel
+            , reply.fail('fail')
+            ])
+          })
+      })
+      .on(wire.LogcatStopMessage, function(channel) {
+        var reply = wireutil.reply(options.serial)
+        plugin.stop()
+          .then(function() {
+            push.send([
+              channel
+            , reply.okay('success')
+            ])
+          })
+          .catch(function(err) {
+            log.error('Failed to stop logcat', err.stack)
+            push.send([
+              channel
+            , reply.fail('fail')
+            ])
+          })
+      })
+
+    return plugin
+  })
diff --git a/crowdstf/lib/units/device/plugins/logger.js b/crowdstf/lib/units/device/plugins/logger.js
new file mode 100644
index 0000000..658e501
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/logger.js
@@ -0,0 +1,27 @@
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/push'))
+  .define(function(options, push) {
+    // Forward all logs
+    logger.on('entry', function(entry) {
+      push.send([
+        wireutil.global
+      , wireutil.envelope(new wire.DeviceLogMessage(
+          options.serial
+        , entry.timestamp / 1000
+        , entry.priority
+        , entry.tag
+        , entry.pid
+        , entry.message
+        , entry.identifier
+        ))
+      ])
+    })
+
+    return logger
+  })
diff --git a/crowdstf/lib/units/device/plugins/mute.js b/crowdstf/lib/units/device/plugins/mute.js
new file mode 100644
index 0000000..6ff5467
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/mute.js
@@ -0,0 +1,30 @@
+var Promise = require('bluebird')
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+
+module.exports = syrup.serial()
+  .dependency(require('./group'))
+  .dependency(require('./service'))
+  .define(function(options, group, service) {
+    var log = logger.createLogger('device:plugins:mute')
+
+    if (options.muteMaster) {
+      log.info('Will mute master volume during use')
+
+      group.on('join', function() {
+        log.info('Muting master volume')
+        service.setMasterMute(true)
+      })
+
+      group.on('leave', function() {
+        log.info('Unmuting master volume')
+        service.setMasterMute(false)
+      })
+    }
+    else {
+      log.info('Will not mute master volume during use')
+    }
+
+    return Promise.resolve()
+  })
diff --git a/crowdstf/lib/units/device/plugins/reboot.js b/crowdstf/lib/units/device/plugins/reboot.js
new file mode 100644
index 0000000..0402246
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/reboot.js
@@ -0,0 +1,35 @@
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/adb'))
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .define(function(options, adb, router, push) {
+    var log = logger.createLogger('device:plugins:reboot')
+
+    router.on(wire.RebootMessage, function(channel) {
+      var reply = wireutil.reply(options.serial)
+
+      log.important('Rebooting')
+
+      adb.reboot(options.serial)
+        .timeout(30000)
+        .then(function() {
+          push.send([
+            channel
+          , reply.okay()
+          ])
+        })
+        .error(function(err) {
+          log.error('Reboot failed', err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+  })
diff --git a/crowdstf/lib/units/device/plugins/ringer.js b/crowdstf/lib/units/device/plugins/ringer.js
new file mode 100644
index 0000000..eaf8c31
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/ringer.js
@@ -0,0 +1,57 @@
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+
+module.exports = syrup.serial()
+  .dependency(require('./service'))
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .define(function(options, service, router, push) {
+    var log = logger.createLogger('device:plugins:ringer')
+
+    router.on(wire.RingerSetMessage, function(channel, message) {
+      var reply = wireutil.reply(options.serial)
+
+      log.info('Setting ringer mode to mode "%s"', message.mode)
+
+      service.setRingerMode(message.mode)
+        .timeout(30000)
+        .then(function() {
+          push.send([
+            channel
+          , reply.okay()
+          ])
+        })
+        .catch(function(err) {
+          log.error('Setting ringer mode failed', err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+
+    router.on(wire.RingerGetMessage, function(channel) {
+      var reply = wireutil.reply(options.serial)
+
+      log.info('Getting ringer mode')
+
+      service.getRingerMode()
+        .timeout(30000)
+        .then(function(mode) {
+          push.send([
+            channel
+          , reply.okay('success', mode)
+          ])
+        })
+        .catch(function(err) {
+          log.error('Getting ringer mode failed', err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+  })
diff --git a/crowdstf/lib/units/device/plugins/screen/capture.js b/crowdstf/lib/units/device/plugins/screen/capture.js
new file mode 100644
index 0000000..1ccf8c4
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/screen/capture.js
@@ -0,0 +1,81 @@
+var util = require('util')
+
+var syrup = require('stf-syrup')
+var adbkit = require('adbkit')
+
+var logger = require('../../../../util/logger')
+var wire = require('../../../../wire')
+var wireutil = require('../../../../wire/util')
+
+module.exports = syrup.serial()
+  .dependency(require('../../support/adb'))
+  .dependency(require('../../support/router'))
+  .dependency(require('../../support/push'))
+  .dependency(require('../../support/storage'))
+  .dependency(require('../../resources/minicap'))
+  .dependency(require('../util/display'))
+  .define(function(options, adb, router, push, storage, minicap, display) {
+    var log = logger.createLogger('device:plugins:screen:capture')
+    var plugin = Object.create(null)
+
+    function projectionFormat() {
+      return util.format(
+        '%dx%d@%dx%d/%d'
+      , display.properties.width
+      , display.properties.height
+      , display.properties.width
+      , display.properties.height
+      , display.properties.rotation
+      )
+    }
+
+    plugin.capture = function() {
+      log.info('Capturing screenshot')
+
+      var file = util.format('/data/local/tmp/minicap_%d.jpg', Date.now())
+      return minicap.run(util.format(
+          '-P %s -s >%s', projectionFormat(), file))
+        .then(adbkit.util.readAll)
+        .then(function() {
+          return adb.stat(options.serial, file)
+        })
+        .then(function(stats) {
+          if (stats.size === 0) {
+            throw new Error('Empty screenshot; possibly secure screen?')
+          }
+
+          return adb.pull(options.serial, file)
+            .then(function(transfer) {
+              return storage.store('image', transfer, {
+                filename: util.format('%s.jpg', options.serial)
+              , contentType: 'image/jpeg'
+              , knownLength: stats.size
+              })
+            })
+        })
+        .finally(function() {
+          return adb.shell(options.serial, ['rm', '-f', file])
+            .then(adbkit.util.readAll)
+        })
+    }
+
+    router.on(wire.ScreenCaptureMessage, function(channel) {
+      var reply = wireutil.reply(options.serial)
+      plugin.capture()
+        .then(function(file) {
+          push.send([
+            channel
+          , reply.okay('success', file)
+          ])
+        })
+        .catch(function(err) {
+          log.error('Screen capture failed', err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+
+    return plugin
+  })
diff --git a/crowdstf/lib/units/device/plugins/screen/options.js b/crowdstf/lib/units/device/plugins/screen/options.js
new file mode 100644
index 0000000..9a2f688
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/screen/options.js
@@ -0,0 +1,17 @@
+var syrup = require('stf-syrup')
+var _ = require('lodash')
+
+module.exports = syrup.serial()
+  .define(function(options) {
+    var plugin = Object.create(null)
+
+    plugin.devicePort = 9002
+    plugin.publicPort = options.screenPort
+    plugin.publicUrl = _.template(options.screenWsUrlPattern)({
+      publicIp: options.publicIp
+    , publicPort: plugin.publicPort
+    , serial: options.serial
+    })
+
+    return plugin
+  })
diff --git a/crowdstf/lib/units/device/plugins/screen/stream.js b/crowdstf/lib/units/device/plugins/screen/stream.js
new file mode 100644
index 0000000..9d2c738
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/screen/stream.js
@@ -0,0 +1,597 @@
+var util = require('util')
+
+var Promise = require('bluebird')
+var syrup = require('stf-syrup')
+var WebSocket = require('ws')
+var uuid = require('node-uuid')
+var EventEmitter = require('eventemitter3').EventEmitter
+var split = require('split')
+var adbkit = require('adbkit')
+
+var logger = require('../../../../util/logger')
+var lifecycle = require('../../../../util/lifecycle')
+var bannerutil = require('./util/banner')
+var FrameParser = require('./util/frameparser')
+var FrameConfig = require('./util/frameconfig')
+var BroadcastSet = require('./util/broadcastset')
+var StateQueue = require('../../../../util/statequeue')
+var RiskyStream = require('../../../../util/riskystream')
+var FailCounter = require('../../../../util/failcounter')
+
+module.exports = syrup.serial()
+  .dependency(require('../../support/adb'))
+  .dependency(require('../../resources/minicap'))
+  .dependency(require('../util/display'))
+  .dependency(require('./options'))
+  .define(function(options, adb, minicap, display, screenOptions) {
+    var log = logger.createLogger('device:plugins:screen:stream')
+
+    function FrameProducer(config) {
+      EventEmitter.call(this)
+      this.actionQueue = []
+      this.runningState = FrameProducer.STATE_STOPPED
+      this.desiredState = new StateQueue()
+      this.output = null
+      this.socket = null
+      this.pid = -1
+      this.banner = null
+      this.parser = null
+      this.frameConfig = config
+      this.readable = false
+      this.needsReadable = false
+      this.failCounter = new FailCounter(3, 10000)
+      this.failCounter.on('exceedLimit', this._failLimitExceeded.bind(this))
+      this.failed = false
+      this.readableListener = this._readableListener.bind(this)
+    }
+
+    util.inherits(FrameProducer, EventEmitter)
+
+    FrameProducer.STATE_STOPPED = 1
+    FrameProducer.STATE_STARTING = 2
+    FrameProducer.STATE_STARTED = 3
+    FrameProducer.STATE_STOPPING = 4
+
+    FrameProducer.prototype._ensureState = function() {
+      if (this.desiredState.empty()) {
+        return
+      }
+
+      if (this.failed) {
+        log.warn('Will not apply desired state due to too many failures')
+        return
+      }
+
+      switch (this.runningState) {
+      case FrameProducer.STATE_STARTING:
+      case FrameProducer.STATE_STOPPING:
+        // Just wait.
+        break
+      case FrameProducer.STATE_STOPPED:
+        if (this.desiredState.next() === FrameProducer.STATE_STARTED) {
+          this.runningState = FrameProducer.STATE_STARTING
+          this._startService().bind(this)
+            .then(function(out) {
+              this.output = new RiskyStream(out)
+                .on('unexpectedEnd', this._outputEnded.bind(this))
+              return this._readOutput(this.output.stream)
+            })
+            .then(function() {
+              return this._waitForPid()
+            })
+            .then(function() {
+              return this._connectService()
+            })
+            .then(function(socket) {
+              this.parser = new FrameParser()
+              this.socket = new RiskyStream(socket)
+                .on('unexpectedEnd', this._socketEnded.bind(this))
+              return this._readBanner(this.socket.stream)
+            })
+            .then(function(banner) {
+              this.banner = banner
+              return this._readFrames(this.socket.stream)
+            })
+            .then(function() {
+              this.runningState = FrameProducer.STATE_STARTED
+              this.emit('start')
+            })
+            .catch(Promise.CancellationError, function() {
+              return this._stop()
+            })
+            .catch(function(err) {
+              return this._stop().finally(function() {
+                this.failCounter.inc()
+                this.emit('error', err)
+              })
+            })
+            .finally(function() {
+              this._ensureState()
+            })
+        }
+        else {
+          setImmediate(this._ensureState.bind(this))
+        }
+        break
+      case FrameProducer.STATE_STARTED:
+        if (this.desiredState.next() === FrameProducer.STATE_STOPPED) {
+          this.runningState = FrameProducer.STATE_STOPPING
+          this._stop().finally(function() {
+            this._ensureState()
+          })
+        }
+        else {
+          setImmediate(this._ensureState.bind(this))
+        }
+        break
+      }
+    }
+
+    FrameProducer.prototype.start = function() {
+      log.info('Requesting frame producer to start')
+      this.desiredState.push(FrameProducer.STATE_STARTED)
+      this._ensureState()
+    }
+
+    FrameProducer.prototype.stop = function() {
+      log.info('Requesting frame producer to stop')
+      this.desiredState.push(FrameProducer.STATE_STOPPED)
+      this._ensureState()
+    }
+
+    FrameProducer.prototype.restart = function() {
+      switch (this.runningState) {
+      case FrameProducer.STATE_STARTED:
+      case FrameProducer.STATE_STARTING:
+        this.desiredState.push(FrameProducer.STATE_STOPPED)
+        this.desiredState.push(FrameProducer.STATE_STARTED)
+        this._ensureState()
+        break
+      }
+    }
+
+    FrameProducer.prototype.updateRotation = function(rotation) {
+      if (this.frameConfig.rotation === rotation) {
+        log.info('Keeping %d as current frame producer rotation', rotation)
+        return
+      }
+
+      log.info('Setting frame producer rotation to %d', rotation)
+      this.frameConfig.rotation = rotation
+      this._configChanged()
+    }
+
+    FrameProducer.prototype.updateProjection = function(width, height) {
+      if (this.frameConfig.virtualWidth === width &&
+          this.frameConfig.virtualHeight === height) {
+        log.info(
+          'Keeping %dx%d as current frame producer projection', width, height)
+        return
+      }
+
+      log.info('Setting frame producer projection to %dx%d', width, height)
+      this.frameConfig.virtualWidth = width
+      this.frameConfig.virtualHeight = height
+      this._configChanged()
+    }
+
+    FrameProducer.prototype.nextFrame = function() {
+      var frame = null
+      var chunk
+
+      if (this.parser) {
+        while ((frame = this.parser.nextFrame()) === null) {
+          chunk = this.socket.stream.read()
+          if (chunk) {
+            this.parser.push(chunk)
+          }
+          else {
+            this.readable = false
+            break
+          }
+        }
+      }
+
+      return frame
+    }
+
+    FrameProducer.prototype.needFrame = function() {
+      this.needsReadable = true
+      this._maybeEmitReadable()
+    }
+
+    FrameProducer.prototype._configChanged = function() {
+      this.restart()
+    }
+
+    FrameProducer.prototype._socketEnded = function() {
+      log.warn('Connection to minicap ended unexpectedly')
+      this.failCounter.inc()
+      this.restart()
+    }
+
+    FrameProducer.prototype._outputEnded = function() {
+      log.warn('Shell keeping minicap running ended unexpectedly')
+      this.failCounter.inc()
+      this.restart()
+    }
+
+    FrameProducer.prototype._failLimitExceeded = function(limit, time) {
+      this._stop()
+      this.failed = true
+      this.emit('error', new Error(util.format(
+        'Failed more than %d times in %dms'
+      , limit
+      , time
+      )))
+    }
+
+    FrameProducer.prototype._startService = function() {
+      log.info('Launching screen service')
+      return minicap.run(util.format('-S -P %s', this.frameConfig.toString()))
+        .timeout(10000)
+    }
+
+    FrameProducer.prototype._readOutput = function(out) {
+      out.pipe(split()).on('data', function(line) {
+        var trimmed = line.toString().trim()
+
+        if (trimmed === '') {
+          return
+        }
+
+        if (/ERROR/.test(line)) {
+          log.fatal('minicap error: "%s"', line)
+          return lifecycle.fatal()
+        }
+
+        var match = /^PID: (\d+)$/.exec(line)
+        if (match) {
+          this.pid = Number(match[1])
+          this.emit('pid', this.pid)
+        }
+
+        log.info('minicap says: "%s"', line)
+      }.bind(this))
+    }
+
+    FrameProducer.prototype._waitForPid = function() {
+      if (this.pid > 0) {
+        return Promise.resolve(this.pid)
+      }
+
+      var pidListener
+      return new Promise(function(resolve) {
+          this.on('pid', pidListener = resolve)
+        }.bind(this)).bind(this)
+        .timeout(2000)
+        .finally(function() {
+          this.removeListener('pid', pidListener)
+        })
+    }
+
+    FrameProducer.prototype._connectService = function() {
+      function tryConnect(times, delay) {
+        return adb.openLocal(options.serial, 'localabstract:minicap')
+          .timeout(10000)
+          .then(function(out) {
+            return out
+          })
+          .catch(function(err) {
+            if (/closed/.test(err.message) && times > 1) {
+              return Promise.delay(delay)
+                .then(function() {
+                  return tryConnect(times - 1, delay * 2)
+                })
+            }
+            return Promise.reject(err)
+          })
+      }
+      log.info('Connecting to minicap service')
+      return tryConnect(5, 100)
+    }
+
+    FrameProducer.prototype._stop = function() {
+      return this._disconnectService(this.socket).bind(this)
+        .timeout(2000)
+        .then(function() {
+          return this._stopService(this.output).timeout(10000)
+        })
+        .then(function() {
+          this.runningState = FrameProducer.STATE_STOPPED
+          this.emit('stop')
+        })
+        .catch(function(err) {
+          // In practice we _should_ never get here due to _stopService()
+          // being quite aggressive. But if we do, well... assume it
+          // stopped anyway for now.
+          this.runningState = FrameProducer.STATE_STOPPED
+          this.emit('error', err)
+          this.emit('stop')
+        })
+        .finally(function() {
+          this.output = null
+          this.socket = null
+          this.pid = -1
+          this.banner = null
+          this.parser = null
+        })
+    }
+
+    FrameProducer.prototype._disconnectService = function(socket) {
+      log.info('Disconnecting from minicap service')
+
+      if (!socket || socket.ended) {
+        return Promise.resolve(true)
+      }
+
+      socket.stream.removeListener('readable', this.readableListener)
+
+      var endListener
+      return new Promise(function(resolve) {
+          socket.on('end', endListener = function() {
+            resolve(true)
+          })
+
+          socket.stream.resume()
+          socket.end()
+        })
+        .finally(function() {
+          socket.removeListener('end', endListener)
+        })
+    }
+
+    FrameProducer.prototype._stopService = function(output) {
+      log.info('Stopping minicap service')
+
+      if (!output || output.ended) {
+        return Promise.resolve(true)
+      }
+
+      var pid = this.pid
+
+      function kill(signal) {
+        if (pid <= 0) {
+          return Promise.reject(new Error('Minicap service pid is unknown'))
+        }
+
+        var signum = {
+          SIGTERM: -15
+        , SIGKILL: -9
+        }[signal]
+
+        log.info('Sending %s to minicap', signal)
+        return Promise.all([
+            output.waitForEnd()
+          , adb.shell(options.serial, ['kill', signum, pid])
+              .then(adbkit.util.readAll)
+              .return(true)
+          ])
+          .timeout(2000)
+      }
+
+      function kindKill() {
+        return kill('SIGTERM')
+      }
+
+      function forceKill() {
+        return kill('SIGKILL')
+      }
+
+      function forceEnd() {
+        log.info('Ending minicap I/O as a last resort')
+        output.end()
+        return Promise.resolve(true)
+      }
+
+      return kindKill()
+        .catch(Promise.TimeoutError, forceKill)
+        .catch(forceEnd)
+    }
+
+    FrameProducer.prototype._readBanner = function(socket) {
+      log.info('Reading minicap banner')
+      return bannerutil.read(socket).timeout(2000)
+    }
+
+    FrameProducer.prototype._readFrames = function(socket) {
+      this.needsReadable = true
+      socket.on('readable', this.readableListener)
+
+      // We may already have data pending. Let the user know they should
+      // at least attempt to read frames now.
+      this.readableListener()
+    }
+
+    FrameProducer.prototype._maybeEmitReadable = function() {
+      if (this.readable && this.needsReadable) {
+        this.needsReadable = false
+        this.emit('readable')
+      }
+    }
+
+    FrameProducer.prototype._readableListener = function() {
+      this.readable = true
+      this._maybeEmitReadable()
+    }
+
+    function createServer() {
+      log.info('Starting WebSocket server on port %d', screenOptions.publicPort)
+
+      var wss = new WebSocket.Server({
+        port: screenOptions.publicPort
+      , perMessageDeflate: false
+      })
+
+      var listeningListener, errorListener
+      return new Promise(function(resolve, reject) {
+          listeningListener = function() {
+            return resolve(wss)
+          }
+
+          errorListener = function(err) {
+            return reject(err)
+          }
+
+          wss.on('listening', listeningListener)
+          wss.on('error', errorListener)
+        })
+        .finally(function() {
+          wss.removeListener('listening', listeningListener)
+          wss.removeListener('error', errorListener)
+        })
+    }
+
+    return createServer()
+      .then(function(wss) {
+        var frameProducer = new FrameProducer(
+          new FrameConfig(display.properties, display.properties))
+        var broadcastSet = frameProducer.broadcastSet = new BroadcastSet()
+
+        broadcastSet.on('nonempty', function() {
+          frameProducer.start()
+        })
+
+        broadcastSet.on('empty', function() {
+          frameProducer.stop()
+        })
+
+        broadcastSet.on('insert', function(id) {
+          // If two clients join a session in the middle, one of them
+          // may not release the initial size because the projection
+          // doesn't necessarily change, and the producer doesn't Getting
+          // restarted. Therefore we have to call onStart() manually
+          // if the producer is already up and running.
+          switch (frameProducer.runningState) {
+          case FrameProducer.STATE_STARTED:
+            broadcastSet.get(id).onStart(frameProducer)
+            break
+          }
+        })
+
+        display.on('rotationChange', function(newRotation) {
+          frameProducer.updateRotation(newRotation)
+        })
+
+        frameProducer.on('start', function() {
+          broadcastSet.keys().map(function(id) {
+            return broadcastSet.get(id).onStart(frameProducer)
+          })
+        })
+
+        frameProducer.on('readable', function next() {
+          var frame = frameProducer.nextFrame()
+          if (frame) {
+            Promise.settle([broadcastSet.keys().map(function(id) {
+              return broadcastSet.get(id).onFrame(frame)
+            })]).then(next)
+          }
+          else {
+            frameProducer.needFrame()
+          }
+        })
+
+        frameProducer.on('error', function(err) {
+          log.fatal('Frame producer had an error', err.stack)
+          lifecycle.fatal()
+        })
+
+        wss.on('connection', function(ws) {
+          var id = uuid.v4()
+
+          function wsStartNotifier() {
+            return new Promise(function(resolve, reject) {
+              var message = util.format(
+                'start %s'
+              , JSON.stringify(frameProducer.banner)
+              )
+
+              switch (ws.readyState) {
+              case WebSocket.OPENING:
+                // This should never happen.
+                log.warn('Unable to send banner to OPENING client "%s"', id)
+                break
+              case WebSocket.OPEN:
+                // This is what SHOULD happen.
+                ws.send(message, function(err) {
+                  return err ? reject(err) : resolve()
+                })
+                break
+              case WebSocket.CLOSING:
+                // Ok, a 'close' event should remove the client from the set
+                // soon.
+                break
+              case WebSocket.CLOSED:
+                // This should never happen.
+                log.warn('Unable to send banner to CLOSED client "%s"', id)
+                broadcastSet.remove(id)
+                break
+              }
+            })
+          }
+
+          function wsFrameNotifier(frame) {
+            return new Promise(function(resolve, reject) {
+              switch (ws.readyState) {
+              case WebSocket.OPENING:
+                // This should never happen.
+                return reject(new Error(util.format(
+                  'Unable to send frame to OPENING client "%s"', id)))
+              case WebSocket.OPEN:
+                // This is what SHOULD happen.
+                ws.send(frame, {
+                  binary: true
+                }, function(err) {
+                  return err ? reject(err) : resolve()
+                })
+                return
+              case WebSocket.CLOSING:
+                // Ok, a 'close' event should remove the client from the set
+                // soon.
+                return
+              case WebSocket.CLOSED:
+                // This should never happen.
+                broadcastSet.remove(id)
+                return reject(new Error(util.format(
+                  'Unable to send frame to CLOSED client "%s"', id)))
+              }
+            })
+          }
+
+          ws.on('message', function(data) {
+            var match = /^(on|off|(size) ([0-9]+)x([0-9]+))$/.exec(data)
+            if (match) {
+              switch (match[2] || match[1]) {
+              case 'on':
+                broadcastSet.insert(id, {
+                  onStart: wsStartNotifier
+                , onFrame: wsFrameNotifier
+                })
+                break
+              case 'off':
+                broadcastSet.remove(id)
+                break
+              case 'size':
+                frameProducer.updateProjection(
+                  Number(match[3]), Number(match[4]))
+                break
+              }
+            }
+          })
+
+          ws.on('close', function() {
+            broadcastSet.remove(id)
+          })
+        })
+
+        lifecycle.observe(function() {
+          wss.close()
+        })
+
+        lifecycle.observe(function() {
+          frameProducer.stop()
+        })
+
+        return frameProducer
+      })
+  })
diff --git a/crowdstf/lib/units/device/plugins/screen/util/banner.js b/crowdstf/lib/units/device/plugins/screen/util/banner.js
new file mode 100644
index 0000000..d63cfc3
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/screen/util/banner.js
@@ -0,0 +1,114 @@
+var Promise = require('bluebird')
+
+module.exports.read = function parseBanner(out) {
+  var tryRead
+
+  return new Promise(function(resolve, reject) {
+    var readBannerBytes = 0
+    var needBannerBytes = 2
+
+    var banner = out.banner = {
+      version: 0
+    , length: 0
+    , pid: 0
+    , realWidth: 0
+    , realHeight: 0
+    , virtualWidth: 0
+    , virtualHeight: 0
+    , orientation: 0
+    , quirks: {
+        dumb: false
+      , alwaysUpright: false
+      , tear: false
+      }
+    }
+
+    tryRead = function() {
+      for (var chunk; (chunk = out.read(needBannerBytes - readBannerBytes));) {
+        for (var cursor = 0, len = chunk.length; cursor < len;) {
+          if (readBannerBytes < needBannerBytes) {
+            switch (readBannerBytes) {
+            case 0:
+              // version
+              banner.version = chunk[cursor]
+              break
+            case 1:
+              // length
+              banner.length = needBannerBytes = chunk[cursor]
+              break
+            case 2:
+            case 3:
+            case 4:
+            case 5:
+              // pid
+              banner.pid +=
+                (chunk[cursor] << ((readBannerBytes - 2) * 8)) >>> 0
+              break
+            case 6:
+            case 7:
+            case 8:
+            case 9:
+              // real width
+              banner.realWidth +=
+                (chunk[cursor] << ((readBannerBytes - 6) * 8)) >>> 0
+              break
+            case 10:
+            case 11:
+            case 12:
+            case 13:
+              // real height
+              banner.realHeight +=
+                (chunk[cursor] << ((readBannerBytes - 10) * 8)) >>> 0
+              break
+            case 14:
+            case 15:
+            case 16:
+            case 17:
+              // virtual width
+              banner.virtualWidth +=
+                (chunk[cursor] << ((readBannerBytes - 14) * 8)) >>> 0
+              break
+            case 18:
+            case 19:
+            case 20:
+            case 21:
+              // virtual height
+              banner.virtualHeight +=
+                (chunk[cursor] << ((readBannerBytes - 18) * 8)) >>> 0
+              break
+            case 22:
+              // orientation
+              banner.orientation += chunk[cursor] * 90
+              break
+            case 23:
+              // quirks
+              banner.quirks.dumb = (chunk[cursor] & 1) === 1
+              banner.quirks.alwaysUpright = (chunk[cursor] & 2) === 2
+              banner.quirks.tear = (chunk[cursor] & 4) === 4
+              break
+            }
+
+            cursor += 1
+            readBannerBytes += 1
+
+            if (readBannerBytes === needBannerBytes) {
+              return resolve(banner)
+            }
+          }
+          else {
+            reject(new Error(
+              'Supposedly impossible error parsing banner'
+            ))
+          }
+        }
+      }
+    }
+
+    tryRead()
+
+    out.on('readable', tryRead)
+  })
+  .finally(function() {
+    out.removeListener('readable', tryRead)
+  })
+}
diff --git a/crowdstf/lib/units/device/plugins/screen/util/broadcastset.js b/crowdstf/lib/units/device/plugins/screen/util/broadcastset.js
new file mode 100644
index 0000000..9b1a072
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/screen/util/broadcastset.js
@@ -0,0 +1,48 @@
+var util = require('util')
+
+var EventEmitter = require('eventemitter3').EventEmitter
+
+function BroadcastSet() {
+  this.set = Object.create(null)
+  this.count = 0
+}
+
+util.inherits(BroadcastSet, EventEmitter)
+
+BroadcastSet.prototype.insert = function(id, ws) {
+  if (!(id in this.set)) {
+    this.set[id] = ws
+    this.count += 1
+    this.emit('insert', id)
+    if (this.count === 1) {
+      this.emit('nonempty')
+    }
+  }
+}
+
+BroadcastSet.prototype.remove = function(id) {
+  if (id in this.set) {
+    delete this.set[id]
+    this.count -= 1
+    this.emit('remove', id)
+    if (this.count === 0) {
+      this.emit('empty')
+    }
+  }
+}
+
+BroadcastSet.prototype.values = function() {
+  return Object.keys(this.set).map(function(id) {
+    return this.set[id]
+  }, this)
+}
+
+BroadcastSet.prototype.keys = function() {
+  return Object.keys(this.set)
+}
+
+BroadcastSet.prototype.get = function(id) {
+  return this.set[id]
+}
+
+module.exports = BroadcastSet
diff --git a/crowdstf/lib/units/device/plugins/screen/util/frameconfig.js b/crowdstf/lib/units/device/plugins/screen/util/frameconfig.js
new file mode 100644
index 0000000..3a9d804
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/screen/util/frameconfig.js
@@ -0,0 +1,22 @@
+var util = require('util')
+
+function FrameConfig(real, virtual) {
+  this.realWidth = real.width
+  this.realHeight = real.height
+  this.virtualWidth = virtual.width
+  this.virtualHeight = virtual.height
+  this.rotation = virtual.rotation
+}
+
+FrameConfig.prototype.toString = function() {
+  return util.format(
+    '%dx%d@%dx%d/%d'
+  , this.realWidth
+  , this.realHeight
+  , this.virtualWidth
+  , this.virtualHeight
+  , this.rotation
+  )
+}
+
+module.exports = FrameConfig
diff --git a/crowdstf/lib/units/device/plugins/screen/util/frameparser.js b/crowdstf/lib/units/device/plugins/screen/util/frameparser.js
new file mode 100644
index 0000000..3f0de8c
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/screen/util/frameparser.js
@@ -0,0 +1,75 @@
+function FrameParser() {
+  this.readFrameBytes = 0
+  this.frameBodyLength = 0
+  this.frameBody = null
+  this.cursor = 0
+  this.chunk = null
+}
+
+FrameParser.prototype.push = function(chunk) {
+  if (this.chunk) {
+    throw new Error('Must consume pending frames before pushing more chunks')
+  }
+
+  this.chunk = chunk
+}
+
+FrameParser.prototype.nextFrame = function() {
+  if (!this.chunk) {
+    return null
+  }
+
+  for (var len = this.chunk.length; this.cursor < len;) {
+    if (this.readFrameBytes < 4) {
+      this.frameBodyLength +=
+        (this.chunk[this.cursor] << (this.readFrameBytes * 8)) >>> 0
+      this.cursor += 1
+      this.readFrameBytes += 1
+    }
+    else {
+      var bytesLeft = len - this.cursor
+
+      if (bytesLeft >= this.frameBodyLength) {
+        var completeBody
+        if (this.frameBody) {
+          completeBody = Buffer.concat([
+            this.frameBody
+            , this.chunk.slice(this.cursor, this.cursor + this.frameBodyLength)
+          ])
+        }
+        else {
+          completeBody = this.chunk.slice(this.cursor,
+            this.cursor + this.frameBodyLength)
+        }
+
+        this.cursor += this.frameBodyLength
+        this.frameBodyLength = this.readFrameBytes = 0
+        this.frameBody = null
+
+        return completeBody
+      }
+      else {
+        // @todo Consider/benchmark continuation frames to prevent
+        // potential Buffer thrashing.
+        if (this.frameBody) {
+          this.frameBody =
+            Buffer.concat([this.frameBody, this.chunk.slice(this.cursor, len)])
+        }
+        else {
+          this.frameBody = this.chunk.slice(this.cursor, len)
+        }
+
+        this.frameBodyLength -= bytesLeft
+        this.readFrameBytes += bytesLeft
+        this.cursor = len
+      }
+    }
+  }
+
+  this.cursor = 0
+  this.chunk = null
+
+  return null
+}
+
+module.exports = FrameParser
diff --git a/crowdstf/lib/units/device/plugins/sd.js b/crowdstf/lib/units/device/plugins/sd.js
new file mode 100644
index 0000000..755b96e
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/sd.js
@@ -0,0 +1,33 @@
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+
+module.exports = syrup.serial()
+  .dependency(require('./service'))
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .define(function(options, service, router, push) {
+    var log = logger.createLogger('device:plugins:sd')
+
+    router.on(wire.SdStatusMessage, function(channel, message) {
+      var reply = wireutil.reply(options.serial)
+      log.info('Getting SD card status')
+      service.getSdStatus(message)
+        .timeout(30000)
+        .then(function(mounted) {
+          push.send([
+            channel
+          , reply.okay(mounted ? 'sd_mounted' : 'sd_unmounted')
+          ])
+        })
+        .catch(function(err) {
+          log.error('Getting SD card Status', err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+  })
diff --git a/crowdstf/lib/units/device/plugins/service.js b/crowdstf/lib/units/device/plugins/service.js
new file mode 100644
index 0000000..dee0df2
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/service.js
@@ -0,0 +1,762 @@
+var util = require('util')
+var events = require('events')
+
+var syrup = require('stf-syrup')
+var Promise = require('bluebird')
+
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+var devutil = require('../../../util/devutil')
+var keyutil = require('../../../util/keyutil')
+var streamutil = require('../../../util/streamutil')
+var logger = require('../../../util/logger')
+var ms = require('../../../wire/messagestream')
+var lifecycle = require('../../../util/lifecycle')
+
+function MessageResolver() {
+  this.resolvers = Object.create(null)
+
+  this.await = function(id, resolver) {
+    this.resolvers[id] = resolver
+    return resolver.promise
+  }
+
+  this.resolve = function(id, value) {
+    var resolver = this.resolvers[id]
+    delete this.resolvers[id]
+    resolver.resolve(value)
+    return resolver.promise
+  }
+}
+
+module.exports = syrup.serial()
+  .dependency(require('../support/adb'))
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .dependency(require('../resources/service'))
+  .define(function(options, adb, router, push, apk) {
+    var log = logger.createLogger('device:plugins:service')
+    var messageResolver = new MessageResolver()
+    var plugin = new events.EventEmitter()
+
+    var agent = {
+      socket: null
+    , writer: null
+    , port: 1090
+    }
+
+    var service = {
+      socket: null
+    , writer: null
+    , reader: null
+    , port: 1100
+    }
+
+    function stopAgent() {
+      return devutil.killProcsByComm(
+        adb
+        , options.serial
+        , 'stf.agent'
+        , 'stf.agent'
+      )
+    }
+
+    function callService(intent) {
+      return adb.shell(options.serial, util.format(
+        'am startservice --user 0 %s'
+        , intent
+        ))
+        .timeout(15000)
+        .then(function(out) {
+          return streamutil.findLine(out, /^Error/)
+            .finally(function() {
+              out.end()
+            })
+            .timeout(10000)
+            .then(function(line) {
+              if (line.indexOf('--user') !== -1) {
+                return adb.shell(options.serial, util.format(
+                  'am startservice %s'
+                  , intent
+                  ))
+                  .timeout(15000)
+                  .then(function() {
+                    return streamutil.findLine(out, /^Error/)
+                      .finally(function() {
+                        out.end()
+                      })
+                      .timeout(10000)
+                      .then(function(line) {
+                        throw new Error(util.format(
+                          'Service had an error: "%s"'
+                          , line
+                        ))
+                      })
+                      .catch(streamutil.NoSuchLineError, function() {
+                        return true
+                      })
+                  })
+              }
+              else {
+                throw new Error(util.format(
+                  'Service had an error: "%s"'
+                  , line
+                ))
+              }
+            })
+            .catch(streamutil.NoSuchLineError, function() {
+              return true
+            })
+        })
+    }
+
+    function prepareForServiceDeath(conn) {
+      function endListener() {
+        var startTime = Date.now()
+        log.important('Service connection ended, attempting to relaunch')
+
+        /* eslint no-use-before-define: 0 */
+        openService()
+          .timeout(5000)
+          .then(function() {
+            log.important('Service relaunched in %dms', Date.now() - startTime)
+          })
+          .catch(function(err) {
+            log.fatal('Service connection could not be relaunched', err.stack)
+            lifecycle.fatal()
+          })
+      }
+
+      conn.once('end', endListener)
+
+      conn.on('error', function(err) {
+        log.fatal('Service connection had an error', err.stack)
+        lifecycle.fatal()
+      })
+    }
+
+    function handleEnvelope(data) {
+      var envelope = apk.wire.Envelope.decode(data)
+      var message
+      if (envelope.id !== null) {
+        messageResolver.resolve(envelope.id, envelope.message)
+      }
+      else {
+        switch (envelope.type) {
+          case apk.wire.MessageType.EVENT_AIRPLANE_MODE:
+            message = apk.wire.AirplaneModeEvent.decode(envelope.message)
+            push.send([
+              wireutil.global
+              , wireutil.envelope(new wire.AirplaneModeEvent(
+                options.serial
+                , message.enabled
+              ))
+            ])
+            plugin.emit('airplaneModeChange', message)
+            break
+          case apk.wire.MessageType.EVENT_BATTERY:
+            message = apk.wire.BatteryEvent.decode(envelope.message)
+            push.send([
+              wireutil.global
+              , wireutil.envelope(new wire.BatteryEvent(
+                options.serial
+                , message.status
+                , message.health
+                , message.source
+                , message.level
+                , message.scale
+                , message.temp
+                , message.voltage
+              ))
+            ])
+            plugin.emit('batteryChange', message)
+            break
+          case apk.wire.MessageType.EVENT_BROWSER_PACKAGE:
+            message = apk.wire.BrowserPackageEvent.decode(envelope.message)
+            plugin.emit('browserPackageChange', message)
+            break
+          case apk.wire.MessageType.EVENT_CONNECTIVITY:
+            message = apk.wire.ConnectivityEvent.decode(envelope.message)
+            push.send([
+              wireutil.global
+              , wireutil.envelope(new wire.ConnectivityEvent(
+                options.serial
+                , message.connected
+                , message.type
+                , message.subtype
+                , message.failover
+                , message.roaming
+              ))
+            ])
+            plugin.emit('connectivityChange', message)
+            break
+          case apk.wire.MessageType.EVENT_PHONE_STATE:
+            message = apk.wire.PhoneStateEvent.decode(envelope.message)
+            push.send([
+              wireutil.global
+              , wireutil.envelope(new wire.PhoneStateEvent(
+                options.serial
+                , message.state
+                , message.manual
+                , message.operator
+              ))
+            ])
+            plugin.emit('phoneStateChange', message)
+            break
+          case apk.wire.MessageType.EVENT_ROTATION:
+            message = apk.wire.RotationEvent.decode(envelope.message)
+            push.send([
+              wireutil.global
+              , wireutil.envelope(new wire.RotationEvent(
+                options.serial
+                , message.rotation
+              ))
+            ])
+            plugin.emit('rotationChange', message)
+            break
+        }
+      }
+    }
+
+    // The APK should be up to date at this point. If it was reinstalled, the
+    // service should have been automatically stopped while it was happening.
+    // So, we should be good to go.
+    function openService() {
+      log.info('Launching service')
+      return callService(util.format(
+        "-a '%s' -n '%s'"
+        , apk.startIntent.action
+        , apk.startIntent.component
+      ))
+        .then(function() {
+          return devutil.waitForPort(adb, options.serial, service.port)
+            .timeout(15000)
+        })
+        .then(function(conn) {
+          service.socket = conn
+          service.reader = conn.pipe(new ms.DelimitedStream())
+          service.reader.on('data', handleEnvelope)
+          service.writer = new ms.DelimitingStream()
+          service.writer.pipe(conn)
+          return prepareForServiceDeath(conn)
+        })
+    }
+
+    function prepareForAgentDeath(conn) {
+      function endListener() {
+        var startTime = Date.now()
+        log.important('Agent connection ended, attempting to relaunch')
+        openService()
+          .timeout(5000)
+          .then(function() {
+            log.important('Agent relaunched in %dms', Date.now() - startTime)
+          })
+          .catch(function(err) {
+            log.fatal('Agent connection could not be relaunched', err.stack)
+            lifecycle.fatal()
+          })
+      }
+
+      conn.once('end', endListener)
+
+      conn.on('error', function(err) {
+        log.fatal('Agent connection had an error', err.stack)
+        lifecycle.fatal()
+      })
+    }
+
+    function openAgent() {
+      log.info('Launching agent')
+      return stopAgent()
+        .timeout(15000)
+        .then(function() {
+          return devutil.ensureUnusedPort(adb, options.serial, agent.port)
+            .timeout(10000)
+        })
+        .then(function() {
+          return adb.shell(options.serial, util.format(
+              "export CLASSPATH='%s'; exec app_process /system/bin '%s'"
+            , apk.path
+            , apk.main
+            ))
+            .timeout(10000)
+        })
+        .then(function(out) {
+          streamutil.talk(log, 'Agent says: "%s"', out)
+        })
+        .then(function() {
+          return devutil.waitForPort(adb, options.serial, agent.port)
+            .timeout(10000)
+        })
+        .then(function(conn) {
+          agent.socket = conn
+          agent.writer = new ms.DelimitingStream()
+          agent.writer.pipe(conn)
+          return prepareForAgentDeath(conn)
+        })
+    }
+
+    function runAgentCommand(type, cmd) {
+      agent.writer.write(new apk.wire.Envelope(
+        null
+        , type
+        , cmd.encodeNB()
+      ).encodeNB())
+    }
+
+    function keyEvent(data) {
+      return runAgentCommand(
+        apk.wire.MessageType.DO_KEYEVENT
+      , new apk.wire.KeyEventRequest(data)
+      )
+    }
+
+    plugin.type = function(text) {
+      return runAgentCommand(
+        apk.wire.MessageType.DO_TYPE
+      , new apk.wire.DoTypeRequest(text)
+      )
+    }
+
+    plugin.paste = function(text) {
+      return plugin.setClipboard(text)
+        .delay(500) // Give it a little bit of time to settle.
+        .then(function() {
+          keyEvent({
+            event: apk.wire.KeyEvent.PRESS
+          , keyCode: adb.Keycode.KEYCODE_V
+          , ctrlKey: true
+          })
+        })
+    }
+
+    plugin.copy = function() {
+      // @TODO Not sure how to force the device to copy the current selection
+      // yet.
+      return plugin.getClipboard()
+    }
+
+    function runServiceCommand(type, cmd) {
+      var resolver = Promise.defer()
+      var id = Math.floor(Math.random() * 0xFFFFFF)
+      service.writer.write(new apk.wire.Envelope(
+        id
+        , type
+        , cmd.encodeNB()
+      ).encodeNB())
+      return messageResolver.await(id, resolver)
+    }
+
+    plugin.getDisplay = function(id) {
+      return runServiceCommand(
+          apk.wire.MessageType.GET_DISPLAY
+        , new apk.wire.GetDisplayRequest(id)
+        )
+        .timeout(10000)
+        .then(function(data) {
+          var response = apk.wire.GetDisplayResponse.decode(data)
+          if (response.success) {
+            return {
+              id: id
+            , width: response.width
+            , height: response.height
+            , xdpi: response.xdpi
+            , ydpi: response.ydpi
+            , fps: response.fps
+            , density: response.density
+            , rotation: response.rotation
+            , secure: response.secure
+            , size: Math.sqrt(
+                Math.pow(response.width / response.xdpi, 2) +
+                Math.pow(response.height / response.ydpi, 2)
+              )
+            }
+          }
+          throw new Error('Unable to retrieve display information')
+        })
+    }
+
+    plugin.wake = function() {
+      return runAgentCommand(
+        apk.wire.MessageType.DO_WAKE
+      , new apk.wire.DoWakeRequest()
+      )
+    }
+
+    plugin.rotate = function(rotation) {
+      return runAgentCommand(
+        apk.wire.MessageType.SET_ROTATION
+      , new apk.wire.SetRotationRequest(rotation, options.lockRotation || false)
+      )
+    }
+
+    plugin.freezeRotation = function(rotation) {
+      return runAgentCommand(
+        apk.wire.MessageType.SET_ROTATION
+      , new apk.wire.SetRotationRequest(rotation, true)
+      )
+    }
+
+    plugin.thawRotation = function() {
+      return runAgentCommand(
+        apk.wire.MessageType.SET_ROTATION
+      , new apk.wire.SetRotationRequest(0, false)
+      )
+    }
+
+    plugin.version = function() {
+      return runServiceCommand(
+          apk.wire.MessageType.GET_VERSION
+        , new apk.wire.GetVersionRequest()
+        )
+        .timeout(10000)
+        .then(function(data) {
+          var response = apk.wire.GetVersionResponse.decode(data)
+          if (response.success) {
+            return response.version
+          }
+          throw new Error('Unable to retrieve version')
+        })
+    }
+
+    plugin.unlock = function() {
+      return runServiceCommand(
+          apk.wire.MessageType.SET_KEYGUARD_STATE
+        , new apk.wire.SetKeyguardStateRequest(false)
+        )
+        .timeout(10000)
+        .then(function(data) {
+          var response = apk.wire.SetKeyguardStateResponse.decode(data)
+          if (!response.success) {
+            throw new Error('Unable to unlock device')
+          }
+        })
+    }
+
+    plugin.lock = function() {
+      return runServiceCommand(
+          apk.wire.MessageType.SET_KEYGUARD_STATE
+        , new apk.wire.SetKeyguardStateRequest(true)
+        )
+        .timeout(10000)
+        .then(function(data) {
+          var response = apk.wire.SetKeyguardStateResponse.decode(data)
+          if (!response.success) {
+            throw new Error('Unable to lock device')
+          }
+        })
+    }
+
+    plugin.acquireWakeLock = function() {
+      return runServiceCommand(
+          apk.wire.MessageType.SET_WAKE_LOCK
+        , new apk.wire.SetWakeLockRequest(true)
+        )
+        .timeout(10000)
+        .then(function(data) {
+          var response = apk.wire.SetWakeLockResponse.decode(data)
+          if (!response.success) {
+            throw new Error('Unable to acquire WakeLock')
+          }
+        })
+    }
+
+    plugin.releaseWakeLock = function() {
+      return runServiceCommand(
+          apk.wire.MessageType.SET_WAKE_LOCK
+        , new apk.wire.SetWakeLockRequest(false)
+        )
+        .timeout(10000)
+        .then(function(data) {
+          var response = apk.wire.SetWakeLockResponse.decode(data)
+          if (!response.success) {
+            throw new Error('Unable to release WakeLock')
+          }
+        })
+    }
+
+    plugin.identity = function() {
+      return runServiceCommand(
+          apk.wire.MessageType.DO_IDENTIFY
+        , new apk.wire.DoIdentifyRequest(options.serial)
+        )
+        .timeout(10000)
+        .then(function(data) {
+          var response = apk.wire.DoIdentifyResponse.decode(data)
+          if (!response.success) {
+            throw new Error('Unable to identify device')
+          }
+        })
+    }
+
+    plugin.setClipboard = function(text) {
+      return runServiceCommand(
+          apk.wire.MessageType.SET_CLIPBOARD
+        , new apk.wire.SetClipboardRequest(
+            apk.wire.ClipboardType.TEXT
+          , text
+          )
+        )
+        .timeout(10000)
+        .then(function(data) {
+          var response = apk.wire.SetClipboardResponse.decode(data)
+          if (!response.success) {
+            throw new Error('Unable to set clipboard')
+          }
+        })
+    }
+
+    plugin.getClipboard = function() {
+      return runServiceCommand(
+          apk.wire.MessageType.GET_CLIPBOARD
+        , new apk.wire.GetClipboardRequest(
+            apk.wire.ClipboardType.TEXT
+          )
+        )
+        .timeout(10000)
+        .then(function(data) {
+          var response = apk.wire.GetClipboardResponse.decode(data)
+          if (response.success) {
+            switch (response.type) {
+              case apk.wire.ClipboardType.TEXT:
+                return response.text
+            }
+          }
+          throw new Error('Unable to get clipboard')
+        })
+    }
+
+    plugin.getBrowsers = function() {
+      return runServiceCommand(
+          apk.wire.MessageType.GET_BROWSERS
+        , new apk.wire.GetBrowsersRequest()
+        )
+        .timeout(15000)
+        .then(function(data) {
+          var response = apk.wire.GetBrowsersResponse.decode(data)
+          if (response.success) {
+            delete response.success
+            return response
+          }
+          throw new Error('Unable to get browser list')
+        })
+    }
+
+    plugin.getProperties = function(properties) {
+      return runServiceCommand(
+          apk.wire.MessageType.GET_PROPERTIES
+        , new apk.wire.GetPropertiesRequest(properties)
+        )
+        .timeout(15000)
+        .then(function(data) {
+          var response = apk.wire.GetPropertiesResponse.decode(data)
+          if (response.success) {
+            var mapped = Object.create(null)
+            response.properties.forEach(function(property) {
+              mapped[property.name] = property.value
+            })
+            return mapped
+          }
+          throw new Error('Unable to get properties')
+        })
+    }
+
+    plugin.getAccounts = function(data) {
+      return runServiceCommand(
+        apk.wire.MessageType.GET_ACCOUNTS
+      , new apk.wire.GetAccountsRequest({type: data.type})
+      )
+      .timeout(15000)
+      .then(function(data) {
+        var response = apk.wire.GetAccountsResponse.decode(data)
+        if (response.success) {
+          return response.accounts
+        }
+        throw new Error('No accounts returned')
+      })
+    }
+
+    plugin.removeAccount = function(data) {
+      return runServiceCommand(
+        apk.wire.MessageType.DO_REMOVE_ACCOUNT
+      , new apk.wire.DoRemoveAccountRequest({
+          type: data.type
+        , account: data.account
+        })
+      )
+      .timeout(15000)
+      .then(function(data) {
+        var response = apk.wire.DoRemoveAccountResponse.decode(data)
+        if (response.success) {
+          return true
+        }
+        throw new Error('Unable to remove account')
+      })
+    }
+
+    plugin.addAccountMenu = function() {
+      return runServiceCommand(
+        apk.wire.MessageType.DO_ADD_ACCOUNT_MENU
+      , new apk.wire.DoAddAccountMenuRequest()
+      )
+      .timeout(15000)
+      .then(function(data) {
+        var response = apk.wire.DoAddAccountMenuResponse.decode(data)
+        if (response.success) {
+          return true
+        }
+        throw new Error('Unable to show add account menu')
+      })
+    }
+
+    plugin.setRingerMode = function(mode) {
+      return runServiceCommand(
+          apk.wire.MessageType.SET_RINGER_MODE
+        , new apk.wire.SetRingerModeRequest(mode)
+        )
+        .timeout(10000)
+        .then(function(data) {
+          var response = apk.wire.SetRingerModeResponse.decode(data)
+          if (!response.success) {
+            throw new Error('Unable to set ringer mode')
+          }
+        })
+    }
+
+    plugin.getRingerMode = function() {
+      return runServiceCommand(
+          apk.wire.MessageType.GET_RINGER_MODE
+        , new apk.wire.GetRingerModeRequest()
+        )
+        .timeout(10000)
+        .then(function(data) {
+          var response = apk.wire.GetRingerModeResponse.decode(data)
+          // Reflection to decode enums to their string values, otherwise
+          // we only get an integer
+          var ringerMode = apk.builder.lookup(
+            'jp.co.cyberagent.stf.proto.RingerMode')
+            .children[response.mode].name
+          if (response.success) {
+            return ringerMode
+          }
+          throw new Error('Unable to get ringer mode')
+        })
+    }
+
+    plugin.setWifiEnabled = function(enabled) {
+      return runServiceCommand(
+          apk.wire.MessageType.SET_WIFI_ENABLED
+        , new apk.wire.SetWifiEnabledRequest(enabled)
+        )
+        .timeout(10000)
+        .then(function(data) {
+          var response = apk.wire.SetWifiEnabledResponse.decode(data)
+          if (!response.success) {
+            throw new Error('Unable to set Wifi')
+          }
+        })
+    }
+
+    plugin.getWifiStatus = function() {
+      return runServiceCommand(
+        apk.wire.MessageType.GET_WIFI_STATUS
+      , new apk.wire.GetWifiStatusRequest()
+      )
+      .timeout(10000)
+      .then(function(data) {
+        var response = apk.wire.GetWifiStatusResponse.decode(data)
+        if (response.success) {
+          return response.status
+        }
+        throw new Error('Unable to get Wifi status')
+      })
+    }
+
+    plugin.getSdStatus = function() {
+      return runServiceCommand(
+        apk.wire.MessageType.GET_SD_STATUS
+      , new apk.wire.GetSdStatusRequest()
+      )
+      .timeout(10000)
+      .then(function(data) {
+        var response = apk.wire.GetSdStatusResponse.decode(data)
+        if (response.success) {
+          return response.mounted
+        }
+        throw new Error('Unable to get SD card status')
+      })
+    }
+
+    plugin.pressKey = function(key) {
+      keyEvent({event: apk.wire.KeyEvent.PRESS, keyCode: keyutil.namedKey(key)})
+      return Promise.resolve(true)
+    }
+
+    plugin.setMasterMute = function(mode) {
+      return runServiceCommand(
+          apk.wire.MessageType.SET_MASTER_MUTE
+        , new apk.wire.SetMasterMuteRequest(mode)
+        )
+        .timeout(10000)
+        .then(function(data) {
+          var response = apk.wire.SetMasterMuteResponse.decode(data)
+          if (!response.success) {
+            throw new Error('Unable to set master mute')
+          }
+        })
+    }
+
+    return openAgent()
+      .then(openService)
+      .then(function() {
+        router
+          .on(wire.PhysicalIdentifyMessage, function(channel) {
+            var reply = wireutil.reply(options.serial)
+            plugin.identity()
+            push.send([
+              channel
+            , reply.okay()
+            ])
+          })
+          .on(wire.KeyDownMessage, function(channel, message) {
+            try {
+              keyEvent({
+                event: apk.wire.KeyEvent.DOWN
+              , keyCode: keyutil.namedKey(message.key)
+              })
+            }
+            catch (e) {
+              log.warn(e.message)
+            }
+          })
+          .on(wire.KeyUpMessage, function(channel, message) {
+            try {
+              keyEvent({
+                event: apk.wire.KeyEvent.UP
+                , keyCode: keyutil.namedKey(message.key)
+              })
+            }
+            catch (e) {
+              log.warn(e.message)
+            }
+          })
+          .on(wire.KeyPressMessage, function(channel, message) {
+            try {
+              keyEvent({
+                event: apk.wire.KeyEvent.PRESS
+                , keyCode: keyutil.namedKey(message.key)
+              })
+            }
+            catch (e) {
+              log.warn(e.message)
+            }
+          })
+          .on(wire.TypeMessage, function(channel, message) {
+            plugin.type(message.text)
+          })
+          .on(wire.RotateMessage, function(channel, message) {
+            plugin.rotate(message.rotation)
+          })
+      })
+      .return(plugin)
+  })
diff --git a/crowdstf/lib/units/device/plugins/shell.js b/crowdstf/lib/units/device/plugins/shell.js
new file mode 100644
index 0000000..93493d0
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/shell.js
@@ -0,0 +1,86 @@
+var Promise = require('bluebird')
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/adb'))
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .dependency(require('../support/sub'))
+  .define(function(options, adb, router, push, sub) {
+    var log = logger.createLogger('device:plugins:shell')
+
+    router.on(wire.ShellCommandMessage, function(channel, message) {
+      var reply = wireutil.reply(options.serial)
+
+      log.info('Running shell command "%s"', message.command)
+
+      adb.shell(options.serial, message.command)
+        .timeout(10000)
+        .then(function(stream) {
+          var resolver = Promise.defer()
+          var timer
+
+          function forceStop() {
+            stream.end()
+          }
+
+          function keepAliveListener(channel, message) {
+            clearTimeout(timer)
+            timer = setTimeout(forceStop, message.timeout)
+          }
+
+          function readableListener() {
+            var chunk
+            while ((chunk = stream.read())) {
+              push.send([
+                channel
+              , reply.progress(chunk)
+              ])
+            }
+          }
+
+          function endListener() {
+            push.send([
+              channel
+            , reply.okay(null)
+            ])
+            resolver.resolve()
+          }
+
+          function errorListener(err) {
+            resolver.reject(err)
+          }
+
+          stream.setEncoding('utf8')
+
+          stream.on('readable', readableListener)
+          stream.on('end', endListener)
+          stream.on('error', errorListener)
+
+          sub.subscribe(channel)
+          router.on(wire.ShellKeepAliveMessage, keepAliveListener)
+
+          timer = setTimeout(forceStop, message.timeout)
+
+          return resolver.promise.finally(function() {
+            stream.removeListener('readable', readableListener)
+            stream.removeListener('end', endListener)
+            stream.removeListener('error', errorListener)
+            sub.unsubscribe(channel)
+            router.removeListener(wire.ShellKeepAliveMessage, keepAliveListener)
+            clearTimeout(timer)
+          })
+        })
+        .error(function(err) {
+          log.error('Shell command "%s" failed', message.command, err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+  })
diff --git a/crowdstf/lib/units/device/plugins/solo.js b/crowdstf/lib/units/device/plugins/solo.js
new file mode 100644
index 0000000..0451c4f
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/solo.js
@@ -0,0 +1,61 @@
+var crypto = require('crypto')
+
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/sub'))
+  .dependency(require('../support/push'))
+  .dependency(require('../support/router'))
+  .dependency(require('./util/identity'))
+  .define(function(options, sub, push, router, identity) {
+    var log = logger.createLogger('device:plugins:solo')
+
+    // The channel should keep the same value between restarts, so that
+    // having the client side up to date all the time is not horribly painful.
+    function makeChannelId() {
+      var hash = crypto.createHash('sha1')
+      hash.update(options.serial)
+      return hash.digest('base64')
+    }
+
+    var channel = makeChannelId()
+
+    log.info('Subscribing to permanent channel "%s"', channel)
+    sub.subscribe(channel)
+
+    router.on(wire.ProbeMessage, function() {
+      push.send([
+        wireutil.global
+      , wireutil.envelope(new wire.DeviceIdentityMessage(
+          options.serial
+        , identity.platform
+        , identity.manufacturer
+        , identity.operator
+        , identity.model
+        , identity.version
+        , identity.abi
+        , identity.sdk
+        , new wire.DeviceDisplayMessage(identity.display)
+        , new wire.DevicePhoneMessage(identity.phone)
+        , identity.product
+        ))
+      ])
+    })
+
+    return {
+      channel: channel
+    , poke: function() {
+        push.send([
+          wireutil.global
+        , wireutil.envelope(new wire.DeviceReadyMessage(
+            options.serial
+          , channel
+          ))
+        ])
+      }
+    }
+  })
diff --git a/crowdstf/lib/units/device/plugins/store.js b/crowdstf/lib/units/device/plugins/store.js
new file mode 100644
index 0000000..b81fb21
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/store.js
@@ -0,0 +1,40 @@
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .dependency(require('../support/adb'))
+  .define(function(options, router, push, adb) {
+    var log = logger.createLogger('device:plugins:store')
+
+    router.on(wire.StoreOpenMessage, function(channel) {
+      log.info('Opening Play Store')
+
+      var reply = wireutil.reply(options.serial)
+      adb.startActivity(options.serial, {
+          action: 'android.intent.action.MAIN'
+        , component: 'com.android.vending/.AssetBrowserActivity'
+          // FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
+          // FLAG_ACTIVITY_BROUGHT_TO_FRONT
+          // FLAG_ACTIVITY_NEW_TASK
+        , flags: 0x10600000
+        })
+        .then(function() {
+          push.send([
+            channel
+          , reply.okay()
+          ])
+        })
+        .catch(function(err) {
+          log.error('Play Store could not be opened', err.stack)
+          push.send([
+            channel
+          , reply.fail()
+          ])
+        })
+    })
+  })
diff --git a/crowdstf/lib/units/device/plugins/touch/index.js b/crowdstf/lib/units/device/plugins/touch/index.js
new file mode 100644
index 0000000..043323a
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/touch/index.js
@@ -0,0 +1,572 @@
+var util = require('util')
+
+var Promise = require('bluebird')
+var syrup = require('stf-syrup')
+var split = require('split')
+var EventEmitter = require('eventemitter3').EventEmitter
+var adbkit = require('adbkit')
+var Parser = require('adbkit/lib/adb/parser')
+
+var wire = require('../../../../wire')
+var logger = require('../../../../util/logger')
+var lifecycle = require('../../../../util/lifecycle')
+var SeqQueue = require('../../../../wire/seqqueue')
+var StateQueue = require('../../../../util/statequeue')
+var RiskyStream = require('../../../../util/riskystream')
+var FailCounter = require('../../../../util/failcounter')
+
+module.exports = syrup.serial()
+  .dependency(require('../../support/adb'))
+  .dependency(require('../../support/router'))
+  .dependency(require('../../resources/minitouch'))
+  .dependency(require('../util/flags'))
+  .define(function(options, adb, router, minitouch, flags) {
+    var log = logger.createLogger('device:plugins:touch')
+
+    function TouchConsumer(config) {
+      EventEmitter.call(this)
+      this.actionQueue = []
+      this.runningState = TouchConsumer.STATE_STOPPED
+      this.desiredState = new StateQueue()
+      this.output = null
+      this.socket = null
+      this.banner = null
+      this.touchConfig = config
+      this.starter = Promise.resolve(true)
+      this.failCounter = new FailCounter(3, 10000)
+      this.failCounter.on('exceedLimit', this._failLimitExceeded.bind(this))
+      this.failed = false
+      this.readableListener = this._readableListener.bind(this)
+      this.writeQueue = []
+    }
+
+    util.inherits(TouchConsumer, EventEmitter)
+
+    TouchConsumer.STATE_STOPPED = 1
+    TouchConsumer.STATE_STARTING = 2
+    TouchConsumer.STATE_STARTED = 3
+    TouchConsumer.STATE_STOPPING = 4
+
+    TouchConsumer.prototype._queueWrite = function(writer) {
+      switch (this.runningState) {
+      case TouchConsumer.STATE_STARTED:
+        writer.call(this)
+        break
+      default:
+        this.writeQueue.push(writer)
+        break
+      }
+    }
+
+    TouchConsumer.prototype.touchDown = function(point) {
+      this._queueWrite(function() {
+        return this._write(util.format(
+          'd %s %s %s %s\n'
+        , point.contact
+        , Math.floor(this.touchConfig.origin.x(point) * this.banner.maxX)
+        , Math.floor(this.touchConfig.origin.y(point) * this.banner.maxY)
+        , Math.floor((point.pressure || 0.5) * this.banner.maxPressure)
+        ))
+      })
+    }
+
+    TouchConsumer.prototype.touchMove = function(point) {
+      this._queueWrite(function() {
+        return this._write(util.format(
+          'm %s %s %s %s\n'
+        , point.contact
+        , Math.floor(this.touchConfig.origin.x(point) * this.banner.maxX)
+        , Math.floor(this.touchConfig.origin.y(point) * this.banner.maxY)
+        , Math.floor((point.pressure || 0.5) * this.banner.maxPressure)
+        ))
+      })
+    }
+
+    TouchConsumer.prototype.touchUp = function(point) {
+      this._queueWrite(function() {
+        return this._write(util.format(
+          'u %s\n'
+        , point.contact
+        ))
+      })
+    }
+
+    TouchConsumer.prototype.touchCommit = function() {
+      this._queueWrite(function() {
+        return this._write('c\n')
+      })
+    }
+
+    TouchConsumer.prototype.touchReset = function() {
+      this._queueWrite(function() {
+        return this._write('r\n')
+      })
+    }
+
+    TouchConsumer.prototype.tap = function(point) {
+      this.touchDown(point)
+      this.touchCommit()
+      this.touchUp(point)
+      this.touchCommit()
+    }
+
+    TouchConsumer.prototype._ensureState = function() {
+      if (this.desiredState.empty()) {
+        return
+      }
+
+      if (this.failed) {
+        log.warn('Will not apply desired state due to too many failures')
+        return
+      }
+
+      switch (this.runningState) {
+      case TouchConsumer.STATE_STARTING:
+      case TouchConsumer.STATE_STOPPING:
+        // Just wait.
+        break
+      case TouchConsumer.STATE_STOPPED:
+        if (this.desiredState.next() === TouchConsumer.STATE_STARTED) {
+          this.runningState = TouchConsumer.STATE_STARTING
+          this.starter = this._startService().bind(this)
+            .then(function(out) {
+              this.output = new RiskyStream(out)
+                .on('unexpectedEnd', this._outputEnded.bind(this))
+              return this._readOutput(this.output.stream)
+            })
+            .then(function() {
+              return this._connectService()
+            })
+            .then(function(socket) {
+              this.socket = new RiskyStream(socket)
+                .on('unexpectedEnd', this._socketEnded.bind(this))
+              return this._readBanner(this.socket.stream)
+            })
+            .then(function(banner) {
+              this.banner = banner
+              return this._readUnexpected(this.socket.stream)
+            })
+            .then(function() {
+              this._processWriteQueue()
+            })
+            .then(function() {
+              this.runningState = TouchConsumer.STATE_STARTED
+              this.emit('start')
+            })
+            .catch(Promise.CancellationError, function() {
+              return this._stop()
+            })
+            .catch(function(err) {
+              return this._stop().finally(function() {
+                this.failCounter.inc()
+                this.emit('error', err)
+              })
+            })
+            .finally(function() {
+              this._ensureState()
+            })
+        }
+        else {
+          setImmediate(this._ensureState.bind(this))
+        }
+        break
+      case TouchConsumer.STATE_STARTED:
+        if (this.desiredState.next() === TouchConsumer.STATE_STOPPED) {
+          this.runningState = TouchConsumer.STATE_STOPPING
+          this._stop().finally(function() {
+            this._ensureState()
+          })
+        }
+        else {
+          setImmediate(this._ensureState.bind(this))
+        }
+        break
+      }
+    }
+
+    TouchConsumer.prototype.start = function() {
+      log.info('Requesting touch consumer to start')
+      this.desiredState.push(TouchConsumer.STATE_STARTED)
+      this._ensureState()
+    }
+
+    TouchConsumer.prototype.stop = function() {
+      log.info('Requesting touch consumer to stop')
+      this.desiredState.push(TouchConsumer.STATE_STOPPED)
+      this._ensureState()
+    }
+
+    TouchConsumer.prototype.restart = function() {
+      switch (this.runningState) {
+      case TouchConsumer.STATE_STARTED:
+      case TouchConsumer.STATE_STARTING:
+        this.starter.cancel()
+        this.desiredState.push(TouchConsumer.STATE_STOPPED)
+        this.desiredState.push(TouchConsumer.STATE_STARTED)
+        this._ensureState()
+        break
+      }
+    }
+
+    TouchConsumer.prototype._configChanged = function() {
+      this.restart()
+    }
+
+    TouchConsumer.prototype._socketEnded = function() {
+      log.warn('Connection to minitouch ended unexpectedly')
+      this.failCounter.inc()
+      this.restart()
+    }
+
+    TouchConsumer.prototype._outputEnded = function() {
+      log.warn('Shell keeping minitouch running ended unexpectedly')
+      this.failCounter.inc()
+      this.restart()
+    }
+
+    TouchConsumer.prototype._failLimitExceeded = function(limit, time) {
+      this._stop()
+      this.failed = true
+      this.emit('error', new Error(util.format(
+        'Failed more than %d times in %dms'
+      , limit
+      , time
+      )))
+    }
+
+    TouchConsumer.prototype._startService = function() {
+      log.info('Launching screen service')
+      return minitouch.run()
+        .timeout(10000)
+    }
+
+    TouchConsumer.prototype._readOutput = function(out) {
+      out.pipe(split()).on('data', function(line) {
+        var trimmed = line.toString().trim()
+
+        if (trimmed === '') {
+          return
+        }
+
+        if (/ERROR/.test(line)) {
+          log.fatal('minitouch error: "%s"', line)
+          return lifecycle.fatal()
+        }
+
+        log.info('minitouch says: "%s"', line)
+      })
+    }
+
+    TouchConsumer.prototype._connectService = function() {
+      function tryConnect(times, delay) {
+        return adb.openLocal(options.serial, 'localabstract:minitouch')
+          .timeout(10000)
+          .then(function(out) {
+            return out
+          })
+          .catch(function(err) {
+            if (/closed/.test(err.message) && times > 1) {
+              return Promise.delay(delay)
+                .then(function() {
+                  return tryConnect(times - 1, delay * 2)
+                })
+            }
+            return Promise.reject(err)
+          })
+      }
+      log.info('Connecting to minitouch service')
+      // SH-03G can be very slow to start sometimes. Make sure we try long
+      // enough.
+      return tryConnect(7, 100)
+    }
+
+    TouchConsumer.prototype._stop = function() {
+      return this._disconnectService(this.socket).bind(this)
+        .timeout(2000)
+        .then(function() {
+          return this._stopService(this.output).timeout(10000)
+        })
+        .then(function() {
+          this.runningState = TouchConsumer.STATE_STOPPED
+          this.emit('stop')
+        })
+        .catch(function(err) {
+          // In practice we _should_ never get here due to _stopService()
+          // being quite aggressive. But if we do, well... assume it
+          // stopped anyway for now.
+          this.runningState = TouchConsumer.STATE_STOPPED
+          this.emit('error', err)
+          this.emit('stop')
+        })
+        .finally(function() {
+          this.output = null
+          this.socket = null
+          this.banner = null
+        })
+    }
+
+    TouchConsumer.prototype._disconnectService = function(socket) {
+      log.info('Disconnecting from minitouch service')
+
+      if (!socket || socket.ended) {
+        return Promise.resolve(true)
+      }
+
+      socket.stream.removeListener('readable', this.readableListener)
+
+      var endListener
+      return new Promise(function(resolve) {
+          socket.on('end', endListener = function() {
+            resolve(true)
+          })
+
+          socket.stream.resume()
+          socket.end()
+        })
+        .finally(function() {
+          socket.removeListener('end', endListener)
+        })
+    }
+
+    TouchConsumer.prototype._stopService = function(output) {
+      log.info('Stopping minitouch service')
+
+      if (!output || output.ended) {
+        return Promise.resolve(true)
+      }
+
+      var pid = this.banner ? this.banner.pid : -1
+
+      function kill(signal) {
+        if (pid <= 0) {
+          return Promise.reject(new Error('Minitouch service pid is unknown'))
+        }
+
+        var signum = {
+          SIGTERM: -15
+        , SIGKILL: -9
+        }[signal]
+
+        log.info('Sending %s to minitouch', signal)
+        return Promise.all([
+            output.waitForEnd()
+          , adb.shell(options.serial, ['kill', signum, pid])
+              .then(adbkit.util.readAll)
+              .return(true)
+          ])
+          .timeout(2000)
+      }
+
+      function kindKill() {
+        return kill('SIGTERM')
+      }
+
+      function forceKill() {
+        return kill('SIGKILL')
+      }
+
+      function forceEnd() {
+        log.info('Ending minitouch I/O as a last resort')
+        output.end()
+        return Promise.resolve(true)
+      }
+
+      return kindKill()
+        .catch(Promise.TimeoutError, forceKill)
+        .catch(forceEnd)
+    }
+
+    TouchConsumer.prototype._readBanner = function(socket) {
+      log.info('Reading minitouch banner')
+
+      var parser = new Parser(socket)
+      var banner = {
+        pid: -1 // @todo
+      , version: 0
+      , maxContacts: 0
+      , maxX: 0
+      , maxY: 0
+      , maxPressure: 0
+      }
+
+      function readVersion() {
+        return parser.readLine()
+          .then(function(chunk) {
+            var args = chunk.toString().split(/ /g)
+            switch (args[0]) {
+              case 'v':
+                banner.version = Number(args[1])
+                break
+              default:
+                throw new Error(util.format(
+                  'Unexpected output "%s", expecting version line'
+                , chunk
+                ))
+            }
+          })
+      }
+
+      function readLimits() {
+        return parser.readLine()
+          .then(function(chunk) {
+            var args = chunk.toString().split(/ /g)
+            switch (args[0]) {
+              case '^':
+                banner.maxContacts = args[1]
+                banner.maxX = args[2]
+                banner.maxY = args[3]
+                banner.maxPressure = args[4]
+                break
+              default:
+                throw new Error(util.format(
+                  'Unknown output "%s", expecting limits line'
+                , chunk
+                ))
+            }
+          })
+      }
+
+      function readPid() {
+        return parser.readLine()
+          .then(function(chunk) {
+            var args = chunk.toString().split(/ /g)
+            switch (args[0]) {
+              case '$':
+                banner.pid = Number(args[1])
+                break
+              default:
+                throw new Error(util.format(
+                  'Unexpected output "%s", expecting pid line'
+                , chunk
+                ))
+            }
+          })
+      }
+
+      return readVersion()
+        .then(readLimits)
+        .then(readPid)
+        .return(banner)
+        .timeout(2000)
+    }
+
+    TouchConsumer.prototype._readUnexpected = function(socket) {
+      socket.on('readable', this.readableListener)
+
+      // We may already have data pending.
+      this.readableListener()
+    }
+
+    TouchConsumer.prototype._readableListener = function() {
+      var chunk
+
+      while ((chunk = this.socket.stream.read())) {
+        log.warn('Unexpected output from minitouch socket', chunk)
+      }
+    }
+
+    TouchConsumer.prototype._processWriteQueue = function() {
+      for (var i = 0, l = this.writeQueue.length; i < l; ++i) {
+        this.writeQueue[i].call(this)
+      }
+
+      this.writeQueue = []
+    }
+
+    TouchConsumer.prototype._write = function(chunk) {
+      this.socket.stream.write(chunk)
+    }
+
+    function startConsumer() {
+      var touchConsumer = new TouchConsumer({
+        // Usually the touch origin is the same as the display's origin,
+        // but sometimes it might not be.
+        origin: (function(origin) {
+          log.info('Touch origin is %s', origin)
+          return {
+            'top left': {
+              x: function(point) {
+                return point.x
+              }
+            , y: function(point) {
+                return point.y
+              }
+            }
+            // So far the only device we've seen exhibiting this behavior
+            // is Yoga Tablet 8.
+          , 'bottom left': {
+              x: function(point) {
+                return 1 - point.y
+              }
+            , y: function(point) {
+                return point.x
+              }
+            }
+          }[origin]
+        })(flags.get('forceTouchOrigin', 'top left'))
+      })
+
+      var startListener, errorListener
+
+      return new Promise(function(resolve, reject) {
+        touchConsumer.on('start', startListener = function() {
+          resolve(touchConsumer)
+        })
+
+        touchConsumer.on('error', errorListener = reject)
+
+        touchConsumer.start()
+      })
+      .finally(function() {
+        touchConsumer.removeListener('start', startListener)
+        touchConsumer.removeListener('error', errorListener)
+      })
+    }
+
+    return startConsumer()
+      .then(function(touchConsumer) {
+        var queue = new SeqQueue(100, 4)
+
+        touchConsumer.on('error', function(err) {
+          log.fatal('Touch consumer had an error', err.stack)
+          lifecycle.fatal()
+        })
+
+        router
+          .on(wire.GestureStartMessage, function(channel, message) {
+            queue.start(message.seq)
+          })
+          .on(wire.GestureStopMessage, function(channel, message) {
+            queue.push(message.seq, function() {
+              queue.stop()
+            })
+          })
+          .on(wire.TouchDownMessage, function(channel, message) {
+            queue.push(message.seq, function() {
+              touchConsumer.touchDown(message)
+            })
+          })
+          .on(wire.TouchMoveMessage, function(channel, message) {
+            queue.push(message.seq, function() {
+              touchConsumer.touchMove(message)
+            })
+          })
+          .on(wire.TouchUpMessage, function(channel, message) {
+            queue.push(message.seq, function() {
+              touchConsumer.touchUp(message)
+            })
+          })
+          .on(wire.TouchCommitMessage, function(channel, message) {
+            queue.push(message.seq, function() {
+              touchConsumer.touchCommit()
+            })
+          })
+          .on(wire.TouchResetMessage, function(channel, message) {
+            queue.push(message.seq, function() {
+              touchConsumer.touchReset()
+            })
+          })
+
+        return touchConsumer
+      })
+  })
diff --git a/crowdstf/lib/units/device/plugins/util/data.js b/crowdstf/lib/units/device/plugins/util/data.js
new file mode 100644
index 0000000..a0aa6a6
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/util/data.js
@@ -0,0 +1,20 @@
+var syrup = require('stf-syrup')
+var deviceData = require('stf-device-db')
+
+var logger = require('../../../../util/logger')
+
+module.exports = syrup.serial()
+  .dependency(require('./identity'))
+  .define(function(options, identity) {
+    var log = logger.createLogger('device:plugins:data')
+
+    function find() {
+      var data = deviceData.find(identity)
+      if (!data) {
+        log.warn('Unable to find device data', identity)
+      }
+      return data
+    }
+
+    return find()
+  })
diff --git a/crowdstf/lib/units/device/plugins/util/display.js b/crowdstf/lib/units/device/plugins/util/display.js
new file mode 100644
index 0000000..5f20e1c
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/util/display.js
@@ -0,0 +1,71 @@
+var util = require('util')
+
+var syrup = require('stf-syrup')
+var EventEmitter = require('eventemitter3').EventEmitter
+
+var logger = require('../../../../util/logger')
+var streamutil = require('../../../../util/streamutil')
+
+module.exports = syrup.serial()
+  .dependency(require('../../support/adb'))
+  .dependency(require('../../resources/minicap'))
+  .dependency(require('../service'))
+  .dependency(require('../screen/options'))
+  .define(function(options, adb, minicap, service, screenOptions) {
+    var log = logger.createLogger('device:plugins:display')
+
+    function Display(id, properties) {
+      this.id = id
+      this.properties = properties
+    }
+
+    util.inherits(Display, EventEmitter)
+
+    Display.prototype.updateRotation = function(newRotation) {
+      log.info('Rotation changed to %d', newRotation)
+      this.properties.rotation = newRotation
+      this.emit('rotationChange', newRotation)
+    }
+
+    function infoFromMinicap(id) {
+      return minicap.run(util.format('-d %d -i', id))
+        .then(streamutil.readAll)
+        .then(function(out) {
+          var match
+          if ((match = /^ERROR: (.*)$/.exec(out))) {
+            throw new Error(match[1])
+          }
+
+          try {
+            return JSON.parse(out)
+          }
+          catch (e) {
+            throw new Error(out.toString())
+          }
+        })
+    }
+
+    function infoFromService(id) {
+      return service.getDisplay(id)
+    }
+
+    function readInfo(id) {
+      log.info('Reading display info')
+      return infoFromService(id)
+        .catch(function() {
+          return infoFromMinicap(id)
+        })
+        .then(function(properties) {
+          properties.url = screenOptions.publicUrl
+          return new Display(id, properties)
+        })
+    }
+
+    return readInfo(0).then(function(display) {
+      service.on('rotationChange', function(data) {
+        display.updateRotation(data.rotation)
+      })
+
+      return display
+    })
+  })
diff --git a/crowdstf/lib/units/device/plugins/util/flags.js b/crowdstf/lib/units/device/plugins/util/flags.js
new file mode 100644
index 0000000..45fee2a
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/util/flags.js
@@ -0,0 +1,16 @@
+var syrup = require('stf-syrup')
+
+module.exports = syrup.serial()
+  .dependency(require('./data'))
+  .define(function(options, data) {
+    return {
+      has: function(flag) {
+        return data && data.flags && !!data.flags[flag]
+      }
+    , get: function(flag, defaultValue) {
+        return data && data.flags && typeof data.flags[flag] !== 'undefined' ?
+          data.flags[flag] :
+          defaultValue
+      }
+    }
+  })
diff --git a/crowdstf/lib/units/device/plugins/util/identity.js b/crowdstf/lib/units/device/plugins/util/identity.js
new file mode 100644
index 0000000..1d14f3e
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/util/identity.js
@@ -0,0 +1,22 @@
+var syrup = require('stf-syrup')
+
+var devutil = require('../../../../util/devutil')
+var logger = require('../../../../util/logger')
+
+module.exports = syrup.serial()
+  .dependency(require('../../support/properties'))
+  .dependency(require('./display'))
+  .dependency(require('./phone'))
+  .define(function(options, properties, display, phone) {
+    var log = logger.createLogger('device:plugins:identity')
+
+    function solve() {
+      log.info('Solving identity')
+      var identity = devutil.makeIdentity(options.serial, properties)
+      identity.display = display.properties
+      identity.phone = phone
+      return identity
+    }
+
+    return solve()
+  })
diff --git a/crowdstf/lib/units/device/plugins/util/phone.js b/crowdstf/lib/units/device/plugins/util/phone.js
new file mode 100644
index 0000000..1ae0228
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/util/phone.js
@@ -0,0 +1,21 @@
+var syrup = require('stf-syrup')
+
+var logger = require('../../../../util/logger')
+
+module.exports = syrup.serial()
+  .dependency(require('../service'))
+  .define(function(options, service) {
+    var log = logger.createLogger('device:plugins:phone')
+
+    function fetch() {
+      log.info('Fetching phone info')
+      return service.getProperties([
+        'imei'
+      , 'phoneNumber'
+      , 'iccid'
+      , 'network'
+      ])
+    }
+
+    return fetch()
+  })
diff --git a/crowdstf/lib/units/device/plugins/util/urlformat.js b/crowdstf/lib/units/device/plugins/util/urlformat.js
new file mode 100644
index 0000000..4a473e5
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/util/urlformat.js
@@ -0,0 +1,33 @@
+var syrup = require('stf-syrup')
+var _ = require('lodash')
+var tr = require('transliteration')
+
+module.exports = syrup.serial()
+  .dependency(require('./identity'))
+  .dependency(require('./data'))
+  .define(function(options, identity, data) {
+    function createSlug() {
+      var model = identity.model
+      var name = data ? data.name.id : ''
+
+      return (name === '' || model.toLowerCase() === name.toLowerCase()) ?
+        tr.slugify(model) :
+        tr.slugify(name + ' ' + model)
+    }
+
+    var defaults = {
+      publicIp: options.publicIp
+    , serial: options.serial
+    , model: identity.model
+    , name: data ? data.name.id : ''
+    , slug: createSlug()
+    }
+
+    return function(template, port) {
+      return _.template(template, {
+          imports: {
+            slugify: tr.slugify
+          }
+        })(_.defaults({publicPort: port}, defaults))
+    }
+  })
diff --git a/crowdstf/lib/units/device/plugins/vnc/index.js b/crowdstf/lib/units/device/plugins/vnc/index.js
new file mode 100644
index 0000000..e6a6fd2
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/vnc/index.js
@@ -0,0 +1,292 @@
+var net = require('net')
+var util = require('util')
+var os = require('os')
+
+var syrup = require('stf-syrup')
+var Promise = require('bluebird')
+var uuid = require('node-uuid')
+var jpeg = require('jpeg-turbo')
+
+var logger = require('../../../../util/logger')
+var grouputil = require('../../../../util/grouputil')
+var wire = require('../../../../wire')
+var wireutil = require('../../../../wire/util')
+var lifecycle = require('../../../../util/lifecycle')
+
+var VncServer = require('./util/server')
+var VncConnection = require('./util/connection')
+var PointerTranslator = require('./util/pointertranslator')
+
+module.exports = syrup.serial()
+  .dependency(require('../../support/router'))
+  .dependency(require('../../support/push'))
+  .dependency(require('../screen/stream'))
+  .dependency(require('../touch'))
+  .dependency(require('../group'))
+  .dependency(require('../solo'))
+  .define(function(options, router, push, screenStream, touch, group, solo) {
+    var log = logger.createLogger('device:plugins:vnc')
+
+    function vncAuthHandler(data) {
+      log.info(
+        'VNC authentication attempt using "%s"'
+      , data.response.toString('hex')
+      )
+
+      var resolver = Promise.defer()
+
+      function notify() {
+        group.get()
+          .then(function(currentGroup) {
+            push.send([
+              solo.channel
+            , wireutil.envelope(new wire.JoinGroupByVncAuthResponseMessage(
+                options.serial
+              , data.response.toString('hex')
+              , currentGroup.group
+              ))
+            ])
+          })
+          .catch(grouputil.NoGroupError, function() {
+            push.send([
+              solo.channel
+            , wireutil.envelope(new wire.JoinGroupByVncAuthResponseMessage(
+                options.serial
+              , data.response.toString('hex')
+              ))
+            ])
+          })
+      }
+
+      function joinListener(newGroup, identifier) {
+        if (!data.response.equals(new Buffer(identifier || '', 'hex'))) {
+          resolver.reject(new Error('Someone else took the device'))
+        }
+      }
+
+      function autojoinListener(identifier, joined) {
+        if (data.response.equals(new Buffer(identifier, 'hex'))) {
+          if (joined) {
+            resolver.resolve()
+          }
+          else {
+            resolver.reject(new Error('Device is already in use'))
+          }
+        }
+      }
+
+      group.on('join', joinListener)
+      group.on('autojoin', autojoinListener)
+      router.on(wire.VncAuthResponsesUpdatedMessage, notify)
+
+      notify()
+
+      return resolver.promise
+        .timeout(5000)
+        .finally(function() {
+          group.removeListener('join', joinListener)
+          group.removeListener('autojoin', autojoinListener)
+          router.removeListener(wire.VncAuthResponsesUpdatedMessage, notify)
+        })
+    }
+
+    function createServer() {
+      log.info('Starting VNC server on port %d', options.vncPort)
+
+      var opts = {
+        name: options.serial
+      , width: options.vncInitialSize[0]
+      , height: options.vncInitialSize[1]
+      , security: [{
+          type: VncConnection.SECURITY_VNC
+        , challenge: new Buffer(16).fill(0)
+        , auth: vncAuthHandler
+        }]
+      }
+
+      var vnc = new VncServer(net.createServer({
+        allowHalfOpen: true
+      }), opts)
+
+      var listeningListener, errorListener
+      return new Promise(function(resolve, reject) {
+          listeningListener = function() {
+            return resolve(vnc)
+          }
+
+          errorListener = function(err) {
+            return reject(err)
+          }
+
+          vnc.on('listening', listeningListener)
+          vnc.on('error', errorListener)
+
+          vnc.listen(options.vncPort)
+        })
+        .finally(function() {
+          vnc.removeListener('listening', listeningListener)
+          vnc.removeListener('error', errorListener)
+        })
+    }
+
+    return createServer()
+      .then(function(vnc) {
+        vnc.on('connection', function(conn) {
+          log.info('New VNC connection from %s', conn.conn.remoteAddress)
+
+          var id = util.format('vnc-%s', uuid.v4())
+
+          var connState = {
+            lastFrame: null
+          , lastFrameTime: null
+          , frameWidth: 0
+          , frameHeight: 0
+          , sentFrameTime: null
+          , updateRequests: 0
+          , frameConfig: {
+              format: jpeg.FORMAT_RGB
+            }
+          }
+
+          var pointerTranslator = new PointerTranslator()
+
+          pointerTranslator.on('touchdown', function(event) {
+            touch.touchDown(event)
+          })
+
+          pointerTranslator.on('touchmove', function(event) {
+            touch.touchMove(event)
+          })
+
+          pointerTranslator.on('touchup', function(event) {
+            touch.touchUp(event)
+          })
+
+          pointerTranslator.on('touchcommit', function() {
+            touch.touchCommit()
+          })
+
+          function maybeSendFrame() {
+            if (!connState.updateRequests) {
+              return
+            }
+
+            if (!connState.lastFrame) {
+              return
+            }
+
+            if (connState.lastFrameTime === connState.sentFrameTime) {
+              return
+            }
+
+            var decoded = jpeg.decompressSync(
+              connState.lastFrame, connState.frameConfig)
+
+            conn.writeFramebufferUpdate([{
+                xPosition: 0
+              , yPosition: 0
+              , width: decoded.width
+              , height: decoded.height
+              , encodingType: VncConnection.ENCODING_RAW
+              , data: decoded.data
+              }
+            , {
+                xPosition: 0
+              , yPosition: 0
+              , width: decoded.width
+              , height: decoded.height
+              , encodingType: VncConnection.ENCODING_DESKTOPSIZE
+              }
+            ])
+
+            connState.updateRequests = 0
+            connState.sentFrameTime = connState.lastFrameTime
+          }
+
+          function vncStartListener(frameProducer) {
+            return new Promise(function(resolve) {
+              connState.frameWidth = frameProducer.banner.virtualWidth
+              connState.frameHeight = frameProducer.banner.virtualHeight
+              resolve()
+            })
+          }
+
+          function vncFrameListener(frame) {
+            return new Promise(function(resolve) {
+              connState.lastFrame = frame
+              connState.lastFrameTime = Date.now()
+              maybeSendFrame()
+              resolve()
+            })
+          }
+
+          function groupLeaveListener() {
+            conn.end()
+          }
+
+          conn.on('authenticated', function() {
+            screenStream.updateProjection(
+              options.vncInitialSize[0], options.vncInitialSize[1])
+            screenStream.broadcastSet.insert(id, {
+              onStart: vncStartListener
+            , onFrame: vncFrameListener
+            })
+          })
+
+          conn.on('fbupdaterequest', function() {
+            connState.updateRequests += 1
+            maybeSendFrame()
+          })
+
+          conn.on('formatchange', function(format) {
+            var same = os.endianness() === 'BE' ===
+              Boolean(format.bigEndianFlag)
+            var formatOrder = (format.redShift > format.blueShift) === same
+
+            switch (format.bitsPerPixel) {
+            case 8:
+              connState.frameConfig = {
+                format: jpeg.FORMAT_GRAY
+              }
+              break
+            case 24:
+              connState.frameConfig = {
+                format: formatOrder ? jpeg.FORMAT_BGR : jpeg.FORMAT_RGB
+              }
+              break
+            case 32:
+              var f
+              if (formatOrder) {
+                f = format.blueShift === 0 ? jpeg.FORMAT_BGRX : jpeg.FORMAT_XBGR
+              }
+              else {
+                f = format.redShift === 0 ? jpeg.FORMAT_RGBX : jpeg.FORMAT_XRGB
+              }
+              connState.frameConfig = {
+                format: f
+              }
+              break
+            }
+          })
+
+          conn.on('pointer', function(event) {
+            pointerTranslator.push(event)
+          })
+
+          conn.on('close', function() {
+            screenStream.broadcastSet.remove(id)
+            group.removeListener('leave', groupLeaveListener)
+          })
+
+          conn.on('userActivity', function() {
+            group.keepalive()
+          })
+
+          group.on('leave', groupLeaveListener)
+        })
+
+        lifecycle.observe(function() {
+          vnc.close()
+        })
+      })
+  })
diff --git a/crowdstf/lib/units/device/plugins/vnc/util/connection.js b/crowdstf/lib/units/device/plugins/vnc/util/connection.js
new file mode 100644
index 0000000..4928366
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/vnc/util/connection.js
@@ -0,0 +1,536 @@
+var util = require('util')
+var os = require('os')
+var crypto = require('crypto')
+
+var EventEmitter = require('eventemitter3').EventEmitter
+var debug = require('debug')('vnc:connection')
+var Promise = require('bluebird')
+
+var PixelFormat = require('./pixelformat')
+
+function VncConnection(conn, options) {
+  this.options = options
+
+  this._bound = {
+    _errorListener: this._errorListener.bind(this)
+  , _readableListener: this._readableListener.bind(this)
+  , _endListener: this._endListener.bind(this)
+  , _closeListener: this._closeListener.bind(this)
+  }
+
+  this._buffer = null
+  this._state = 0
+  this._changeState(VncConnection.STATE_NEED_CLIENT_VERSION)
+
+  this._serverVersion = VncConnection.V3_008
+  this._serverSupportedSecurity = this.options.security
+  this._serverSupportedSecurityByType =
+    this.options.security.reduce(
+      function(map, method) {
+        map[method.type] = method
+        return map
+      }
+    , Object.create(null)
+  )
+  this._serverWidth = this.options.width
+  this._serverHeight = this.options.height
+  this._serverPixelFormat = new PixelFormat({
+    bitsPerPixel: 32
+  , depth: 24
+  , bigEndianFlag: os.endianness() === 'BE' ? 1 : 0
+  , trueColorFlag: 1
+  , redMax: 255
+  , greenMax: 255
+  , blueMax: 255
+  , redShift: 16
+  , greenShift: 8
+  , blueShift: 0
+  })
+  this._serverName = this.options.name
+
+  this._clientVersion = null
+  this._clientShare = false
+  this._clientPixelFormat = this._serverPixelFormat
+  this._clientEncodingCount = 0
+  this._clientEncodings = []
+  this._clientCutTextLength = 0
+
+  this._authChallenge = this.options.challenge || crypto.randomBytes(16)
+
+  this.conn = conn
+    .on('error', this._bound._errorListener)
+    .on('readable', this._bound._readableListener)
+    .on('end', this._bound._endListener)
+    .on('close', this._bound._closeListener)
+
+  this._blockingOps = []
+
+  this._writeServerVersion()
+  this._read()
+}
+
+util.inherits(VncConnection, EventEmitter)
+
+VncConnection.V3_003 = 3003
+VncConnection.V3_007 = 3007
+VncConnection.V3_008 = 3008
+
+VncConnection.SECURITY_NONE = 1
+VncConnection.SECURITY_VNC = 2
+
+VncConnection.SECURITYRESULT_OK = 0
+VncConnection.SECURITYRESULT_FAIL = 1
+
+VncConnection.CLIENT_MESSAGE_SETPIXELFORMAT = 0
+VncConnection.CLIENT_MESSAGE_SETENCODINGS = 2
+VncConnection.CLIENT_MESSAGE_FBUPDATEREQUEST = 3
+VncConnection.CLIENT_MESSAGE_KEYEVENT = 4
+VncConnection.CLIENT_MESSAGE_POINTEREVENT = 5
+VncConnection.CLIENT_MESSAGE_CLIENTCUTTEXT = 6
+
+VncConnection.SERVER_MESSAGE_FBUPDATE = 0
+
+var StateReverse = Object.create(null)
+var State = {
+  STATE_NEED_CLIENT_VERSION: 10
+, STATE_NEED_CLIENT_SECURITY: 20
+, STATE_NEED_CLIENT_INIT: 30
+, STATE_NEED_CLIENT_VNC_AUTH: 31
+, STATE_NEED_CLIENT_MESSAGE: 40
+, STATE_NEED_CLIENT_MESSAGE_SETPIXELFORMAT: 50
+, STATE_NEED_CLIENT_MESSAGE_SETENCODINGS: 60
+, STATE_NEED_CLIENT_MESSAGE_SETENCODINGS_VALUE: 61
+, STATE_NEED_CLIENT_MESSAGE_FBUPDATEREQUEST: 70
+, STATE_NEED_CLIENT_MESSAGE_KEYEVENT: 80
+, STATE_NEED_CLIENT_MESSAGE_POINTEREVENT: 90
+, STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT: 100
+, STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT_VALUE: 101
+}
+
+VncConnection.ENCODING_RAW = 0
+VncConnection.ENCODING_DESKTOPSIZE = -223
+
+Object.keys(State).map(function(name) {
+  VncConnection[name] = State[name]
+  StateReverse[State[name]] = name
+})
+
+VncConnection.prototype.end = function() {
+  this.conn.end()
+}
+
+VncConnection.prototype.writeFramebufferUpdate = function(rectangles) {
+  var chunk = new Buffer(4)
+  chunk[0] = VncConnection.SERVER_MESSAGE_FBUPDATE
+  chunk[1] = 0
+  chunk.writeUInt16BE(rectangles.length, 2)
+  this._write(chunk)
+
+  rectangles.forEach(function(rect) {
+    var rchunk = new Buffer(12)
+    rchunk.writeUInt16BE(rect.xPosition, 0)
+    rchunk.writeUInt16BE(rect.yPosition, 2)
+    rchunk.writeUInt16BE(rect.width, 4)
+    rchunk.writeUInt16BE(rect.height, 6)
+    rchunk.writeInt32BE(rect.encodingType, 8)
+    this._write(rchunk)
+
+    switch (rect.encodingType) {
+    case VncConnection.ENCODING_RAW:
+      this._write(rect.data)
+      break
+    case VncConnection.ENCODING_DESKTOPSIZE:
+      this._serverWidth = rect.width
+      this._serverHeight = rect.height
+      break
+    default:
+      throw new Error(util.format(
+        'Unsupported encoding type', rect.encodingType))
+    }
+  }, this)
+}
+
+VncConnection.prototype._error = function(err) {
+  this.emit('error', err)
+  this.end()
+}
+
+VncConnection.prototype._errorListener = function(err) {
+  this._error(err)
+}
+
+VncConnection.prototype._endListener = function() {
+  this.emit('end')
+}
+
+VncConnection.prototype._closeListener = function() {
+  this.emit('close')
+}
+
+VncConnection.prototype._writeServerVersion = function() {
+  // Yes, we could just format the string instead. Didn't feel like it.
+  switch (this._serverVersion) {
+  case VncConnection.V3_003:
+    this._write(new Buffer('RFB 003.003\n'))
+    break
+  case VncConnection.V3_007:
+    this._write(new Buffer('RFB 003.007\n'))
+    break
+  case VncConnection.V3_008:
+    this._write(new Buffer('RFB 003.008\n'))
+    break
+  }
+}
+
+VncConnection.prototype._writeSupportedSecurity = function() {
+  var chunk = new Buffer(1 + this._serverSupportedSecurity.length)
+
+  chunk[0] = this._serverSupportedSecurity.length
+  this._serverSupportedSecurity.forEach(function(security, i) {
+    chunk[1 + i] = security.type
+  })
+
+  this._write(chunk)
+}
+
+VncConnection.prototype._writeSecurityResult = function(result, reason) {
+  var chunk
+  switch (result) {
+  case VncConnection.SECURITYRESULT_OK:
+    chunk = new Buffer(4)
+    chunk.writeUInt32BE(result, 0)
+    this._write(chunk)
+    break
+  case VncConnection.SECURITYRESULT_FAIL:
+    chunk = new Buffer(4 + 4 + reason.length)
+    chunk.writeUInt32BE(result, 0)
+    chunk.writeUInt32BE(reason.length, 4)
+    chunk.write(reason, 8, reason.length)
+    this._write(chunk)
+    break
+  }
+}
+
+VncConnection.prototype._writeServerInit = function() {
+  debug('server pixel format', this._serverPixelFormat)
+  var chunk = new Buffer(2 + 2 + 16 + 4 + this._serverName.length)
+  chunk.writeUInt16BE(this._serverWidth, 0)
+  chunk.writeUInt16BE(this._serverHeight, 2)
+  chunk[4] = this._serverPixelFormat.bitsPerPixel
+  chunk[5] = this._serverPixelFormat.depth
+  chunk[6] = this._serverPixelFormat.bigEndianFlag
+  chunk[7] = this._serverPixelFormat.trueColorFlag
+  chunk.writeUInt16BE(this._serverPixelFormat.redMax, 8)
+  chunk.writeUInt16BE(this._serverPixelFormat.greenMax, 10)
+  chunk.writeUInt16BE(this._serverPixelFormat.blueMax, 12)
+  chunk[14] = this._serverPixelFormat.redShift
+  chunk[15] = this._serverPixelFormat.greenShift
+  chunk[16] = this._serverPixelFormat.blueShift
+  chunk[17] = 0 // padding
+  chunk[18] = 0 // padding
+  chunk[19] = 0 // padding
+  chunk.writeUInt32BE(this._serverName.length, 20)
+  chunk.write(this._serverName, 24, this._serverName.length)
+  this._write(chunk)
+}
+
+VncConnection.prototype._writeVncAuthChallenge = function() {
+  var vncSec = this._serverSupportedSecurityByType[VncConnection.SECURITY_VNC]
+  debug('vnc auth challenge', vncSec.challenge)
+  this._write(vncSec.challenge)
+}
+
+VncConnection.prototype._readableListener = function() {
+  this._read()
+}
+
+VncConnection.prototype._read = function() {
+  Promise.all(this._blockingOps).bind(this)
+    .then(this._unguardedRead)
+}
+
+VncConnection.prototype._auth = function(type, data) {
+  var security = this._serverSupportedSecurityByType[type]
+  this._blockingOps.push(
+    security.auth(data).bind(this)
+      .then(function() {
+        this._changeState(VncConnection.STATE_NEED_CLIENT_INIT)
+        this._writeSecurityResult(VncConnection.SECURITYRESULT_OK)
+        this.emit('authenticated')
+        this._read()
+      })
+      .catch(function() {
+        this._writeSecurityResult(
+          VncConnection.SECURITYRESULT_FAIL, 'Authentication failure')
+        this.end()
+      })
+  )
+}
+
+VncConnection.prototype._unguardedRead = function() {
+  var chunk, lo, hi
+  while (this._append(this.conn.read())) {
+    do {
+      debug('state', StateReverse[this._state])
+      chunk = null
+      switch (this._state) {
+      case VncConnection.STATE_NEED_CLIENT_VERSION:
+        if ((chunk = this._consume(12))) {
+          if ((this._clientVersion = this._parseVersion(chunk)) === null) {
+            this.end()
+            return
+          }
+          debug('client version', this._clientVersion)
+          this._writeSupportedSecurity()
+          this._changeState(VncConnection.STATE_NEED_CLIENT_SECURITY)
+        }
+        break
+      case VncConnection.STATE_NEED_CLIENT_SECURITY:
+        if ((chunk = this._consume(1))) {
+          if ((this._clientSecurity = this._parseSecurity(chunk)) === null) {
+            this._writeSecurityResult(
+              VncConnection.SECURITYRESULT_FAIL, 'Unimplemented security type')
+            this.end()
+            return
+          }
+          debug('client security', this._clientSecurity)
+          if (!(this._clientSecurity in this._serverSupportedSecurityByType)) {
+            this._writeSecurityResult(
+              VncConnection.SECURITYRESULT_FAIL, 'Unsupported security type')
+            this.end()
+            return
+          }
+          switch (this._clientSecurity) {
+          case VncConnection.SECURITY_NONE:
+            this._auth(VncConnection.SECURITY_NONE)
+            return
+          case VncConnection.SECURITY_VNC:
+            this._writeVncAuthChallenge()
+            this._changeState(VncConnection.STATE_NEED_CLIENT_VNC_AUTH)
+            break
+          }
+        }
+        break
+      case VncConnection.STATE_NEED_CLIENT_VNC_AUTH:
+        if ((chunk = this._consume(16))) {
+          this._auth(VncConnection.SECURITY_VNC, {
+            response: chunk
+          })
+          return
+        }
+        break
+      case VncConnection.STATE_NEED_CLIENT_INIT:
+        if ((chunk = this._consume(1))) {
+          this._clientShare = chunk[0]
+          debug('client shareFlag', this._clientShare)
+          this._writeServerInit()
+          this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE)
+        }
+        break
+      case VncConnection.STATE_NEED_CLIENT_MESSAGE:
+        if ((chunk = this._consume(1))) {
+          switch (chunk[0]) {
+          case VncConnection.CLIENT_MESSAGE_SETPIXELFORMAT:
+            this._changeState(
+              VncConnection.STATE_NEED_CLIENT_MESSAGE_SETPIXELFORMAT)
+            break
+          case VncConnection.CLIENT_MESSAGE_SETENCODINGS:
+            this._changeState(
+              VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS)
+            break
+          case VncConnection.CLIENT_MESSAGE_FBUPDATEREQUEST:
+            this._changeState(
+              VncConnection.STATE_NEED_CLIENT_MESSAGE_FBUPDATEREQUEST)
+            break
+          case VncConnection.CLIENT_MESSAGE_KEYEVENT:
+            this.emit('userActivity')
+            this._changeState(
+              VncConnection.STATE_NEED_CLIENT_MESSAGE_KEYEVENT)
+            break
+          case VncConnection.CLIENT_MESSAGE_POINTEREVENT:
+            this.emit('userActivity')
+            this._changeState(
+              VncConnection.STATE_NEED_CLIENT_MESSAGE_POINTEREVENT)
+            break
+          case VncConnection.CLIENT_MESSAGE_CLIENTCUTTEXT:
+            this.emit('userActivity')
+            this._changeState(
+              VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT)
+            break
+          default:
+            this._error(new Error(util.format(
+              'Unsupported message type %d', chunk[0])))
+            return
+          }
+        }
+        break
+      case VncConnection.STATE_NEED_CLIENT_MESSAGE_SETPIXELFORMAT:
+        if ((chunk = this._consume(19))) {
+          // [0b, 3b) padding
+          this._clientPixelFormat = new PixelFormat({
+            bitsPerPixel: chunk[3]
+          , depth: chunk[4]
+          , bigEndianFlag: chunk[5]
+          , trueColorFlag: chunk[6]
+          , redMax: chunk.readUInt16BE(7, true)
+          , greenMax: chunk.readUInt16BE(9, true)
+          , blueMax: chunk.readUInt16BE(11, true)
+          , redShift: chunk[13]
+          , greenShift: chunk[14]
+          , blueShift: chunk[15]
+          })
+          // [16b, 19b) padding
+          debug('client pixel format', this._clientPixelFormat)
+          this.emit('formatchange', this._clientPixelFormat)
+          this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE)
+        }
+        break
+      case VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS:
+        if ((chunk = this._consume(3))) {
+          // [0b, 1b) padding
+          this._clientEncodingCount = chunk.readUInt16BE(1, true)
+          this._changeState(
+            VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS_VALUE)
+        }
+        break
+      case VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS_VALUE:
+        lo = 0
+        hi = 4 * this._clientEncodingCount
+        if ((chunk = this._consume(hi))) {
+          this._clientEncodings = []
+          while (lo < hi) {
+            this._clientEncodings.push(chunk.readInt32BE(lo, true))
+            lo += 4
+          }
+          debug('client encodings', this._clientEncodings)
+          this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE)
+        }
+        break
+      case VncConnection.STATE_NEED_CLIENT_MESSAGE_FBUPDATEREQUEST:
+        if ((chunk = this._consume(9))) {
+          this.emit('fbupdaterequest', {
+            incremental: chunk[0]
+          , xPosition: chunk.readUInt16BE(1, true)
+          , yPosition: chunk.readUInt16BE(3, true)
+          , width: chunk.readUInt16BE(5, true)
+          , height: chunk.readUInt16BE(7, true)
+          })
+          this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE)
+        }
+        break
+      case VncConnection.STATE_NEED_CLIENT_MESSAGE_KEYEVENT:
+        if ((chunk = this._consume(7))) {
+          // downFlag = chunk[0]
+          // [1b, 3b) padding
+          // key = chunk.readUInt32BE(3, true)
+          this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE)
+        }
+        break
+      case VncConnection.STATE_NEED_CLIENT_MESSAGE_POINTEREVENT:
+        if ((chunk = this._consume(5))) {
+          this.emit('pointer', {
+            buttonMask: chunk[0]
+          , xPosition: chunk.readUInt16BE(1, true) / this._serverWidth
+          , yPosition: chunk.readUInt16BE(3, true) / this._serverHeight
+          })
+          this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE)
+        }
+        break
+      case VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT:
+        if ((chunk = this._consume(7))) {
+          // [0b, 3b) padding
+          this._clientCutTextLength = chunk.readUInt32BE(3)
+          this._changeState(
+            VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT_VALUE)
+        }
+        break
+      case VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT_VALUE:
+        if ((chunk = this._consume(this._clientCutTextLength))) {
+          // value = chunk
+          this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE)
+        }
+        break
+      default:
+        throw new Error(util.format('Impossible state %d', this._state))
+      }
+    }
+    while (chunk)
+  }
+}
+
+VncConnection.prototype._parseVersion = function(chunk) {
+  if (chunk.equals(new Buffer('RFB 003.008\n'))) {
+    return VncConnection.V3_008
+  }
+
+  if (chunk.equals(new Buffer('RFB 003.007\n'))) {
+    return VncConnection.V3_007
+  }
+
+  if (chunk.equals(new Buffer('RFB 003.003\n'))) {
+    return VncConnection.V3_003
+  }
+
+  return null
+}
+
+VncConnection.prototype._parseSecurity = function(chunk) {
+  switch (chunk[0]) {
+  case VncConnection.SECURITY_NONE:
+  case VncConnection.SECURITY_VNC:
+    return chunk[0]
+  default:
+    return null
+  }
+}
+
+VncConnection.prototype._changeState = function(state) {
+  this._state = state
+}
+
+VncConnection.prototype._append = function(chunk) {
+  if (!chunk) {
+    return false
+  }
+
+  debug('in', chunk)
+
+  if (this._buffer) {
+    this._buffer = Buffer.concat(
+      [this._buffer, chunk], this._buffer.length + chunk.length)
+  }
+  else {
+    this._buffer = chunk
+  }
+
+  return true
+}
+
+VncConnection.prototype._consume = function(n) {
+  var chunk
+
+  if (!this._buffer) {
+    return null
+  }
+
+  if (n < this._buffer.length) {
+    chunk = this._buffer.slice(0, n)
+    this._buffer = this._buffer.slice(n)
+    return chunk
+  }
+
+  if (n === this._buffer.length) {
+    chunk = this._buffer
+    this._buffer = null
+    return chunk
+  }
+
+  return null
+}
+
+VncConnection.prototype._write = function(chunk) {
+  debug('out', chunk)
+  this.conn.write(chunk)
+}
+
+module.exports = VncConnection
diff --git a/crowdstf/lib/units/device/plugins/vnc/util/pixelformat.js b/crowdstf/lib/units/device/plugins/vnc/util/pixelformat.js
new file mode 100644
index 0000000..9a1c427
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/vnc/util/pixelformat.js
@@ -0,0 +1,14 @@
+function PixelFormat(values) {
+  this.bitsPerPixel = values.bitsPerPixel
+  this.depth = values.depth
+  this.bigEndianFlag = values.bigEndianFlag
+  this.trueColorFlag = values.trueColorFlag
+  this.redMax = values.redMax
+  this.greenMax = values.greenMax
+  this.blueMax = values.blueMax
+  this.redShift = values.redShift
+  this.greenShift = values.greenShift
+  this.blueShift = values.blueShift
+}
+
+module.exports = PixelFormat
diff --git a/crowdstf/lib/units/device/plugins/vnc/util/pointertranslator.js b/crowdstf/lib/units/device/plugins/vnc/util/pointertranslator.js
new file mode 100644
index 0000000..8161efa
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/vnc/util/pointertranslator.js
@@ -0,0 +1,66 @@
+var util = require('util')
+
+var EventEmitter = require('eventemitter3').EventEmitter
+
+function PointerTranslator() {
+  this.previousEvent = null
+}
+
+util.inherits(PointerTranslator, EventEmitter)
+
+PointerTranslator.prototype.push = function(event) {
+  if (event.buttonMask & 0xFE) {
+    // Non-primary buttons included, ignore.
+    return
+  }
+
+  if (this.previousEvent) {
+    var buttonChanges = event.buttonMask ^ this.previousEvent.buttonMask
+
+    // If the primary button changed, we have an up/down event.
+    if (buttonChanges & 1) {
+      // If it's pressed now, that's a down event.
+      if (event.buttonMask & 1) {
+        this.emit('touchdown', {
+          contact: 1
+        , x: event.xPosition
+        , y: event.yPosition
+        })
+        this.emit('touchcommit')
+      }
+      // It's not pressed, so we have an up event.
+      else {
+        this.emit('touchup', {
+          contact: 1
+        })
+        this.emit('touchcommit')
+      }
+    }
+    // Otherwise, if we're still holding the primary button down,
+    // that's a move event.
+    else if (event.buttonMask & 1) {
+      this.emit('touchmove', {
+        contact: 1
+      , x: event.xPosition
+      , y: event.yPosition
+      })
+      this.emit('touchcommit')
+    }
+  }
+  else {
+    // If it's the first event we get and the primary button's pressed,
+    // it's a down event.
+    if (event.buttonMask & 1) {
+      this.emit('touchdown', {
+        contact: 1
+      , x: event.xPosition
+      , y: event.yPosition
+      })
+      this.emit('touchcommit')
+    }
+  }
+
+  this.previousEvent = event
+}
+
+module.exports = PointerTranslator
diff --git a/crowdstf/lib/units/device/plugins/vnc/util/server.js b/crowdstf/lib/units/device/plugins/vnc/util/server.js
new file mode 100644
index 0000000..5a8b238
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/vnc/util/server.js
@@ -0,0 +1,52 @@
+var util = require('util')
+
+var EventEmitter = require('eventemitter3').EventEmitter
+var debug = require('debug')('vnc:server')
+
+var VncConnection = require('./connection')
+
+function VncServer(server, options) {
+  this.options = options
+
+  this._bound = {
+    _listeningListener: this._listeningListener.bind(this)
+  , _connectionListener: this._connectionListener.bind(this)
+  , _closeListener: this._closeListener.bind(this)
+  , _errorListener: this._errorListener.bind(this)
+  }
+
+  this.server = server
+    .on('listening', this._bound._listeningListener)
+    .on('connection', this._bound._connectionListener)
+    .on('close', this._bound._closeListener)
+    .on('error', this._bound._errorListener)
+}
+
+util.inherits(VncServer, EventEmitter)
+
+VncServer.prototype.close = function() {
+  this.server.close()
+}
+
+VncServer.prototype.listen = function() {
+  this.server.listen.apply(this.server, arguments)
+}
+
+VncServer.prototype._listeningListener = function() {
+  this.emit('listening')
+}
+
+VncServer.prototype._connectionListener = function(conn) {
+  debug('connection', conn.remoteAddress, conn.remotePort)
+  this.emit('connection', new VncConnection(conn, this.options))
+}
+
+VncServer.prototype._closeListener = function() {
+  this.emit('close')
+}
+
+VncServer.prototype._errorListener = function(err) {
+  this.emit('error', err)
+}
+
+module.exports = VncServer
diff --git a/crowdstf/lib/units/device/plugins/wifi.js b/crowdstf/lib/units/device/plugins/wifi.js
new file mode 100644
index 0000000..1f43781
--- /dev/null
+++ b/crowdstf/lib/units/device/plugins/wifi.js
@@ -0,0 +1,53 @@
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var wire = require('../../../wire')
+var wireutil = require('../../../wire/util')
+
+module.exports = syrup.serial()
+  .dependency(require('./service'))
+  .dependency(require('../support/router'))
+  .dependency(require('../support/push'))
+  .define(function(options, service, router, push) {
+    var log = logger.createLogger('device:plugins:wifi')
+
+    router.on(wire.WifiSetEnabledMessage, function(channel, message) {
+      var reply = wireutil.reply(options.serial)
+      log.info('Setting Wifi "%s"', message.enabled)
+      service.setWifiEnabled(message.enabled)
+        .timeout(30000)
+        .then(function() {
+          push.send([
+            channel
+          , reply.okay()
+          ])
+        })
+        .catch(function(err) {
+          log.error('Setting Wifi enabled failed', err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+
+    router.on(wire.WifiGetStatusMessage, function(channel) {
+      var reply = wireutil.reply(options.serial)
+      log.info('Getting Wifi status')
+      service.getWifiStatus()
+        .timeout(30000)
+        .then(function(enabled) {
+          push.send([
+            channel
+          , reply.okay(enabled ? 'wifi_enabled' : 'wifi_disabled')
+          ])
+        })
+        .catch(function(err) {
+          log.error('Getting Wifi status failed', err.stack)
+          push.send([
+            channel
+          , reply.fail(err.message)
+          ])
+        })
+    })
+  })
diff --git a/crowdstf/lib/units/device/resources/minicap.js b/crowdstf/lib/units/device/resources/minicap.js
new file mode 100644
index 0000000..67189bf
--- /dev/null
+++ b/crowdstf/lib/units/device/resources/minicap.js
@@ -0,0 +1,108 @@
+var util = require('util')
+var path = require('path')
+
+var Promise = require('bluebird')
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var pathutil = require('../../../util/pathutil')
+var devutil = require('../../../util/devutil')
+var streamutil = require('../../../util/streamutil')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/adb'))
+  .dependency(require('../support/properties'))
+  .dependency(require('../support/abi'))
+  .define(function(options, adb, properties, abi) {
+    logger.createLogger('device:resources:minicap')
+
+    var resources = {
+      bin: {
+        src: pathutil.requiredMatch(abi.all.map(function(supportedAbi) {
+          return pathutil.vendor(util.format(
+            'minicap/bin/%s/minicap%s'
+          , supportedAbi
+          , abi.pie ? '' : '-nopie'
+          ))
+        }))
+      , dest: '/data/local/tmp/minicap'
+      , comm: 'minicap'
+      , mode: 0755
+      }
+    , lib: {
+        // @todo The lib ABI should match the bin ABI. Currently we don't
+        // have an x86_64 version of the binary while the lib supports it.
+        src: pathutil.requiredMatch(abi.all.reduce(function(all, supportedAbi) {
+          return all.concat([
+            pathutil.vendor(util.format(
+              'minicap/shared/android-%s/%s/minicap.so'
+            , properties['ro.build.version.release']
+            , supportedAbi
+            ))
+          , pathutil.vendor(util.format(
+              'minicap/shared/android-%d/%s/minicap.so'
+            , properties['ro.build.version.sdk']
+            , supportedAbi
+            ))
+          ])
+        }, []))
+      , dest: '/data/local/tmp/minicap.so'
+      , mode: 0755
+      }
+    }
+
+    function removeResource(res) {
+      return adb.shell(options.serial, ['rm', res.dest])
+        .timeout(10000)
+        .then(function(out) {
+          return streamutil.readAll(out)
+        })
+        .return(res)
+    }
+
+    function installResource(res) {
+      return adb.push(options.serial, res.src, res.dest, res.mode)
+        .timeout(10000)
+        .then(function(transfer) {
+          return new Promise(function(resolve, reject) {
+            transfer.on('error', reject)
+            transfer.on('end', resolve)
+          })
+        })
+        .return(res)
+    }
+
+    function installAll() {
+      return Promise.all([
+        removeResource(resources.bin).then(installResource)
+      , removeResource(resources.lib).then(installResource)
+      ])
+    }
+
+    function stop() {
+      return devutil.killProcsByComm(
+          adb
+        , options.serial
+        , resources.bin.comm
+        , resources.bin.dest
+        )
+        .timeout(15000)
+    }
+
+    return stop()
+      .then(installAll)
+      .then(function() {
+        return {
+          bin: resources.bin.dest
+        , lib: resources.lib.dest
+        , run: function(cmd) {
+            return adb.shell(options.serial, util.format(
+              'LD_LIBRARY_PATH=%s exec %s %s'
+            , path.dirname(resources.lib.dest)
+            , resources.bin.dest
+            , cmd
+            ))
+          }
+        }
+      })
+  })
diff --git a/crowdstf/lib/units/device/resources/minirev.js b/crowdstf/lib/units/device/resources/minirev.js
new file mode 100644
index 0000000..abf3ec0
--- /dev/null
+++ b/crowdstf/lib/units/device/resources/minirev.js
@@ -0,0 +1,94 @@
+var util = require('util')
+
+var Promise = require('bluebird')
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var pathutil = require('../../../util/pathutil')
+var devutil = require('../../../util/devutil')
+var streamutil = require('../../../util/streamutil')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/adb'))
+  .dependency(require('../support/properties'))
+  .define(function(options, adb, properties) {
+    var log = logger.createLogger('device:resources:minirev')
+
+    var resources = {
+      bin: {
+        src: pathutil.vendor(util.format(
+          'minirev/%s/minirev%s'
+        , properties['ro.product.cpu.abi']
+        , properties['ro.build.version.sdk'] < 16 ? '-nopie' : ''
+        ))
+      , dest: '/data/local/tmp/minirev'
+      , comm: 'minirev'
+      , mode: 0755
+      }
+    }
+
+    function removeResource(res) {
+      return adb.shell(options.serial, ['rm', res.dest])
+        .timeout(10000)
+        .then(function(out) {
+          return streamutil.readAll(out)
+        })
+        .return(res)
+    }
+
+    function installResource(res) {
+      return adb.push(options.serial, res.src, res.dest, res.mode)
+        .timeout(10000)
+        .then(function(transfer) {
+          return new Promise(function(resolve, reject) {
+            transfer.on('error', reject)
+            transfer.on('end', resolve)
+          })
+        })
+        .return(res)
+    }
+
+    function ensureNotBusy(res) {
+      return adb.shell(options.serial, [res.dest, '-h'])
+        .timeout(10000)
+        .then(function(out) {
+          // Can be "Text is busy", "text busy"
+          return streamutil.findLine(out, (/busy/i))
+            .timeout(10000)
+            .then(function() {
+              log.info('Binary is busy, will retry')
+              return Promise.delay(1000)
+            })
+            .then(function() {
+              return ensureNotBusy(res)
+            })
+            .catch(streamutil.NoSuchLineError, function() {
+              return res
+            })
+        })
+    }
+
+    function installAll() {
+      return Promise.all([
+        removeResource(resources.bin).then(installResource).then(ensureNotBusy)
+      ])
+    }
+
+    function stop() {
+      return devutil.killProcsByComm(
+          adb
+        , options.serial
+        , resources.bin.comm
+        , resources.bin.dest
+        )
+        .timeout(15000)
+    }
+
+    return stop()
+      .then(installAll)
+      .then(function() {
+        return {
+          bin: resources.bin.dest
+        }
+      })
+  })
diff --git a/crowdstf/lib/units/device/resources/minitouch.js b/crowdstf/lib/units/device/resources/minitouch.js
new file mode 100644
index 0000000..f891ffa
--- /dev/null
+++ b/crowdstf/lib/units/device/resources/minitouch.js
@@ -0,0 +1,83 @@
+var util = require('util')
+
+var Promise = require('bluebird')
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var pathutil = require('../../../util/pathutil')
+var devutil = require('../../../util/devutil')
+var streamutil = require('../../../util/streamutil')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/adb'))
+  .dependency(require('../support/abi'))
+  .define(function(options, adb, abi) {
+    logger.createLogger('device:resources:minitouch')
+
+    var resources = {
+      bin: {
+        src: pathutil.requiredMatch(abi.all.map(function(supportedAbi) {
+          return pathutil.vendor(util.format(
+            'minitouch/%s/minitouch%s'
+          , supportedAbi
+          , abi.pie ? '' : '-nopie'
+          ))
+        }))
+      , dest: '/data/local/tmp/minitouch'
+      , comm: 'minitouch'
+      , mode: 0755
+      }
+    }
+
+    function removeResource(res) {
+      return adb.shell(options.serial, ['rm', res.dest])
+        .timeout(10000)
+        .then(function(out) {
+          return streamutil.readAll(out)
+        })
+        .return(res)
+    }
+
+    function installResource(res) {
+      return adb.push(options.serial, res.src, res.dest, res.mode)
+        .timeout(10000)
+        .then(function(transfer) {
+          return new Promise(function(resolve, reject) {
+            transfer.on('error', reject)
+            transfer.on('end', resolve)
+          })
+        })
+        .return(res)
+    }
+
+    function installAll() {
+      return Promise.all([
+        removeResource(resources.bin).then(installResource)
+      ])
+    }
+
+    function stop() {
+      return devutil.killProcsByComm(
+          adb
+        , options.serial
+        , resources.bin.comm
+        , resources.bin.dest
+        )
+        .timeout(15000)
+    }
+
+    return stop()
+      .then(installAll)
+      .then(function() {
+        return {
+          bin: resources.bin.dest
+        , run: function(cmd) {
+            return adb.shell(options.serial, util.format(
+              'exec %s%s'
+            , resources.bin.dest
+            , cmd ? util.format(' %s', cmd) : ''
+            ))
+          }
+        }
+      })
+  })
diff --git a/crowdstf/lib/units/device/resources/service.js b/crowdstf/lib/units/device/resources/service.js
new file mode 100644
index 0000000..bc3f737
--- /dev/null
+++ b/crowdstf/lib/units/device/resources/service.js
@@ -0,0 +1,105 @@
+var util = require('util')
+
+var syrup = require('stf-syrup')
+var ProtoBuf = require('protobufjs')
+var semver = require('semver')
+
+var pathutil = require('../../../util/pathutil')
+var streamutil = require('../../../util/streamutil')
+var promiseutil = require('../../../util/promiseutil')
+var logger = require('../../../util/logger')
+
+module.exports = syrup.serial()
+  .dependency(require('../support/adb'))
+  .define(function(options, adb) {
+    var log = logger.createLogger('device:resources:service')
+    var builder = ProtoBuf.loadProtoFile(
+      pathutil.vendor('STFService/wire.proto'))
+
+    var resource = {
+      requiredVersion: '1.0.2'
+    , pkg: 'jp.co.cyberagent.stf'
+    , main: 'jp.co.cyberagent.stf.Agent'
+    , apk: pathutil.vendor('STFService/STFService.apk')
+    , wire: builder.build().jp.co.cyberagent.stf.proto
+    , builder: builder
+    , startIntent: {
+        action: 'jp.co.cyberagent.stf.ACTION_START'
+      , component: 'jp.co.cyberagent.stf/.Service'
+      }
+    }
+
+    function getPath() {
+      return adb.shell(options.serial, ['pm', 'path', resource.pkg])
+        .timeout(10000)
+        .then(function(out) {
+          return streamutil.findLine(out, (/^package:/))
+            .timeout(15000)
+            .then(function(line) {
+              return line.substr(8)
+            })
+        })
+    }
+
+    function install() {
+      log.info('Checking whether we need to install STFService')
+      return getPath()
+        .then(function(installedPath) {
+          log.info('Running version check')
+          return adb.shell(options.serial, util.format(
+            "export CLASSPATH='%s';" +
+            " exec app_process /system/bin '%s' --version 2>/dev/null"
+          , installedPath
+          , resource.main
+          ))
+          .timeout(10000)
+          .then(function(out) {
+            return streamutil.readAll(out)
+              .timeout(10000)
+              .then(function(buffer) {
+                var version = buffer.toString()
+                if (semver.satisfies(version, resource.requiredVersion)) {
+                  return installedPath
+                }
+                else {
+                  throw new Error(util.format(
+                    'Incompatible version %s'
+                  , version
+                  ))
+                }
+              })
+          })
+        })
+        .catch(function() {
+          log.info('Installing STFService')
+          // Uninstall first to make sure we don't have any certificate
+          // issues.
+          return adb.uninstall(options.serial, resource.pkg)
+            .timeout(15000)
+            .then(function() {
+              return promiseutil.periodicNotify(
+                  adb.install(options.serial, resource.apk)
+                , 20000
+                )
+                .timeout(65000)
+            })
+            .progressed(function() {
+              log.warn(
+                'STFService installation is taking a long time; ' +
+                'perhaps you have to accept 3rd party app installation ' +
+                'on the device?'
+              )
+            })
+            .then(function() {
+              return getPath()
+            })
+        })
+    }
+
+    return install()
+      .then(function(path) {
+        log.info('STFService up to date')
+        resource.path = path
+        return resource
+      })
+  })
diff --git a/crowdstf/lib/units/device/support/abi.js b/crowdstf/lib/units/device/support/abi.js
new file mode 100644
index 0000000..e7fd59f
--- /dev/null
+++ b/crowdstf/lib/units/device/support/abi.js
@@ -0,0 +1,42 @@
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+
+module.exports = syrup.serial()
+  .dependency(require('./properties'))
+  .define(function(options, properties) {
+    var log = logger.createLogger('device:support:abi')
+    return (function() {
+      function split(list) {
+        return list ? list.split(',') : []
+      }
+
+      var abi = {
+        primary: properties['ro.product.cpu.abi']
+      , pie: properties['ro.build.version.sdk'] >= 16
+      , all: []
+      , b32: []
+      , b64: []
+      }
+
+      // Since Android 5.0
+      if (properties['ro.product.cpu.abilist']) {
+        abi.all = split(properties['ro.product.cpu.abilist'])
+        abi.b64 = split(properties['ro.product.cpu.abilist64'])
+        abi.b32 = split(properties['ro.product.cpu.abilist32'])
+      }
+      // Up to Android 4.4
+      else {
+        abi.all.push(abi.primary)
+        abi.b32.push(abi.primary)
+        if (properties['ro.product.cpu.abi2']) {
+          abi.all.push(properties['ro.product.cpu.abi2'])
+          abi.b32.push(properties['ro.product.cpu.abi2'])
+        }
+      }
+
+      log.info('Supports ABIs %s', abi.all.join(', '))
+
+      return abi
+    })()
+  })
diff --git a/crowdstf/lib/units/device/support/adb.js b/crowdstf/lib/units/device/support/adb.js
new file mode 100644
index 0000000..85de547
--- /dev/null
+++ b/crowdstf/lib/units/device/support/adb.js
@@ -0,0 +1,30 @@
+var syrup = require('stf-syrup')
+
+var adbkit = require('adbkit')
+
+var logger = require('../../../util/logger')
+var promiseutil = require('../../../util/promiseutil')
+
+module.exports = syrup.serial()
+  .define(function(options) {
+    var log = logger.createLogger('device:support:adb')
+    var adb = adbkit.createClient({
+      host: options.adbHost
+    , port: options.adbPort
+    })
+    adb.Keycode = adbkit.Keycode
+
+    function ensureBootComplete() {
+      return promiseutil.periodicNotify(
+          adb.waitBootComplete(options.serial)
+        , 1000
+        )
+        .progressed(function() {
+          log.info('Waiting for boot to complete')
+        })
+        .timeout(60000)
+    }
+
+    return ensureBootComplete()
+      .return(adb)
+  })
diff --git a/crowdstf/lib/units/device/support/channels.js b/crowdstf/lib/units/device/support/channels.js
new file mode 100644
index 0000000..e810252
--- /dev/null
+++ b/crowdstf/lib/units/device/support/channels.js
@@ -0,0 +1,14 @@
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+var ChannelManager = require('../../../wire/channelmanager')
+
+module.exports = syrup.serial()
+  .define(function() {
+    var log = logger.createLogger('device:support:channels')
+    var channels = new ChannelManager()
+    channels.on('timeout', function(channel) {
+      log.info('Channel "%s" timed out', channel)
+    })
+    return channels
+  })
diff --git a/crowdstf/lib/units/device/support/properties.js b/crowdstf/lib/units/device/support/properties.js
new file mode 100644
index 0000000..520c565
--- /dev/null
+++ b/crowdstf/lib/units/device/support/properties.js
@@ -0,0 +1,17 @@
+var syrup = require('stf-syrup')
+
+var logger = require('../../../util/logger')
+
+module.exports = syrup.serial()
+  .dependency(require('./adb'))
+  .define(function(options, adb) {
+    var log = logger.createLogger('device:support:properties')
+
+    function load() {
+      log.info('Loading properties')
+      return adb.getProperties(options.serial)
+        .timeout(10000)
+    }
+
+    return load()
+  })
diff --git a/crowdstf/lib/units/device/support/push.js b/crowdstf/lib/units/device/support/push.js
new file mode 100644
index 0000000..c68bf47
--- /dev/null
+++ b/crowdstf/lib/units/device/support/push.js
@@ -0,0 +1,26 @@
+var syrup = require('stf-syrup')
+
+var Promise = require('bluebird')
+
+var logger = require('../../../util/logger')
+var srv = require('../../../util/srv')
+var zmqutil = require('../../../util/zmqutil')
+
+module.exports = syrup.serial()
+  .define(function(options) {
+    var log = logger.createLogger('device:support:push')
+
+    // Output
+    var push = zmqutil.socket('push')
+
+    return Promise.map(options.endpoints.push, function(endpoint) {
+        return srv.resolve(endpoint).then(function(records) {
+          return srv.attempt(records, function(record) {
+            log.info('Sending output to "%s"', record.url)
+            push.connect(record.url)
+            return Promise.resolve(true)
+          })
+        })
+      })
+      .return(push)
+  })
diff --git a/crowdstf/lib/units/device/support/router.js b/crowdstf/lib/units/device/support/router.js
new file mode 100644
index 0000000..6f7692d
--- /dev/null
+++ b/crowdstf/lib/units/device/support/router.js
@@ -0,0 +1,19 @@
+var syrup = require('stf-syrup')
+
+var wirerouter = require('../../../wire/router')
+
+module.exports = syrup.serial()
+  .dependency(require('./sub'))
+  .dependency(require('./channels'))
+  .define(function(options, sub, channels) {
+    var router = wirerouter()
+
+    sub.on('message', router.handler())
+
+    // Special case, we're hooking into a message that's not actually routed.
+    router.on({$code: 'message'}, function(channel) {
+      channels.keepalive(channel)
+    })
+
+    return router
+  })
diff --git a/crowdstf/lib/units/device/support/storage.js b/crowdstf/lib/units/device/support/storage.js
new file mode 100644
index 0000000..35ea2ff
--- /dev/null
+++ b/crowdstf/lib/units/device/support/storage.js
@@ -0,0 +1,55 @@
+var util = require('util')
+var url = require('url')
+
+var syrup = require('stf-syrup')
+var Promise = require('bluebird')
+var request = require('request')
+
+var logger = require('../../../util/logger')
+
+module.exports = syrup.serial()
+  .define(function(options) {
+    var log = logger.createLogger('device:support:storage')
+    var plugin = Object.create(null)
+
+    plugin.store = function(type, stream, meta) {
+      var resolver = Promise.defer()
+
+      var args = {
+        url: url.resolve(options.storageUrl, util.format('s/upload/%s', type))
+      }
+
+      var req = request.post(args, function(err, res, body) {
+        if (err) {
+          log.error('Upload to "%s" failed', args.url, err.stack)
+          resolver.reject(err)
+        }
+        else if (res.statusCode !== 201) {
+          log.error('Upload to "%s" failed: HTTP %d', args.url, res.statusCode)
+          resolver.reject(new Error(util.format(
+            'Upload to "%s" failed: HTTP %d'
+          , args.url
+          , res.statusCode
+          )))
+        }
+        else {
+          try {
+            var result = JSON.parse(body)
+            log.info('Uploaded to "%s"', result.resources.file.href)
+            resolver.resolve(result.resources.file)
+          }
+          catch (err) {
+            log.error('Invalid JSON in response', err.stack, body)
+            resolver.reject(err)
+          }
+        }
+      })
+
+      req.form()
+        .append('file', stream, meta)
+
+      return resolver.promise
+    }
+
+    return plugin
+  })
diff --git a/crowdstf/lib/units/device/support/sub.js b/crowdstf/lib/units/device/support/sub.js
new file mode 100644
index 0000000..6e1d26a
--- /dev/null
+++ b/crowdstf/lib/units/device/support/sub.js
@@ -0,0 +1,35 @@
+var syrup = require('stf-syrup')
+
+var Promise = require('bluebird')
+
+var logger = require('../../../util/logger')
+var wireutil = require('../../../wire/util')
+var srv = require('../../../util/srv')
+require('../../../util/lifecycle')
+var zmqutil = require('../../../util/zmqutil')
+
+module.exports = syrup.serial()
+  .define(function(options) {
+    var log = logger.createLogger('device:support:sub')
+
+    // Input
+    var sub = zmqutil.socket('sub')
+
+    return Promise.map(options.endpoints.sub, function(endpoint) {
+        return srv.resolve(endpoint).then(function(records) {
+          return srv.attempt(records, function(record) {
+            log.info('Receiving input from "%s"', record.url)
+            sub.connect(record.url)
+            return Promise.resolve(true)
+          })
+        })
+      })
+      .then(function() {
+        // Establish always-on channels
+        [wireutil.global].forEach(function(channel) {
+          log.info('Subscribing to permanent channel "%s"', channel)
+          sub.subscribe(channel)
+        })
+      })
+      .return(sub)
+  })
diff --git a/crowdstf/lib/units/log/rethinkdb.js b/crowdstf/lib/units/log/rethinkdb.js
new file mode 100644
index 0000000..cbb60e8
--- /dev/null
+++ b/crowdstf/lib/units/log/rethinkdb.js
@@ -0,0 +1,52 @@
+var Promise = require('bluebird')
+
+var logger = require('../../util/logger')
+var wire = require('../../wire')
+var wirerouter = require('../../wire/router')
+var wireutil = require('../../wire/util')
+var lifecycle = require('../../util/lifecycle')
+var srv = require('../../util/srv')
+var dbapi = require('../../db/api')
+var zmqutil = require('../../util/zmqutil')
+
+module.exports = function(options) {
+  var log = logger.createLogger('log-db')
+
+  // Input
+  var sub = zmqutil.socket('sub')
+  Promise.map(options.endpoints.sub, function(endpoint) {
+    return srv.resolve(endpoint).then(function(records) {
+      return srv.attempt(records, function(record) {
+        log.info('Receiving input from "%s"', record.url)
+        sub.connect(record.url)
+        return Promise.resolve(true)
+      })
+    })
+  })
+
+  // Establish always-on channels
+  ;[wireutil.global].forEach(function(channel) {
+    log.info('Subscribing to permanent channel "%s"', channel)
+    sub.subscribe(channel)
+  })
+
+  sub.on('message', wirerouter()
+    .on(wire.DeviceLogMessage, function(channel, message) {
+      if (message.priority >= options.priority) {
+        dbapi.saveDeviceLog(message.serial, message)
+      }
+    })
+    .handler())
+
+  log.info('Listening for %s (or higher) level log messages',
+    logger.LevelLabel[options.priority])
+
+  lifecycle.observe(function() {
+    try {
+      sub.close()
+    }
+    catch (err) {
+      // No-op
+    }
+  })
+}
diff --git a/crowdstf/lib/units/notify/hipchat.js b/crowdstf/lib/units/notify/hipchat.js
new file mode 100644
index 0000000..0521d56
--- /dev/null
+++ b/crowdstf/lib/units/notify/hipchat.js
@@ -0,0 +1,90 @@
+/* eslint quote-props:0 */
+
+var util = require('util')
+
+var Hipchatter = require('hipchatter')
+var Promise = require('bluebird')
+
+var logger = require('../../util/logger')
+var wire = require('../../wire')
+var wirerouter = require('../../wire/router')
+var wireutil = require('../../wire/util')
+var lifecycle = require('../../util/lifecycle')
+var srv = require('../../util/srv')
+var zmqutil = require('../../util/zmqutil')
+
+var COLORS = {
+  1: 'gray'
+, 2: 'gray'
+, 3: 'green'
+, 4: 'purple'
+, 5: 'yellow'
+, 6: 'red'
+, 7: 'red'
+}
+
+module.exports = function(options) {
+  var log = logger.createLogger('notify-hipchat')
+  var client = Promise.promisifyAll(new Hipchatter(options.token))
+  var buffer = []
+  var timer
+
+  // Input
+  var sub = zmqutil.socket('sub')
+  Promise.map(options.endpoints.sub, function(endpoint) {
+    return srv.resolve(endpoint).then(function(records) {
+      return srv.attempt(records, function(record) {
+        log.info('Receiving input from "%s"', record.url)
+        sub.connect(record.url)
+        return Promise.resolve(true)
+      })
+    })
+  })
+
+  // Establish always-on channels
+  ;[wireutil.global].forEach(function(channel) {
+    log.info('Subscribing to permanent channel "%s"', channel)
+    sub.subscribe(channel)
+  })
+
+  function push() {
+    buffer.splice(0).forEach(function(entry) {
+      client.notifyAsync(options.room, {
+        message: util.format(
+          '<strong>%s</strong>/<strong>%s</strong> %d [<strong>%s</strong>] %s'
+          , logger.LevelLabel[entry.priority]
+          , entry.tag
+          , entry.pid
+          , entry.identifier
+          , entry.message
+        )
+        , color: COLORS[entry.priority]
+        , notify: entry.priority >= options.notifyPriority
+        , message_format: 'html'
+        , token: options.token
+      })
+    })
+  }
+
+  sub.on('message', wirerouter()
+    .on(wire.DeviceLogMessage, function(channel, message) {
+      if (message.priority >= options.priority) {
+        buffer.push(message)
+        clearTimeout(timer)
+        timer = setTimeout(push, 1000)
+      }
+    })
+    .handler())
+
+  log.info('Listening for %s (or higher) level log messages',
+    logger.LevelLabel[options.priority])
+
+  lifecycle.observe(function() {
+    try {
+      sub.close()
+    }
+    catch (err) {
+      // No-op
+    }
+  })
+}
diff --git a/crowdstf/lib/units/notify/slack.js b/crowdstf/lib/units/notify/slack.js
new file mode 100644
index 0000000..cef523e
--- /dev/null
+++ b/crowdstf/lib/units/notify/slack.js
@@ -0,0 +1,81 @@
+var util = require('util')
+
+var WebClient = require('slack-client').WebClient
+var Promise = require('bluebird')
+
+var logger = require('../../util/logger')
+var wire = require('../../wire')
+var wirerouter = require('../../wire/router')
+var wireutil = require('../../wire/util')
+var lifecycle = require('../../util/lifecycle')
+var srv = require('../../util/srv')
+var zmqutil = require('../../util/zmqutil')
+
+
+module.exports = function(options) {
+  var log = logger.createLogger('notify-slack')
+  var client = new WebClient(options.token)
+  var buffer = []
+  var timer
+
+  // Input
+  var sub = zmqutil.socket('sub')
+  Promise.map(options.endpoints.sub, function(endpoint) {
+    return srv.resolve(endpoint).then(function(records) {
+      return srv.attempt(records, function(record) {
+        log.info('Receiving input from "%s"', record.url)
+        sub.connect(record.url)
+        return Promise.resolve(true)
+      })
+    })
+  })
+
+    // Establish always-on channels
+  ;[wireutil.global].forEach(function(channel) {
+    log.info('Subscribing to permanent channel "%s"', channel)
+    sub.subscribe(channel)
+  })
+
+  function push() {
+    buffer.splice(0).forEach(function(entry) {
+      var format = entry.message.indexOf('\n') === -1 ? '`%s`' : '```%s```'
+      var message = util.format(format, entry.message)
+
+      client.chat.postMessage(options.channel, util.format(
+        '>>> *%s/%s* %d [*%s*] %s'
+        , logger.LevelLabel[entry.priority]
+        , entry.tag
+        , entry.pid
+        , entry.identifier
+        , message
+        )
+        , {
+          username: 'STF'
+          , icon_url: 'https://openstf.io/favicon.png'
+        }
+      )
+    })
+  }
+
+  sub.on('message', wirerouter()
+    .on(wire.DeviceLogMessage, function(channel, message) {
+      if (message.priority >= options.priority) {
+        buffer.push(message)
+        clearTimeout(timer)
+        timer = setTimeout(push, 1000)
+      }
+    })
+    .handler())
+
+  log.info('Listening for %s (or higher) level log messages',
+    logger.LevelLabel[options.priority])
+
+  lifecycle.observe(function() {
+    try {
+      sub.close()
+    }
+    catch (err) {
+      // No-op
+    }
+  })
+}
diff --git a/crowdstf/lib/units/poorxy/index.js b/crowdstf/lib/units/poorxy/index.js
new file mode 100644
index 0000000..1141d1b
--- /dev/null
+++ b/crowdstf/lib/units/poorxy/index.js
@@ -0,0 +1,62 @@
+var http = require('http')
+
+var express = require('express')
+var httpProxy = require('http-proxy')
+
+var logger = require('../../util/logger')
+
+module.exports = function(options) {
+  var log = logger.createLogger('poorxy')
+  var app = express()
+  var server = http.createServer(app)
+  var proxy = httpProxy.createProxyServer()
+
+  proxy.on('error', function(err) {
+    log.error('Proxy had an error', err.stack)
+  })
+
+  app.set('strict routing', true)
+  app.set('case sensitive routing', true)
+  app.set('trust proxy', true)
+
+  ;['/static/auth/*', '/auth/*'].forEach(function(route) {
+    app.all(route, function(req, res) {
+      proxy.web(req, res, {
+        target: options.authUrl
+      })
+    })
+  })
+
+  ;['/s/image/*'].forEach(function(route) {
+    app.all(route, function(req, res) {
+      proxy.web(req, res, {
+        target: options.storagePluginImageUrl
+      })
+    })
+  })
+
+  ;['/s/apk/*'].forEach(function(route) {
+    app.all(route, function(req, res) {
+      proxy.web(req, res, {
+        target: options.storagePluginApkUrl
+      })
+    })
+  })
+
+  ;['/s/*'].forEach(function(route) {
+    app.all(route, function(req, res) {
+      proxy.web(req, res, {
+        target: options.storageUrl
+      })
+    })
+  })
+
+  app.use(function(req, res) {
+    proxy.web(req, res, {
+      target: options.appUrl
+    })
+  })
+
+  server.listen(options.port)
+  log.info('Listening on port %d', options.port)
+}
diff --git a/crowdstf/lib/units/processor/index.js b/crowdstf/lib/units/processor/index.js
new file mode 100644
index 0000000..7b022d9
--- /dev/null
+++ b/crowdstf/lib/units/processor/index.js
@@ -0,0 +1,232 @@
+var Promise = require('bluebird')
+
+var logger = require('../../util/logger')
+var wire = require('../../wire')
+var wirerouter = require('../../wire/router')
+var wireutil = require('../../wire/util')
+var dbapi = require('../../db/api')
+var lifecycle = require('../../util/lifecycle')
+var srv = require('../../util/srv')
+var zmqutil = require('../../util/zmqutil')
+
+module.exports = function(options) {
+  var log = logger.createLogger('processor')
+
+  if (options.name) {
+    logger.setGlobalIdentifier(options.name)
+  }
+
+  // App side
+  var appDealer = zmqutil.socket('dealer')
+  Promise.map(options.endpoints.appDealer, function(endpoint) {
+    return srv.resolve(endpoint).then(function(records) {
+      return srv.attempt(records, function(record) {
+        log.info('App dealer connected to "%s"', record.url)
+        appDealer.connect(record.url)
+        return Promise.resolve(true)
+      })
+    })
+  })
+  .catch(function(err) {
+    log.fatal('Unable to connect to app dealer endpoint', err)
+    lifecycle.fatal()
+  })
+
+  // Device side
+  var devDealer = zmqutil.socket('dealer')
+
+  appDealer.on('message', function(channel, data) {
+    devDealer.send([channel, data])
+  })
+
+  Promise.map(options.endpoints.devDealer, function(endpoint) {
+    return srv.resolve(endpoint).then(function(records) {
+      return srv.attempt(records, function(record) {
+        log.info('Device dealer connected to "%s"', record.url)
+        devDealer.connect(record.url)
+        return Promise.resolve(true)
+      })
+    })
+  })
+  .catch(function(err) {
+    log.fatal('Unable to connect to dev dealer endpoint', err)
+    lifecycle.fatal()
+  })
+
+  devDealer.on('message', wirerouter()
+    // Initial device message
+    .on(wire.DeviceIntroductionMessage, function(channel, message, data) {
+      dbapi.saveDeviceInitialState(message.serial, message)
+        .then(function() {
+          devDealer.send([
+            message.provider.channel
+          , wireutil.envelope(new wire.DeviceRegisteredMessage(
+              message.serial
+            ))
+          ])
+          appDealer.send([channel, data])
+        })
+    })
+    // Workerless messages
+    .on(wire.DevicePresentMessage, function(channel, message, data) {
+      dbapi.setDevicePresent(message.serial)
+      appDealer.send([channel, data])
+    })
+    .on(wire.DeviceAbsentMessage, function(channel, message, data) {
+      dbapi.setDeviceAbsent(message.serial)
+      appDealer.send([channel, data])
+    })
+    .on(wire.DeviceStatusMessage, function(channel, message, data) {
+      dbapi.saveDeviceStatus(message.serial, message.status)
+      appDealer.send([channel, data])
+    })
+    .on(wire.DeviceHeartbeatMessage, function(channel, message, data) {
+      appDealer.send([channel, data])
+    })
+    // Worker initialized
+    .on(wire.DeviceReadyMessage, function(channel, message, data) {
+      dbapi.setDeviceReady(message.serial, message.channel)
+        .then(function() {
+          devDealer.send([
+            message.channel
+          , wireutil.envelope(new wire.ProbeMessage())
+          ])
+
+          appDealer.send([channel, data])
+        })
+    })
+    // Worker messages
+    .on(wire.JoinGroupByAdbFingerprintMessage, function(channel, message) {
+      dbapi.lookupUserByAdbFingerprint(message.fingerprint)
+        .then(function(user) {
+          if (user) {
+            devDealer.send([
+              channel
+            , wireutil.envelope(new wire.AutoGroupMessage(
+                new wire.OwnerMessage(
+                  user.email
+                , user.name
+                , user.group
+                )
+              , message.fingerprint
+              ))
+            ])
+          }
+          else if (message.currentGroup) {
+            appDealer.send([
+              message.currentGroup
+            , wireutil.envelope(new wire.JoinGroupByAdbFingerprintMessage(
+                message.serial
+              , message.fingerprint
+              , message.comment
+              ))
+            ])
+          }
+        })
+        .catch(function(err) {
+          log.error(
+            'Unable to lookup user by ADB fingerprint "%s"'
+          , message.fingerprint
+          , err.stack
+          )
+        })
+    })
+    .on(wire.JoinGroupByVncAuthResponseMessage, function(channel, message) {
+      dbapi.lookupUserByVncAuthResponse(message.response, message.serial)
+        .then(function(user) {
+          if (user) {
+            devDealer.send([
+              channel
+            , wireutil.envelope(new wire.AutoGroupMessage(
+                new wire.OwnerMessage(
+                  user.email
+                , user.name
+                , user.group
+                )
+              , message.response
+              ))
+            ])
+          }
+          else if (message.currentGroup) {
+            appDealer.send([
+              message.currentGroup
+            , wireutil.envelope(new wire.JoinGroupByVncAuthResponseMessage(
+                message.serial
+              , message.response
+              ))
+            ])
+          }
+        })
+        .catch(function(err) {
+          log.error(
+            'Unable to lookup user by VNC auth response "%s"'
+          , message.response
+          , err.stack
+          )
+        })
+    })
+    .on(wire.JoinGroupMessage, function(channel, message, data) {
+      dbapi.setDeviceOwner(message.serial, message.owner)
+      appDealer.send([channel, data])
+    })
+    .on(wire.LeaveGroupMessage, function(channel, message, data) {
+      dbapi.unsetDeviceOwner(message.serial, message.owner)
+      appDealer.send([channel, data])
+    })
+    .on(wire.DeviceLogMessage, function(channel, message, data) {
+      appDealer.send([channel, data])
+    })
+    .on(wire.DeviceIdentityMessage, function(channel, message, data) {
+      dbapi.saveDeviceIdentity(message.serial, message)
+      appDealer.send([channel, data])
+    })
+    .on(wire.TransactionProgressMessage, function(channel, message, data) {
+      appDealer.send([channel, data])
+    })
+    .on(wire.TransactionDoneMessage, function(channel, message, data) {
+      appDealer.send([channel, data])
+    })
+    .on(wire.DeviceLogcatEntryMessage, function(channel, message, data) {
+      appDealer.send([channel, data])
+    })
+    .on(wire.AirplaneModeEvent, function(channel, message, data) {
+      dbapi.setDeviceAirplaneMode(message.serial, message.enabled)
+      appDealer.send([channel, data])
+    })
+    .on(wire.BatteryEvent, function(channel, message, data) {
+      dbapi.setDeviceBattery(message.serial, message)
+      appDealer.send([channel, data])
+    })
+    .on(wire.DeviceBrowserMessage, function(channel, message, data) {
+      dbapi.setDeviceBrowser(message.serial, message)
+      appDealer.send([channel, data])
+    })
+    .on(wire.ConnectivityEvent, function(channel, message, data) {
+      dbapi.setDeviceConnectivity(message.serial, message)
+      appDealer.send([channel, data])
+    })
+    .on(wire.PhoneStateEvent, function(channel, message, data) {
+      dbapi.setDevicePhoneState(message.serial, message)
+      appDealer.send([channel, data])
+    })
+    .on(wire.RotationEvent, function(channel, message, data) {
+      dbapi.setDeviceRotation(message.serial, message.rotation)
+      appDealer.send([channel, data])
+    })
+    .on(wire.ReverseForwardsEvent, function(channel, message, data) {
+      dbapi.setDeviceReverseForwards(message.serial, message.forwards)
+      appDealer.send([channel, data])
+    })
+    .handler())
+
+  lifecycle.observe(function() {
+    [appDealer, devDealer].forEach(function(sock) {
+      try {
+        sock.close()
+      }
+      catch (err) {
+        // No-op
+      }
+    })
+  })
+}
diff --git a/crowdstf/lib/units/provider/index.js b/crowdstf/lib/units/provider/index.js
new file mode 100644
index 0000000..b3092b5
--- /dev/null
+++ b/crowdstf/lib/units/provider/index.js
@@ -0,0 +1,439 @@
+var adb = require('adbkit')
+var Promise = require('bluebird')
+var _ = require('lodash')
+var EventEmitter = require('eventemitter3').EventEmitter
+
+var logger = require('../../util/logger')
+var wire = require('../../wire')
+var wireutil = require('../../wire/util')
+var wirerouter = require('../../wire/router')
+var procutil = require('../../util/procutil')
+var lifecycle = require('../../util/lifecycle')
+var srv = require('../../util/srv')
+var zmqutil = require('../../util/zmqutil')
+
+module.exports = function(options) {
+  var log = logger.createLogger('provider')
+  var client = adb.createClient({
+    host: options.adbHost
+  , port: options.adbPort
+  })
+  var workers = {}
+  var solo = wireutil.makePrivateChannel()
+  var lists = {
+    all: []
+  , ready: []
+  , waiting: []
+  }
+  var totalsTimer
+
+  // To make sure that we always bind the same type of service to the same
+  // port, we must ensure that we allocate ports in fixed groups.
+  var ports = options.ports.slice(
+    0
+  , options.ports.length - options.ports.length % 4
+  )
+
+  // Information about total devices
+  var delayedTotals = (function() {
+    function totals() {
+      if (lists.waiting.length) {
+        log.info(
+          'Providing %d of %d device(s); waiting for "%s"'
+        , lists.ready.length
+        , lists.all.length
+        , lists.waiting.join('", "')
+        )
+
+        delayedTotals()
+      }
+      else if (lists.ready.length < lists.all.length) {
+        log.info(
+          'Providing all %d of %d device(s); ignoring "%s"'
+        , lists.ready.length
+        , lists.all.length
+        , _.difference(lists.all, lists.ready).join('", "')
+        )
+      }
+      else {
+        log.info(
+          'Providing all %d device(s)'
+        , lists.all.length
+        )
+      }
+    }
+
+    return function() {
+      clearTimeout(totalsTimer)
+      totalsTimer = setTimeout(totals, 10000)
+    }
+  })()
+
+  // Output
+  var push = zmqutil.socket('push')
+  Promise.map(options.endpoints.push, function(endpoint) {
+    return srv.resolve(endpoint).then(function(records) {
+      return srv.attempt(records, function(record) {
+        log.info('Sending output to "%s"', record.url)
+        push.connect(record.url)
+        return Promise.resolve(true)
+      })
+    })
+  })
+  .catch(function(err) {
+    log.fatal('Unable to connect to push endpoint', err)
+    lifecycle.fatal()
+  })
+
+  // Input
+  var sub = zmqutil.socket('sub')
+  Promise.map(options.endpoints.sub, function(endpoint) {
+    return srv.resolve(endpoint).then(function(records) {
+      return srv.attempt(records, function(record) {
+        log.info('Receiving input from "%s"', record.url)
+        sub.connect(record.url)
+        return Promise.resolve(true)
+      })
+    })
+  })
+  .catch(function(err) {
+    log.fatal('Unable to connect to sub endpoint', err)
+    lifecycle.fatal()
+  })
+
+  // Establish always-on channels
+  ;[solo].forEach(function(channel) {
+    log.info('Subscribing to permanent channel "%s"', channel)
+    sub.subscribe(channel)
+  })
+
+  // Track and manage devices
+  client.trackDevices().then(function(tracker) {
+    log.info('Tracking devices')
+
+    // This can happen when ADB doesn't have a good connection to
+    // the device
+    function isWeirdUnusableDevice(device) {
+      return device.id === '????????????'
+    }
+
+    // Check whether the device is remote (i.e. if we're connecting to
+    // an IP address (or hostname) and port pair).
+    function isRemoteDevice(device) {
+      return device.id.indexOf(':') !== -1
+    }
+
+    // Helper for ignoring unwanted devices
+    function filterDevice(listener) {
+      return function(device) {
+        if (isWeirdUnusableDevice(device)) {
+          log.warn('ADB lists a weird device: "%s"', device.id)
+          return false
+        }
+        if (!options.allowRemote && isRemoteDevice(device)) {
+          log.info(
+            'Filtered out remote device "%s", use --allow-remote to override'
+          , device.id
+          )
+          return false
+        }
+        if (options.filter && !options.filter(device)) {
+          log.info('Filtered out device "%s"', device.id)
+          return false
+        }
+        return listener(device)
+      }
+    }
+
+    // To make things easier, we're going to cheat a little, and make all
+    // device events go to their own EventEmitters. This way we can keep all
+    // device data in the same scope.
+    var flippedTracker = new EventEmitter()
+
+    tracker.on('add', filterDevice(function(device) {
+      log.info('Found device "%s" (%s)', device.id, device.type)
+
+      var privateTracker = new EventEmitter()
+      var willStop = false
+      var timer, worker
+
+      // Wait for others to acknowledge the device
+      var register = new Promise(function(resolve) {
+        // Tell others we found a device
+        push.send([
+          wireutil.global
+        , wireutil.envelope(new wire.DeviceIntroductionMessage(
+            device.id
+          , wireutil.toDeviceStatus(device.type)
+          , new wire.ProviderMessage(
+              solo
+            , options.name
+            )
+          ))
+        ])
+
+        privateTracker.once('register', resolve)
+      })
+
+
+      // Spawn a device worker
+      function spawn() {
+        var allocatedPorts = ports.splice(0, 4)
+        var proc = options.fork(device, allocatedPorts.slice())
+        var resolver = Promise.defer()
+
+        function exitListener(code, signal) {
+          if (signal) {
+            log.warn(
+              'Device worker "%s" was killed with signal %s, assuming ' +
+              'deliberate action and not restarting'
+              , device.id
+              , signal
+            )
+            resolver.resolve()
+          }
+          else if (code === 0) {
+            log.info('Device worker "%s" stopped cleanly', device.id)
+            resolver.resolve()
+          }
+          else {
+            resolver.reject(new procutil.ExitError(code))
+          }
+        }
+
+        function errorListener(err) {
+          log.error(
+            'Device worker "%s" had an error: %s'
+            , device.id
+            , err.message
+          )
+        }
+
+        function messageListener(message) {
+          switch (message) {
+            case 'ready':
+              _.pull(lists.waiting, device.id)
+              lists.ready.push(device.id)
+              break
+            default:
+              log.warn(
+                'Unknown message from device worker "%s": "%s"'
+                , device.id
+                , message
+              )
+              break
+          }
+        }
+
+        proc.on('exit', exitListener)
+        proc.on('error', errorListener)
+        proc.on('message', messageListener)
+
+        lists.waiting.push(device.id)
+
+        return resolver.promise
+          .cancellable()
+          .finally(function() {
+            log.info('Cleaning up device worker "%s"', device.id)
+
+            proc.removeListener('exit', exitListener)
+            proc.removeListener('error', errorListener)
+            proc.removeListener('message', messageListener)
+
+            // Return used ports to the main pool
+            Array.prototype.push.apply(ports, allocatedPorts)
+
+            // Update lists
+            _.pull(lists.ready, device.id)
+            _.pull(lists.waiting, device.id)
+          })
+          .catch(Promise.CancellationError, function() {
+            log.info('Gracefully killing device worker "%s"', device.id)
+            return procutil.gracefullyKill(proc, options.killTimeout)
+          })
+          .catch(Promise.TimeoutError, function(err) {
+            log.error(
+              'Device worker "%s" did not stop in time: %s'
+              , device.id
+              , err.message
+            )
+          })
+      }
+
+      // Starts a device worker and keeps it alive
+      function work() {
+        return (worker = workers[device.id] = spawn())
+          .then(function() {
+            log.info('Device worker "%s" has retired', device.id)
+            delete workers[device.id]
+            worker = null
+
+            // Tell others the device is gone
+            push.send([
+              wireutil.global
+              , wireutil.envelope(new wire.DeviceAbsentMessage(
+                device.id
+              ))
+            ])
+          })
+          .catch(procutil.ExitError, function(err) {
+            if (!willStop) {
+              log.error(
+                'Device worker "%s" died with code %s'
+                , device.id
+                , err.code
+              )
+              log.info('Restarting device worker "%s"', device.id)
+              return Promise.delay(500)
+                .then(function() {
+                  return work()
+                })
+            }
+          })
+      }
+
+      // No more work required
+      function stop() {
+        if (worker) {
+          log.info('Shutting down device worker "%s"', device.id)
+          worker.cancel()
+        }
+      }
+
+      // Check if we can do anything with the device
+      function check() {
+        clearTimeout(timer)
+
+        if (device.present) {
+          // We might get multiple status updates in rapid succession,
+          // so let's wait for a while
+          switch (device.type) {
+            case 'device':
+            case 'emulator':
+              willStop = false
+              timer = setTimeout(work, 100)
+              break
+            default:
+              willStop = true
+              timer = setTimeout(stop, 100)
+              break
+          }
+        }
+        else {
+          stop()
+        }
+      }
+
+      register.then(function() {
+        log.info('Registered device "%s"', device.id)
+        check()
+      })
+
+      // Statistics
+      lists.all.push(device.id)
+      delayedTotals()
+
+      // Will be set to false when the device is removed
+      _.assign(device, {
+        present: true
+      })
+
+      // When any event occurs on the added device
+      function deviceListener(type, updatedDevice) {
+        // Okay, this is a bit unnecessary but it allows us to get rid of an
+        // ugly switch statement and return to the original style.
+        privateTracker.emit(type, updatedDevice)
+      }
+
+      // When the added device changes
+      function changeListener(updatedDevice) {
+        register.then(function() {
+          log.info(
+            'Device "%s" is now "%s" (was "%s")'
+          , device.id
+          , updatedDevice.type
+          , device.type
+          )
+
+          _.assign(device, {
+            type: updatedDevice.type
+          })
+
+          // Tell others the device changed
+          push.send([
+            wireutil.global
+          , wireutil.envelope(new wire.DeviceStatusMessage(
+              device.id
+            , wireutil.toDeviceStatus(device.type)
+            ))
+          ])
+
+          check()
+        })
+      }
+
+      // When the added device gets removed
+      function removeListener() {
+        register.then(function() {
+          log.info('Lost device "%s" (%s)', device.id, device.type)
+
+          clearTimeout(timer)
+          flippedTracker.removeListener(device.id, deviceListener)
+          _.pull(lists.all, device.id)
+          delayedTotals()
+
+          // Tell others the device is gone
+          push.send([
+            wireutil.global
+          , wireutil.envelope(new wire.DeviceAbsentMessage(
+              device.id
+            ))
+          ])
+
+          _.assign(device, {
+            present: false
+          })
+
+          check()
+        })
+      }
+
+      flippedTracker.on(device.id, deviceListener)
+      privateTracker.on('change', changeListener)
+      privateTracker.on('remove', removeListener)
+    }))
+
+    tracker.on('change', filterDevice(function(device) {
+      flippedTracker.emit(device.id, 'change', device)
+    }))
+
+    tracker.on('remove', filterDevice(function(device) {
+      flippedTracker.emit(device.id, 'remove', device)
+    }))
+
+    sub.on('message', wirerouter()
+      .on(wire.DeviceRegisteredMessage, function(channel, message) {
+        flippedTracker.emit(message.serial, 'register')
+      })
+      .handler())
+
+    lifecycle.share('Tracker', tracker)
+  })
+
+  lifecycle.observe(function() {
+    [push, sub].forEach(function(sock) {
+      try {
+        sock.close()
+      }
+      catch (err) {
+        // No-op
+      }
+    })
+
+    clearTimeout(totalsTimer)
+
+    return Promise.all(Object.keys(workers).map(function(serial) {
+      return workers[serial].cancel()
+    }))
+  })
+}
diff --git a/crowdstf/lib/units/reaper/index.js b/crowdstf/lib/units/reaper/index.js
new file mode 100644
index 0000000..89c43a2
--- /dev/null
+++ b/crowdstf/lib/units/reaper/index.js
@@ -0,0 +1,126 @@
+var Promise = require('bluebird')
+
+var logger = require('../../util/logger')
+var wire = require('../../wire')
+var wireutil = require('../../wire/util')
+var wirerouter = require('../../wire/router')
+var dbapi = require('../../db/api')
+var lifecycle = require('../../util/lifecycle')
+var srv = require('../../util/srv')
+var TtlSet = require('../../util/ttlset')
+var zmqutil = require('../../util/zmqutil')
+
+module.exports = function(options) {
+  var log = logger.createLogger('reaper')
+  var ttlset = new TtlSet(options.heartbeatTimeout)
+
+  if (options.name) {
+    logger.setGlobalIdentifier(options.name)
+  }
+
+  // Input
+  var sub = zmqutil.socket('sub')
+  Promise.map(options.endpoints.sub, function(endpoint) {
+    return srv.resolve(endpoint).then(function(records) {
+      return srv.attempt(records, function(record) {
+        log.info('Receiving input from "%s"', record.url)
+        sub.connect(record.url)
+        return Promise.resolve(true)
+      })
+    })
+  })
+  .catch(function(err) {
+    log.fatal('Unable to connect to sub endpoint', err)
+    lifecycle.fatal()
+  })
+
+  // Establish always-on channels
+  ;[wireutil.global].forEach(function(channel) {
+    log.info('Subscribing to permanent channel "%s"', channel)
+    sub.subscribe(channel)
+  })
+
+  // Output
+  var push = zmqutil.socket('push')
+  Promise.map(options.endpoints.push, function(endpoint) {
+    return srv.resolve(endpoint).then(function(records) {
+      return srv.attempt(records, function(record) {
+        log.info('Sending output to "%s"', record.url)
+        push.connect(record.url)
+        return Promise.resolve(true)
+      })
+    })
+  })
+  .catch(function(err) {
+    log.fatal('Unable to connect to push endpoint', err)
+    lifecycle.fatal()
+  })
+
+  ttlset.on('insert', function(serial) {
+    log.info('Device "%s" is present', serial)
+    push.send([
+      wireutil.global
+    , wireutil.envelope(new wire.DevicePresentMessage(
+        serial
+      ))
+    ])
+  })
+
+  ttlset.on('drop', function(serial) {
+    log.info('Reaping device "%s" due to heartbeat timeout', serial)
+    push.send([
+      wireutil.global
+    , wireutil.envelope(new wire.DeviceAbsentMessage(
+        serial
+      ))
+    ])
+  })
+
+  function loadInitialState() {
+    return dbapi.loadPresentDevices()
+      .then(function(cursor) {
+        return Promise.promisify(cursor.toArray, cursor)()
+          .then(function(list) {
+            var now = Date.now()
+            list.forEach(function(device) {
+              ttlset.bump(device.serial, now, TtlSet.SILENT)
+            })
+          })
+      })
+  }
+
+  function listenToChanges() {
+    sub.on('message', wirerouter()
+      .on(wire.DeviceIntroductionMessage, function(channel, message) {
+        ttlset.drop(message.serial, TtlSet.SILENT)
+        ttlset.bump(message.serial, Date.now())
+      })
+      .on(wire.DeviceHeartbeatMessage, function(channel, message) {
+        ttlset.bump(message.serial, Date.now())
+      })
+      .on(wire.DeviceAbsentMessage, function(channel, message) {
+        ttlset.drop(message.serial, TtlSet.SILENT)
+      })
+      .handler())
+  }
+
+  log.info('Reaping devices with no heartbeat')
+
+  lifecycle.observe(function() {
+    [push, sub].forEach(function(sock) {
+      try {
+        sock.close()
+      }
+      catch (err) {
+        // No-op
+      }
+    })
+
+    ttlset.stop()
+  })
+
+  loadInitialState().then(listenToChanges).catch(function(err) {
+    log.fatal('Unable to load initial state', err)
+    lifecycle.fatal()
+  })
+}
diff --git a/crowdstf/lib/units/storage/plugins/apk/index.js b/crowdstf/lib/units/storage/plugins/apk/index.js
new file mode 100644
index 0000000..3d1f93b
--- /dev/null
+++ b/crowdstf/lib/units/storage/plugins/apk/index.js
@@ -0,0 +1,58 @@
+var http = require('http')
+var url = require('url')
+var util = require('util')
+
+var express = require('express')
+var request = require('request')
+
+var logger = require('../../../../util/logger')
+var download = require('../../../../util/download')
+var manifest = require('./task/manifest')
+
+module.exports = function(options) {
+  var log = logger.createLogger('storage:plugins:apk')
+  var app = express()
+  var server = http.createServer(app)
+
+  app.set('strict routing', true)
+  app.set('case sensitive routing', true)
+  app.set('trust proxy', true)
+
+  app.get('/s/apk/:id/:name/manifest', function(req, res) {
+    var orig = util.format(
+      '/s/blob/%s/%s'
+    , req.params.id
+    , req.params.name
+    )
+    download(url.resolve(options.storageUrl, orig), {
+        dir: options.cacheDir
+      })
+      .then(manifest)
+      .then(function(data) {
+        res.status(200)
+          .json({
+            success: true
+          , manifest: data
+          })
+      })
+      .catch(function(err) {
+        log.error('Unable to read manifest of "%s"', req.params.id, err.stack)
+        res.status(500)
+          .json({
+            success: false
+          })
+      })
+  })
+
+  app.get('/s/apk/:id/:name', function(req, res) {
+    request(url.resolve(options.storageUrl, util.format(
+      '/s/blob/%s/%s'
+    , req.params.id
+    , req.params.name
+    )))
+    .pipe(res)
+  })
+
+  server.listen(options.port)
+  log.info('Listening on port %d', options.port)
+}
diff --git a/crowdstf/lib/units/storage/plugins/apk/task/manifest.js b/crowdstf/lib/units/storage/plugins/apk/task/manifest.js
new file mode 100644
index 0000000..6a8f8ef
--- /dev/null
+++ b/crowdstf/lib/units/storage/plugins/apk/task/manifest.js
@@ -0,0 +1,19 @@
+var Promise = require('bluebird')
+var ApkReader = require('adbkit-apkreader')
+
+module.exports = function(file) {
+  var resolver = Promise.defer()
+
+  process.nextTick(function() {
+    try {
+      var reader = ApkReader.readFile(file.path)
+      var manifest = reader.readManifestSync()
+      resolver.resolve(manifest)
+    }
+    catch (err) {
+      resolver.reject(err)
+    }
+  })
+
+  return resolver.promise
+}
diff --git a/crowdstf/lib/units/storage/plugins/image/index.js b/crowdstf/lib/units/storage/plugins/image/index.js
new file mode 100644
index 0000000..b35e883
--- /dev/null
+++ b/crowdstf/lib/units/storage/plugins/image/index.js
@@ -0,0 +1,64 @@
+var http = require('http')
+var util = require('util')
+
+var express = require('express')
+
+var logger = require('../../../../util/logger')
+var requtil = require('../../../../util/requtil')
+
+var parseCrop = require('./param/crop')
+var parseGravity = require('./param/gravity')
+var get = require('./task/get')
+var transform = require('./task/transform')
+
+module.exports = function(options) {
+  var log = logger.createLogger('storage:plugins:image')
+  var app = express()
+  var server = http.createServer(app)
+
+  app.set('strict routing', true)
+  app.set('case sensitive routing', true)
+  app.set('trust proxy', true)
+
+  app.get(
+    '/s/image/:id/:name'
+  , requtil.limit(options.concurrency, function(req, res) {
+      var orig = util.format(
+        '/s/blob/%s/%s'
+      , req.params.id
+      , req.params.name
+      )
+      return get(orig, options)
+        .then(function(stream) {
+          return transform(stream, {
+            crop: parseCrop(req.query.crop)
+          , gravity: parseGravity(req.query.gravity)
+          })
+        })
+        .then(function(out) {
+          res.status(200)
+
+          if (typeof req.query.download !== 'undefined') {
+            res.set('Content-Disposition',
+              'attachment; filename="' + req.params['0'] + '"')
+          }
+
+          out.pipe(res)
+        })
+        .catch(function(err) {
+          log.error(
+            'Unable to transform resource "%s"'
+          , req.params.id
+          , err.stack
+          )
+          res.status(500)
+            .json({
+              success: false
+            })
+        })
+    })
+  )
+
+  server.listen(options.port)
+  log.info('Listening on port %d', options.port)
+}
diff --git a/crowdstf/lib/units/storage/plugins/image/param/crop.js b/crowdstf/lib/units/storage/plugins/image/param/crop.js
new file mode 100644
index 0000000..a260dd6
--- /dev/null
+++ b/crowdstf/lib/units/storage/plugins/image/param/crop.js
@@ -0,0 +1,14 @@
+var RE_CROP = /^([0-9]*)x([0-9]*)$/
+
+module.exports = function(raw) {
+  var parsed
+
+  if (raw && (parsed = RE_CROP.exec(raw))) {
+    return {
+      width: Number(parsed[1]) || 0
+    , height: Number(parsed[2]) || 0
+    }
+  }
+
+  return null
+}
diff --git a/crowdstf/lib/units/storage/plugins/image/param/gravity.js b/crowdstf/lib/units/storage/plugins/image/param/gravity.js
new file mode 100644
index 0000000..bb6ecb6
--- /dev/null
+++ b/crowdstf/lib/units/storage/plugins/image/param/gravity.js
@@ -0,0 +1,21 @@
+var GRAVITY = {
+  northwest: 'NorthWest'
+, north: 'North'
+, northeast: 'NorthEast'
+, west: 'West'
+, center: 'Center'
+, east: 'East'
+, southwest: 'SouthWest'
+, south: 'South'
+, southeast: 'SouthEast'
+}
+
+module.exports = function(raw) {
+  var parsed
+
+  if (raw && (parsed = GRAVITY[raw])) {
+    return parsed
+  }
+
+  return null
+}
diff --git a/crowdstf/lib/units/storage/plugins/image/task/get.js b/crowdstf/lib/units/storage/plugins/image/task/get.js
new file mode 100644
index 0000000..8fe09c0
--- /dev/null
+++ b/crowdstf/lib/units/storage/plugins/image/task/get.js
@@ -0,0 +1,23 @@
+var util = require('util')
+var stream = require('stream')
+var url = require('url')
+
+var Promise = require('bluebird')
+var request = require('request')
+
+module.exports = function(path, options) {
+  return new Promise(function(resolve, reject) {
+    var res = request.get(url.resolve(options.storageUrl, path))
+    var ret = new stream.Readable().wrap(res) // Wrap old-style stream
+
+    res.on('response', function(res) {
+        if (res.statusCode !== 200) {
+          reject(new Error(util.format('HTTP %d', res.statusCode)))
+        }
+        else {
+          resolve(ret)
+        }
+      })
+      .on('error', reject)
+  })
+}
diff --git a/crowdstf/lib/units/storage/plugins/image/task/transform.js b/crowdstf/lib/units/storage/plugins/image/task/transform.js
new file mode 100644
index 0000000..b87ccbb
--- /dev/null
+++ b/crowdstf/lib/units/storage/plugins/image/task/transform.js
@@ -0,0 +1,26 @@
+var gm = require('gm')
+var Promise = require('bluebird')
+
+module.exports = function(stream, options) {
+  return new Promise(function(resolve, reject) {
+    var transform = gm(stream)
+
+    if (options.gravity) {
+      transform.gravity(options.gravity)
+    }
+
+    if (options.crop) {
+      transform.geometry(options.crop.width, options.crop.height, '^')
+      transform.crop(options.crop.width, options.crop.height, 0, 0)
+    }
+
+    transform.stream(function(err, stdout) {
+      if (err) {
+        reject(err)
+      }
+      else {
+        resolve(stdout)
+      }
+    })
+  })
+}
diff --git a/crowdstf/lib/units/storage/s3.js b/crowdstf/lib/units/storage/s3.js
new file mode 100644
index 0000000..f690494
--- /dev/null
+++ b/crowdstf/lib/units/storage/s3.js
@@ -0,0 +1,155 @@
+var http = require('http')
+var util = require('util')
+var path = require('path')
+var fs = require('fs')
+
+var express = require('express')
+var validator = require('express-validator')
+var bodyParser = require('body-parser')
+var formidable = require('formidable')
+var Promise = require('bluebird')
+var uuid = require('node-uuid')
+var AWS = require('aws-sdk')
+
+var logger = require('../../util/logger')
+
+module.exports = function(options) {
+  var log = logger.createLogger('storage:s3')
+  var app = express()
+  var server = http.createServer(app)
+
+  var s3 = new AWS.S3({
+    credentials: new AWS.SharedIniFileCredentials({
+      profile: options.profile
+    })
+  , endpoint: options.endpoint
+  })
+
+  app.set('strict routing', true)
+  app.set('case sensitive routing', true)
+  app.set('trust proxy', true)
+
+  app.use(bodyParser.json())
+  app.use(validator())
+
+  function putObject(plugin, file) {
+    return new Promise(function(resolve, reject) {
+      var id = uuid.v4()
+      s3.putObject({
+        Key: id
+      , Body: fs.createReadStream(file.path)
+      , Bucket: options.bucket
+      , Metadata: {
+          plugin: plugin
+        , name: file.name
+        }
+      }, function(err) {
+        if (err) {
+          log.error(
+            'Unable to store "%s" as "%s/%s"'
+          , file.temppath
+          , options.bucket
+          , id
+          , err.stack
+          )
+          reject(err)
+        }
+        else {
+          log.info('Stored "%s" as "%s/%s"', file.name, options.bucket, id)
+          resolve(id)
+        }
+      })
+    })
+  }
+
+  function getHref(plugin, id, name) {
+    return util.format(
+      '/s/%s/%s%s'
+    , plugin
+    , id
+    , name ? '/' + path.basename(name) : ''
+    )
+  }
+
+  app.post('/s/upload/:plugin', function(req, res) {
+    var form = new formidable.IncomingForm()
+    var plugin = req.params.plugin
+    Promise.promisify(form.parse, form)(req)
+      .spread(function(fields, files) {
+        var requests = Object.keys(files).map(function(field) {
+          var file = files[field]
+          log.info('Uploaded "%s" to "%s"', file.name, file.path)
+          return putObject(plugin, file)
+            .then(function(id) {
+              return {
+                field: field
+              , id: id
+              , name: file.name
+              , temppath: file.path
+              }
+            })
+        })
+        return Promise.all(requests)
+      })
+      .then(function(storedFiles) {
+        res.status(201).json({
+          success: true
+        , resources: (function() {
+            var mapped = Object.create(null)
+            storedFiles.forEach(function(file) {
+              mapped[file.field] = {
+                date: new Date()
+              , plugin: plugin
+              , id: file.id
+              , name: file.name
+              , href: getHref(plugin, file.id, file.name)
+              }
+            })
+            return mapped
+          })()
+        })
+        return storedFiles
+      })
+      .then(function(storedFiles) {
+        return Promise.all(storedFiles.map(function(file) {
+          return Promise.promisify(fs.unlink, fs)(file.temppath)
+            .catch(function(err) {
+              log.warn('Unable to clean up "%s"', file.temppath, err.stack)
+              return true
+            })
+        }))
+      })
+      .catch(function(err) {
+        log.error('Error storing resource', err.stack)
+        res.status(500)
+          .json({
+            success: false
+          , error: 'ServerError'
+          })
+      })
+  })
+
+  app.get('/s/blob/:id/:name', function(req, res) {
+    var params = {
+      Key: req.params.id
+    , Bucket: options.bucket
+    }
+
+    s3.getObject(params, function(err, data) {
+      if (err) {
+        log.error('Unable to retrieve "%s"', path, err.stack)
+        res.sendStatus(404)
+        return
+      }
+
+      res.set({
+        'Content-Type': data.ContentType
+      })
+
+      res.send(data.Body)
+    })
+  })
+
+  server.listen(options.port)
+  log.info('Listening on port %d', options.port)
+}
diff --git a/crowdstf/lib/units/storage/temp.js b/crowdstf/lib/units/storage/temp.js
new file mode 100644
index 0000000..4e337c9
--- /dev/null
+++ b/crowdstf/lib/units/storage/temp.js
@@ -0,0 +1,153 @@
+var http = require('http')
+var util = require('util')
+var path = require('path')
+
+var express = require('express')
+var validator = require('express-validator')
+var bodyParser = require('body-parser')
+var formidable = require('formidable')
+var Promise = require('bluebird')
+
+var logger = require('../../util/logger')
+var Storage = require('../../util/storage')
+var requtil = require('../../util/requtil')
+var download = require('../../util/download')
+
+module.exports = function(options) {
+  var log = logger.createLogger('storage:temp')
+  var app = express()
+  var server = http.createServer(app)
+  var storage = new Storage()
+
+  app.set('strict routing', true)
+  app.set('case sensitive routing', true)
+  app.set('trust proxy', true)
+
+  app.use(bodyParser.json())
+  app.use(validator())
+
+  storage.on('timeout', function(id) {
+    log.info('Cleaning up inactive resource "%s"', id)
+  })
+
+  app.post('/s/download/:plugin', function(req, res) {
+    requtil.validate(req, function() {
+        req.checkBody('url').notEmpty()
+      })
+      .then(function() {
+        return download(req.body.url, {
+          dir: options.cacheDir
+        })
+      })
+      .then(function(file) {
+        return {
+          id: storage.store(file)
+        , name: file.name
+        }
+      })
+      .then(function(file) {
+        var plugin = req.params.plugin
+        res.status(201)
+          .json({
+            success: true
+          , resource: {
+              date: new Date()
+            , plugin: plugin
+            , id: file.id
+            , name: file.name
+            , href: util.format(
+                '/s/%s/%s%s'
+              , plugin
+              , file.id
+              , file.name ? util.format('/%s', path.basename(file.name)) : ''
+              )
+            }
+          })
+      })
+      .catch(requtil.ValidationError, function(err) {
+        res.status(400)
+          .json({
+            success: false
+          , error: 'ValidationError'
+          , validationErrors: err.errors
+          })
+      })
+      .catch(function(err) {
+        log.error('Error storing resource', err.stack)
+        res.status(500)
+          .json({
+            success: false
+          , error: 'ServerError'
+          })
+      })
+  })
+
+  app.post('/s/upload/:plugin', function(req, res) {
+    var form = new formidable.IncomingForm()
+    Promise.promisify(form.parse, form)(req)
+      .spread(function(fields, files) {
+        return Object.keys(files).map(function(field) {
+          var file = files[field]
+          log.info('Uploaded "%s" to "%s"', file.name, file.path)
+          return {
+            field: field
+          , id: storage.store(file)
+          , name: file.name
+          }
+        })
+      })
+      .then(function(storedFiles) {
+        res.status(201)
+          .json({
+            success: true
+          , resources: (function() {
+              var mapped = Object.create(null)
+              storedFiles.forEach(function(file) {
+                var plugin = req.params.plugin
+                mapped[file.field] = {
+                  date: new Date()
+                , plugin: plugin
+                , id: file.id
+                , name: file.name
+                , href: util.format(
+                    '/s/%s/%s%s'
+                  , plugin
+                  , file.id
+                  , file.name ?
+                      util.format('/%s', path.basename(file.name)) :
+                      ''
+                  )
+                }
+              })
+              return mapped
+            })()
+          })
+      })
+      .catch(function(err) {
+        log.error('Error storing resource', err.stack)
+        res.status(500)
+          .json({
+            success: false
+          , error: 'ServerError'
+          })
+      })
+  })
+
+  app.get('/s/blob/:id/:name', function(req, res) {
+    var file = storage.retrieve(req.params.id)
+    if (file) {
+      if (typeof req.query.download !== 'undefined') {
+        res.set('Content-Disposition',
+          'attachment; filename="' + path.basename(file.name) + '"')
+      }
+      res.set('Content-Type', file.type)
+      res.sendFile(file.path)
+    }
+    else {
+      res.sendStatus(404)
+    }
+  })
+
+  server.listen(options.port)
+  log.info('Listening on port %d', options.port)
+}
diff --git a/crowdstf/lib/units/triproxy/index.js b/crowdstf/lib/units/triproxy/index.js
new file mode 100644
index 0000000..1632d18
--- /dev/null
+++ b/crowdstf/lib/units/triproxy/index.js
@@ -0,0 +1,45 @@
+var logger = require('../../util/logger')
+var lifecycle = require('../../util/lifecycle')
+var zmqutil = require('../../util/zmqutil')
+
+module.exports = function(options) {
+  var log = logger.createLogger('triproxy')
+
+  if (options.name) {
+    logger.setGlobalIdentifier(options.name)
+  }
+
+  function proxy(to) {
+    return function() {
+      to.send([].slice.call(arguments))
+    }
+  }
+
+  // App/device output
+  var pub = zmqutil.socket('pub')
+  pub.bindSync(options.endpoints.pub)
+  log.info('PUB socket bound on', options.endpoints.pub)
+
+  // Coordinator input/output
+  var dealer = zmqutil.socket('dealer')
+  dealer.bindSync(options.endpoints.dealer)
+  dealer.on('message', proxy(pub))
+  log.info('DEALER socket bound on', options.endpoints.dealer)
+
+  // App/device input
+  var pull = zmqutil.socket('pull')
+  pull.bindSync(options.endpoints.pull)
+  pull.on('message', proxy(dealer))
+  log.info('PULL socket bound on', options.endpoints.pull)
+
+  lifecycle.observe(function() {
+    [pub, dealer, pull].forEach(function(sock) {
+      try {
+        sock.close()
+      }
+      catch (err) {
+        // No-op
+      }
+    })
+  })
+}
diff --git a/crowdstf/lib/units/websocket/index.js b/crowdstf/lib/units/websocket/index.js
new file mode 100644
index 0000000..a04e388
--- /dev/null
+++ b/crowdstf/lib/units/websocket/index.js
@@ -0,0 +1,936 @@
+var http = require('http')
+var events = require('events')
+var util = require('util')
+
+var socketio = require('socket.io')
+var Promise = require('bluebird')
+var _ = require('lodash')
+var request = Promise.promisifyAll(require('request'))
+var adbkit = require('adbkit')
+var uuid = require('node-uuid')
+
+var logger = require('../../util/logger')
+var wire = require('../../wire')
+var wireutil = require('../../wire/util')
+var wirerouter = require('../../wire/router')
+var dbapi = require('../../db/api')
+var datautil = require('../../util/datautil')
+var srv = require('../../util/srv')
+var lifecycle = require('../../util/lifecycle')
+var zmqutil = require('../../util/zmqutil')
+var cookieSession = require('./middleware/cookie-session')
+var ip = require('./middleware/remote-ip')
+var auth = require('./middleware/auth')
+var jwtutil = require('../../util/jwtutil')
+
+module.exports = function(options) {
+  var log = logger.createLogger('websocket')
+  var server = http.createServer()
+  var io = socketio.listen(server, {
+        serveClient: false
+      , transports: ['websocket']
+      })
+  var channelRouter = new events.EventEmitter()
+
+  // Output
+  var push = zmqutil.socket('push')
+  Promise.map(options.endpoints.push, function(endpoint) {
+    return srv.resolve(endpoint).then(function(records) {
+      return srv.attempt(records, function(record) {
+        log.info('Sending output to "%s"', record.url)
+        push.connect(record.url)
+        return Promise.resolve(true)
+      })
+    })
+  })
+  .catch(function(err) {
+    log.fatal('Unable to connect to push endpoint', err)
+    lifecycle.fatal()
+  })
+
+  // Input
+  var sub = zmqutil.socket('sub')
+  Promise.map(options.endpoints.sub, function(endpoint) {
+    return srv.resolve(endpoint).then(function(records) {
+      return srv.attempt(records, function(record) {
+        log.info('Receiving input from "%s"', record.url)
+        sub.connect(record.url)
+        return Promise.resolve(true)
+      })
+    })
+  })
+  .catch(function(err) {
+    log.fatal('Unable to connect to sub endpoint', err)
+    lifecycle.fatal()
+  })
+
+  // Establish always-on channels
+  ;[wireutil.global].forEach(function(channel) {
+    log.info('Subscribing to permanent channel "%s"', channel)
+    sub.subscribe(channel)
+  })
+
+  sub.on('message', function(channel, data) {
+    channelRouter.emit(channel.toString(), channel, data)
+  })
+
+  io.use(cookieSession({
+    name: options.ssid
+  , keys: [options.secret]
+  }))
+
+  io.use(ip({
+    trust: function() {
+      return true
+    }
+  }))
+
+  io.use(auth)
+
+  io.on('connection', function(socket) {
+    var req = socket.request
+    var user = req.user
+    var channels = []
+
+    user.ip = socket.handshake.query.uip || req.ip
+    socket.emit('socket.ip', user.ip)
+
+    function joinChannel(channel) {
+      channels.push(channel)
+      channelRouter.on(channel, messageListener)
+      sub.subscribe(channel)
+    }
+
+    function leaveChannel(channel) {
+      _.pull(channels, channel)
+      channelRouter.removeListener(channel, messageListener)
+      sub.unsubscribe(channel)
+    }
+
+    function createKeyHandler(Klass) {
+      return function(channel, data) {
+        push.send([
+          channel
+        , wireutil.envelope(new Klass(
+            data.key
+          ))
+        ])
+      }
+    }
+
+    var messageListener = wirerouter()
+      .on(wire.DeviceLogMessage, function(channel, message) {
+        socket.emit('device.log', message)
+      })
+      .on(wire.DeviceIntroductionMessage, function(channel, message) {
+        socket.emit('device.add', {
+          important: true
+        , data: {
+            serial: message.serial
+          , present: false
+          , provider: message.provider
+          , owner: null
+          , status: message.status
+          , ready: false
+          , reverseForwards: []
+          }
+        })
+      })
+      .on(wire.DeviceReadyMessage, function(channel, message) {
+        socket.emit('device.change', {
+          important: true
+        , data: {
+            serial: message.serial
+          , channel: message.channel
+          , owner: null // @todo Get rid of need to reset this here.
+          , ready: true
+          , reverseForwards: [] // @todo Get rid of need to reset this here.
+          }
+        })
+      })
+      .on(wire.DevicePresentMessage, function(channel, message) {
+        socket.emit('device.change', {
+          important: true
+        , data: {
+            serial: message.serial
+          , present: true
+          }
+        })
+      })
+      .on(wire.DeviceAbsentMessage, function(channel, message) {
+        socket.emit('device.remove', {
+          important: true
+        , data: {
+            serial: message.serial
+          , present: false
+          , likelyLeaveReason: 'device_absent'
+          }
+        })
+      })
+      .on(wire.JoinGroupMessage, function(channel, message) {
+        socket.emit('device.change', {
+          important: true
+        , data: datautil.applyOwner({
+              serial: message.serial
+            , owner: message.owner
+            , likelyLeaveReason: 'owner_change'
+            }
+          , user
+          )
+        })
+      })
+      .on(wire.JoinGroupByAdbFingerprintMessage, function(channel, message) {
+        socket.emit('user.keys.adb.confirm', {
+          title: message.comment
+        , fingerprint: message.fingerprint
+        })
+      })
+      .on(wire.LeaveGroupMessage, function(channel, message) {
+        socket.emit('device.change', {
+          important: true
+        , data: datautil.applyOwner({
+              serial: message.serial
+            , owner: null
+            , likelyLeaveReason: message.reason
+            }
+          , user
+          )
+        })
+      })
+      .on(wire.DeviceStatusMessage, function(channel, message) {
+        message.likelyLeaveReason = 'status_change'
+        socket.emit('device.change', {
+          important: true
+        , data: message
+        })
+      })
+      .on(wire.DeviceIdentityMessage, function(channel, message) {
+        datautil.applyData(message)
+        socket.emit('device.change', {
+          important: true
+        , data: message
+        })
+      })
+      .on(wire.TransactionProgressMessage, function(channel, message) {
+        socket.emit('tx.progress', channel.toString(), message)
+      })
+      .on(wire.TransactionDoneMessage, function(channel, message) {
+        socket.emit('tx.done', channel.toString(), message)
+      })
+      .on(wire.DeviceLogcatEntryMessage, function(channel, message) {
+        socket.emit('logcat.entry', message)
+      })
+      .on(wire.AirplaneModeEvent, function(channel, message) {
+        socket.emit('device.change', {
+          important: true
+        , data: {
+            serial: message.serial
+          , airplaneMode: message.enabled
+          }
+        })
+      })
+      .on(wire.BatteryEvent, function(channel, message) {
+        var serial = message.serial
+        delete message.serial
+        socket.emit('device.change', {
+          important: false
+        , data: {
+            serial: serial
+          , battery: message
+          }
+        })
+      })
+      .on(wire.DeviceBrowserMessage, function(channel, message) {
+        var serial = message.serial
+        delete message.serial
+        socket.emit('device.change', {
+          important: true
+        , data: datautil.applyBrowsers({
+            serial: serial
+          , browser: message
+          })
+        })
+      })
+      .on(wire.ConnectivityEvent, function(channel, message) {
+        var serial = message.serial
+        delete message.serial
+        socket.emit('device.change', {
+          important: false
+        , data: {
+            serial: serial
+          , network: message
+          }
+        })
+      })
+      .on(wire.PhoneStateEvent, function(channel, message) {
+        var serial = message.serial
+        delete message.serial
+        socket.emit('device.change', {
+          important: false
+        , data: {
+            serial: serial
+          , network: message
+          }
+        })
+      })
+      .on(wire.RotationEvent, function(channel, message) {
+        socket.emit('device.change', {
+          important: false
+        , data: {
+            serial: message.serial
+          , display: {
+              rotation: message.rotation
+            }
+          }
+        })
+      })
+      .on(wire.ReverseForwardsEvent, function(channel, message) {
+        socket.emit('device.change', {
+          important: false
+        , data: {
+            serial: message.serial
+          , reverseForwards: message.forwards
+          }
+        })
+      })
+      .handler()
+
+    // Global messages
+    //
+    // @todo Use socket.io to push global events to all clients instead
+    // of listening on every connection, otherwise we're very likely to
+    // hit EventEmitter's leak complaints (plus it's more work)
+    channelRouter.on(wireutil.global, messageListener)
+
+    // User's private group
+    joinChannel(user.group)
+
+    new Promise(function(resolve) {
+      socket.on('disconnect', resolve)
+        // Global messages for all clients using socket.io
+        //
+        // Device note
+        .on('device.note', function(data) {
+          return dbapi.setDeviceNote(data.serial, data.note)
+            .then(function() {
+              return dbapi.loadDevice(data.serial)
+            })
+            .then(function(device) {
+              if (device) {
+                io.emit('device.change', {
+                  important: true
+                , data: {
+                    serial: device.serial
+                  , notes: device.notes
+                  }
+                })
+              }
+            })
+        })
+        // Client specific messages
+        //
+        // Settings
+        .on('user.settings.update', function(data) {
+          dbapi.updateUserSettings(user.email, data)
+        })
+        .on('user.settings.reset', function() {
+          dbapi.resetUserSettings(user.email)
+        })
+        .on('user.keys.accessToken.generate', function(data) {
+          var jwt = jwtutil.encode({
+            payload: {
+              email: user.email
+            , name: user.name
+            }
+          , secret: options.secret
+          })
+
+          var tokenId = util.format('%s-%s', uuid.v4(), uuid.v4()).replace(/-/g, '')
+          var title = data.title
+
+          return dbapi.saveUserAccessToken(user.email, {
+            title: title
+          , id: tokenId
+          , jwt: jwt
+          })
+            .then(function() {
+              socket.emit('user.keys.accessToken.generated', {
+                title: title
+              , tokenId: tokenId
+              })
+            })
+        })
+        .on('user.keys.accessToken.remove', function(data) {
+          return dbapi.removeUserAccessToken(user.email, data.title)
+            .then(function() {
+              socket.emit('user.keys.accessToken.removed', data.title)
+            })
+        })
+        .on('user.keys.adb.add', function(data) {
+          return adbkit.util.parsePublicKey(data.key)
+            .then(function(key) {
+              return dbapi.lookupUsersByAdbKey(key.fingerprint)
+                .then(function(cursor) {
+                  return cursor.toArray()
+                })
+                .then(function(users) {
+                  if (users.length) {
+                    throw new dbapi.DuplicateSecondaryIndexError()
+                  }
+                  else {
+                    return dbapi.insertUserAdbKey(user.email, {
+                      title: data.title
+                    , fingerprint: key.fingerprint
+                    })
+                  }
+                })
+                .then(function() {
+                  socket.emit('user.keys.adb.added', {
+                    title: data.title
+                  , fingerprint: key.fingerprint
+                  })
+                })
+            })
+            .then(function() {
+              push.send([
+                wireutil.global
+              , wireutil.envelope(new wire.AdbKeysUpdatedMessage())
+              ])
+            })
+            .catch(dbapi.DuplicateSecondaryIndexError, function() {
+              // No-op
+            })
+        })
+        .on('user.keys.adb.accept', function(data) {
+          return dbapi.lookupUsersByAdbKey(data.fingerprint)
+            .then(function(cursor) {
+              return cursor.toArray()
+            })
+            .then(function(users) {
+              if (users.length) {
+                throw new dbapi.DuplicateSecondaryIndexError()
+              }
+              else {
+                return dbapi.insertUserAdbKey(user.email, {
+                  title: data.title
+                , fingerprint: data.fingerprint
+                })
+              }
+            })
+            .then(function() {
+              socket.emit('user.keys.adb.added', {
+                title: data.title
+              , fingerprint: data.fingerprint
+              })
+            })
+            .then(function() {
+              push.send([
+                user.group
+              , wireutil.envelope(new wire.AdbKeysUpdatedMessage())
+              ])
+            })
+            .catch(dbapi.DuplicateSecondaryIndexError, function() {
+              // No-op
+            })
+        })
+        .on('user.keys.adb.remove', function(data) {
+          return dbapi.deleteUserAdbKey(user.email, data.fingerprint)
+            .then(function() {
+              socket.emit('user.keys.adb.removed', data)
+            })
+        })
+        // Touch events
+        .on('input.touchDown', function(channel, data) {
+          push.send([
+            channel
+          , wireutil.envelope(new wire.TouchDownMessage(
+              data.seq
+            , data.contact
+            , data.x
+            , data.y
+            , data.pressure
+            ))
+          ])
+        })
+        .on('input.touchMove', function(channel, data) {
+          push.send([
+            channel
+          , wireutil.envelope(new wire.TouchMoveMessage(
+              data.seq
+            , data.contact
+            , data.x
+            , data.y
+            , data.pressure
+            ))
+          ])
+        })
+        .on('input.touchUp', function(channel, data) {
+          push.send([
+            channel
+          , wireutil.envelope(new wire.TouchUpMessage(
+              data.seq
+            , data.contact
+            ))
+          ])
+        })
+        .on('input.touchCommit', function(channel, data) {
+          push.send([
+            channel
+          , wireutil.envelope(new wire.TouchCommitMessage(
+              data.seq
+            ))
+          ])
+        })
+        .on('input.touchReset', function(channel, data) {
+          push.send([
+            channel
+          , wireutil.envelope(new wire.TouchResetMessage(
+              data.seq
+            ))
+          ])
+        })
+        .on('input.gestureStart', function(channel, data) {
+          push.send([
+            channel
+          , wireutil.envelope(new wire.GestureStartMessage(
+              data.seq
+            ))
+          ])
+        })
+        .on('input.gestureStop', function(channel, data) {
+          push.send([
+            channel
+          , wireutil.envelope(new wire.GestureStopMessage(
+              data.seq
+            ))
+          ])
+        })
+        // Key events
+        .on('input.keyDown', createKeyHandler(wire.KeyDownMessage))
+        .on('input.keyUp', createKeyHandler(wire.KeyUpMessage))
+        .on('input.keyPress', createKeyHandler(wire.KeyPressMessage))
+        .on('input.type', function(channel, data) {
+          push.send([
+            channel
+          , wireutil.envelope(new wire.TypeMessage(
+              data.text
+            ))
+          ])
+        })
+        .on('display.rotate', function(channel, data) {
+          push.send([
+            channel
+          , wireutil.envelope(new wire.RotateMessage(
+              data.rotation
+            ))
+          ])
+        })
+        // Transactions
+        .on('clipboard.paste', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.PasteMessage(data.text)
+            )
+          ])
+        })
+        .on('clipboard.copy', function(channel, responseChannel) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.CopyMessage()
+            )
+          ])
+        })
+        .on('device.identify', function(channel, responseChannel) {
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.PhysicalIdentifyMessage()
+            )
+          ])
+        })
+        .on('device.reboot', function(channel, responseChannel) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.RebootMessage()
+            )
+          ])
+        })
+        .on('account.check', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.AccountCheckMessage(data)
+            )
+          ])
+        })
+        .on('account.remove', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.AccountRemoveMessage(data)
+            )
+          ])
+        })
+        .on('account.addmenu', function(channel, responseChannel) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.AccountAddMenuMessage()
+            )
+          ])
+        })
+        .on('account.add', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.AccountAddMessage(data.user, data.password)
+            )
+          ])
+        })
+        .on('account.get', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.AccountGetMessage(data)
+            )
+          ])
+        })
+        .on('sd.status', function(channel, responseChannel) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.SdStatusMessage()
+            )
+          ])
+        })
+        .on('ringer.set', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.RingerSetMessage(data.mode)
+            )
+          ])
+        })
+        .on('ringer.get', function(channel, responseChannel) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.RingerGetMessage()
+            )
+          ])
+        })
+        .on('wifi.set', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.WifiSetEnabledMessage(data.enabled)
+            )
+          ])
+        })
+        .on('wifi.get', function(channel, responseChannel) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.WifiGetStatusMessage()
+            )
+          ])
+        })
+        .on('group.invite', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.GroupMessage(
+                new wire.OwnerMessage(
+                  user.email
+                , user.name
+                , user.group
+                )
+              , data.timeout || null
+              , wireutil.toDeviceRequirements(data.requirements)
+              )
+            )
+          ])
+        })
+        .on('group.kick', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.UngroupMessage(
+                wireutil.toDeviceRequirements(data.requirements)
+              )
+            )
+          ])
+        })
+        .on('tx.cleanup', function(channel) {
+          leaveChannel(channel)
+        })
+        .on('tx.punch', function(channel) {
+          joinChannel(channel)
+          socket.emit('tx.punch', channel)
+        })
+        .on('shell.command', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.ShellCommandMessage(data)
+            )
+          ])
+        })
+        .on('shell.keepalive', function(channel, data) {
+          push.send([
+            channel
+          , wireutil.envelope(new wire.ShellKeepAliveMessage(data))
+          ])
+        })
+        .on('device.install', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.InstallMessage(
+                data.href
+              , data.launch === true
+              , JSON.stringify(data.manifest)
+              )
+            )
+          ])
+        })
+        .on('device.uninstall', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.UninstallMessage(data)
+            )
+          ])
+        })
+        .on('storage.upload', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          request.postAsync({
+              url: util.format(
+                '%sapi/v1/resources?channel=%s'
+              , options.storageUrl
+              , responseChannel
+              )
+            , json: true
+            , body: {
+                url: data.url
+              }
+            })
+            .catch(function(err) {
+              log.error('Storage upload had an error', err.stack)
+              leaveChannel(responseChannel)
+              socket.emit('tx.cancel', responseChannel, {
+                success: false
+              , data: 'fail_upload'
+              })
+            })
+        })
+        .on('forward.test', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          if (!data.targetHost || data.targetHost === 'localhost') {
+            data.targetHost = user.ip
+          }
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.ForwardTestMessage(data)
+            )
+          ])
+        })
+        .on('forward.create', function(channel, responseChannel, data) {
+          if (!data.targetHost || data.targetHost === 'localhost') {
+            data.targetHost = user.ip
+          }
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.ForwardCreateMessage(data)
+            )
+          ])
+        })
+        .on('forward.remove', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.ForwardRemoveMessage(data)
+            )
+          ])
+        })
+        .on('logcat.start', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.LogcatStartMessage(data)
+            )
+          ])
+        })
+        .on('logcat.stop', function(channel, responseChannel) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.LogcatStopMessage()
+            )
+          ])
+        })
+        .on('connect.start', function(channel, responseChannel) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.ConnectStartMessage()
+            )
+          ])
+        })
+        .on('connect.stop', function(channel, responseChannel) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.ConnectStopMessage()
+            )
+          ])
+        })
+        .on('browser.open', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.BrowserOpenMessage(data)
+            )
+          ])
+        })
+        .on('browser.clear', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.BrowserClearMessage(data)
+            )
+          ])
+        })
+        .on('store.open', function(channel, responseChannel) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.StoreOpenMessage()
+            )
+          ])
+        })
+        .on('screen.capture', function(channel, responseChannel) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.ScreenCaptureMessage()
+            )
+          ])
+        })
+        .on('fs.retrieve', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.FileSystemGetMessage(data)
+            )
+          ])
+        })
+        .on('fs.list', function(channel, responseChannel, data) {
+          joinChannel(responseChannel)
+          push.send([
+            channel
+          , wireutil.transaction(
+              responseChannel
+            , new wire.FileSystemListMessage(data)
+            )
+          ])
+        })
+    })
+    .finally(function() {
+      // Clean up all listeners and subscriptions
+      channelRouter.removeListener(wireutil.global, messageListener)
+      channels.forEach(function(channel) {
+        channelRouter.removeListener(channel, messageListener)
+        sub.unsubscribe(channel)
+      })
+    })
+    .catch(function(err) {
+      // Cannot guarantee integrity of client
+      log.error(
+        'Client had an error, disconnecting due to probable loss of integrity'
+      , err.stack
+      )
+
+      socket.disconnect(true)
+    })
+  })
+
+  lifecycle.observe(function() {
+    [push, sub].forEach(function(sock) {
+      try {
+        sock.close()
+      }
+      catch (err) {
+        // No-op
+      }
+    })
+  })
+
+  server.listen(options.port)
+  log.info('Listening on port %d', options.port)
+}
diff --git a/crowdstf/lib/units/websocket/middleware/auth.js b/crowdstf/lib/units/websocket/middleware/auth.js
new file mode 100644
index 0000000..37e36cb
--- /dev/null
+++ b/crowdstf/lib/units/websocket/middleware/auth.js
@@ -0,0 +1,22 @@
+var dbapi = require('../../../db/api')
+
+module.exports = function(socket, next) {
+  var req = socket.request
+  var token = req.session.jwt
+  if (token) {
+    return dbapi.loadUser(token.email)
+      .then(function(user) {
+        if (user) {
+          req.user = user
+          next()
+        }
+        else {
+          next(new Error('Invalid user'))
+        }
+      })
+      .catch(next)
+  }
+  else {
+    next(new Error('Missing authorization token'))
+  }
+}
diff --git a/crowdstf/lib/units/websocket/middleware/cookie-session.js b/crowdstf/lib/units/websocket/middleware/cookie-session.js
new file mode 100644
index 0000000..0a044b8
--- /dev/null
+++ b/crowdstf/lib/units/websocket/middleware/cookie-session.js
@@ -0,0 +1,10 @@
+var cookieSession = require('cookie-session')
+
+module.exports = function(options) {
+  var session = cookieSession(options)
+  return function(socket, next) {
+    var req = socket.request
+    var res = Object.create(null)
+    session(req, res, next)
+  }
+}
diff --git a/crowdstf/lib/units/websocket/middleware/remote-ip.js b/crowdstf/lib/units/websocket/middleware/remote-ip.js
new file mode 100644
index 0000000..9f25b83
--- /dev/null
+++ b/crowdstf/lib/units/websocket/middleware/remote-ip.js
@@ -0,0 +1,9 @@
+var proxyaddr = require('proxy-addr')
+
+module.exports = function(options) {
+  return function(socket, next) {
+    var req = socket.request
+    req.ip = proxyaddr(req, options.trust)
+    next()
+  }
+}
diff --git a/crowdstf/lib/util/cliutil.js b/crowdstf/lib/util/cliutil.js
new file mode 100644
index 0000000..5b61963
--- /dev/null
+++ b/crowdstf/lib/util/cliutil.js
@@ -0,0 +1,18 @@
+module.exports.list = function(val) {
+  return val.split(/\s*,\s*/g).filter(Boolean)
+}
+
+module.exports.size = function(val) {
+  var match = /^(\d+)x(\d+)$/.exec(val)
+  if (match) {
+    return [Number(match[1]), Number(match[2])]
+  }
+}
+
+module.exports.range = function(from, to) {
+  var items = []
+  for (var i = from; i <= to; ++i) {
+    items.push(i)
+  }
+  return items
+}
diff --git a/crowdstf/lib/util/datautil.js b/crowdstf/lib/util/datautil.js
new file mode 100644
index 0000000..5b27cab
--- /dev/null
+++ b/crowdstf/lib/util/datautil.js
@@ -0,0 +1,63 @@
+var deviceData = require('stf-device-db')
+var browserData = require('stf-browser-db')
+
+var logger = require('./logger')
+
+var log = logger.createLogger('util:datautil')
+
+var datautil = module.exports = Object.create(null)
+
+datautil.applyData = function(device) {
+  var match = deviceData.find({
+    model: device.model
+  , name: device.product
+  })
+
+  if (match) {
+    device.name = match.name.id
+    device.releasedAt = match.date
+    device.image = match.image
+    device.cpu = match.cpu
+    device.memory = match.memory
+    if (match.display && match.display.s) {
+      device.display = device.display || {}
+      device.display.inches = match.display.s
+    }
+  }
+  else {
+    log.warn(
+      'Device database does not have a match for device "%s" (model "%s"/"%s")'
+    , device.serial
+    , device.model
+    , device.product
+    )
+  }
+
+  return device
+}
+
+datautil.applyBrowsers = function(device) {
+  if (device.browser) {
+    device.browser.apps.forEach(function(app) {
+      var data = browserData[app.type]
+      if (data) {
+        app.developer = data.developer
+      }
+    })
+  }
+  return device
+}
+
+datautil.applyOwner = function(device, user) {
+  device.using = !!device.owner && device.owner.email === user.email
+  return device
+}
+
+datautil.normalize = function(device, user) {
+  datautil.applyData(device)
+  datautil.applyBrowsers(device)
+  datautil.applyOwner(device, user)
+  if (!device.present) {
+    device.owner = null
+  }
+}
diff --git a/crowdstf/lib/util/devutil.js b/crowdstf/lib/util/devutil.js
new file mode 100644
index 0000000..f2643b1
--- /dev/null
+++ b/crowdstf/lib/util/devutil.js
@@ -0,0 +1,161 @@
+var util = require('util')
+
+var split = require('split')
+var Promise = require('bluebird')
+
+var devutil = module.exports = Object.create(null)
+
+function closedError(err) {
+  return err.message.indexOf('closed') !== -1
+}
+
+devutil.ensureUnusedPort = function(adb, serial, port) {
+  return adb.openTcp(serial, port)
+    .then(function(conn) {
+      conn.end()
+      throw new Error(util.format('Port "%d" should be unused', port))
+    })
+    .catch(closedError, function() {
+      return Promise.resolve(port)
+    })
+}
+
+devutil.waitForPort = function(adb, serial, port) {
+  return adb.openTcp(serial, port)
+    .then(function(conn) {
+      conn.port = port
+      return conn
+    })
+    .catch(closedError, function() {
+      return Promise.delay(100)
+        .then(function() {
+          return devutil.waitForPort(adb, serial, port)
+        })
+    })
+}
+
+devutil.waitForPortToFree = function(adb, serial, port) {
+  return adb.openTcp(serial, port)
+    .then(function(conn) {
+      var resolver = Promise.defer()
+
+      function endListener() {
+        resolver.resolve(port)
+      }
+
+      function errorListener(err) {
+        resolver.reject(err)
+      }
+
+      conn.on('end', endListener)
+      conn.on('error', errorListener)
+
+      return resolver.promise.finally(function() {
+        conn.removeListener('end', endListener)
+        conn.removeListener('error', errorListener)
+        conn.end()
+      })
+    })
+    .catch(closedError, function() {
+      return port
+    })
+}
+
+devutil.listPidsByComm = function(adb, serial, comm, bin) {
+  var users = {
+    shell: true
+  }
+
+  return adb.shell(serial, ['ps', comm])
+    .then(function(out) {
+      return new Promise(function(resolve) {
+        var header = false
+        var pids = []
+        out.pipe(split())
+          .on('data', function(chunk) {
+            if (header) {
+              header = false
+            }
+            else {
+              var cols = chunk.toString().split(/\s+/)
+              if (cols.pop() === bin && users[cols[0]]) {
+                pids.push(Number(cols[1]))
+              }
+            }
+          })
+          .on('end', function() {
+            resolve(pids)
+          })
+      })
+    })
+}
+
+devutil.waitForProcsToDie = function(adb, serial, comm, bin) {
+  return devutil.listPidsByComm(adb, serial, comm, bin)
+    .then(function(pids) {
+      if (pids.length) {
+        return Promise.delay(100)
+          .then(function() {
+            return devutil.waitForProcsToDie(adb, serial, comm, bin)
+          })
+      }
+    })
+}
+
+devutil.killProcsByComm = function(adb, serial, comm, bin, mode) {
+  return devutil.listPidsByComm(adb, serial, comm, bin, mode)
+    .then(function(pids) {
+      if (!pids.length) {
+        return Promise.resolve()
+      }
+      return adb.shell(serial, ['kill', mode || -15].concat(pids))
+        .then(function(out) {
+          return new Promise(function(resolve) {
+            out.on('end', resolve)
+          })
+        })
+        .then(function() {
+          return devutil.waitForProcsToDie(adb, serial, comm, bin)
+        })
+        .timeout(2000)
+        .catch(function() {
+          return devutil.killProcsByComm(adb, serial, comm, bin, -9)
+        })
+    })
+}
+
+devutil.makeIdentity = function(serial, properties) {
+  var model = properties['ro.product.model']
+  var brand = properties['ro.product.brand']
+  var manufacturer = properties['ro.product.manufacturer']
+  var operator = properties['gsm.sim.operator.alpha'] ||
+        properties['gsm.operator.alpha']
+  var version = properties['ro.build.version.release']
+  var sdk = properties['ro.build.version.sdk']
+  var abi = properties['ro.product.cpu.abi']
+  var product = properties['ro.product.name']
+
+  // Remove brand prefix for consistency
+  if (model.substr(0, brand.length) === brand) {
+    model = model.substr(brand.length)
+  }
+
+  // Remove manufacturer prefix for consistency
+  if (model.substr(0, manufacturer.length) === manufacturer) {
+    model = model.substr(manufacturer.length)
+  }
+
+  // Clean up remaining model name
+  // model = model.replace(/[_ ]/g, '')
+  return {
+    serial: serial
+  , platform: 'Android'
+  , manufacturer: manufacturer.toUpperCase()
+  , operator: operator || null
+  , model: model
+  , version: version
+  , abi: abi
+  , sdk: sdk
+  , product: product
+  }
+}
diff --git a/crowdstf/lib/util/doctor.js b/crowdstf/lib/util/doctor.js
new file mode 100644
index 0000000..444addc
--- /dev/null
+++ b/crowdstf/lib/util/doctor.js
@@ -0,0 +1,246 @@
+var os = require('os')
+var semver = require('semver')
+var childProcess = require('child_process')
+var rethinkdbPkg = require('rethinkdb/package')
+var zmq = require('zmq')
+
+var pkg = require('../../package')
+var log = require('./logger').createLogger('util:doctor')
+
+var doctor = module.exports = Object.create(null)
+
+function unsupportedVersion(desc, ver, range, callback) {
+  if (!semver.satisfies(ver, range)) {
+    log.error(
+      'Current %s version is not supported, it has to be %s'
+      , desc
+      , range
+    )
+    if (callback) {
+      return callback()
+    }
+  }
+}
+
+function execHasErrors(error, stderr) {
+  if (error) {
+    if (error.code === 'ENOENT') {
+      log.warn('Executable was not found')
+    }
+    else {
+      log.error(error)
+    }
+    return true
+  }
+  if (stderr) {
+    log.error('There was an error with: %s', stderr)
+  }
+  return false
+}
+
+function execCommand(command, param, desc, match, callback) {
+  childProcess.execFile(command, [param],
+    function(error, stdout, stderr) {
+      if (!execHasErrors(error, stderr)) {
+        if (stdout) {
+          var result = stdout.replace(match, '$1')
+          if (result) {
+            log.info('%s %s', desc, result)
+            if (callback) {
+              return callback(result)
+            }
+          }
+          else {
+            log.error('There was an error with: %s', stdout)
+          }
+        }
+      }
+    }
+  )
+}
+
+doctor.checkOSArch = function() {
+  log.info('OS Arch: %s', os.arch())
+}
+
+doctor.checkOSPlatform = function() {
+  log.info('OS Platform: %s', os.platform())
+  if (os.platform() === 'win32') {
+    log.warn('STF has never been tested on Windows. Contributions are welcome!')
+  }
+}
+
+doctor.checkOSRelease = function() {
+  log.info('OS Platform: %s', os.release())
+}
+
+doctor.checkNodeVersion = function() {
+  log.info('Using Node %s', process.versions.node)
+  if (pkg.engineStrict) {
+    unsupportedVersion('Node', process.versions.node, pkg.engines.node)
+  }
+}
+
+doctor.checkRethinkDBClient = function() {
+  log.info('Using RethinkDB client %s', rethinkdbPkg.version)
+}
+
+doctor.checkLocalRethinkDBServer = function() {
+  execCommand(
+    'rethinkdb'
+    , '--version'
+    , 'Local RethinkDB server'
+    , /rethinkdb (.*?) \(.*\)\n?/gm
+    , function(ver) {
+      unsupportedVersion(
+        'Local RethinkDB server'
+        , ver
+        , pkg.externalDependencies.rethinkdb
+      )
+    })
+}
+
+doctor.checkGraphicsMagick = function() {
+  execCommand(
+    'gm'
+    , '-version'
+    , 'GraphicsMagick'
+    , /GraphicsMagick ((.|\n)*?) (.|\n)*/g
+    , function(ver) {
+      unsupportedVersion('GraphicsMagick', ver, pkg.externalDependencies.gm)
+    }
+  )
+}
+
+doctor.checkZeroMQ = function() {
+  log.info('Using ZeroMQ %s', zmq.version)
+
+  unsupportedVersion('ZeroMQ', zmq.version, pkg.externalDependencies.zeromq)
+}
+
+doctor.checkProtoBuf = function() {
+  execCommand(
+    'protoc'
+    , '--version'
+    , 'ProtoBuf'
+    , /^libprotoc (.*)\n$/g
+    , function(ver) {
+      unsupportedVersion(
+        'ProtoBuf'
+        , ver
+        , pkg.externalDependencies.protobuf
+      )
+    })
+}
+
+doctor.checkADB = function() {
+  execCommand(
+    'adb'
+    , 'version'
+    , 'Local ADB'
+    , /Android Debug Bridge version (\d+\.\d+\.\d+)(\n.*)?/g
+    , function(ver) {
+      unsupportedVersion('AD', ver, pkg.externalDependencies.adb)
+    }
+  )
+}
+
+doctor.checkDevices = function() {
+  // Show all connected USB devices, including hubs
+  if (os.platform() === 'darwin') {
+    childProcess.execFile('ioreg', ['-p', 'IOUSB', '-w0'],
+      function(error, stdout, stderr) {
+        log.info('USB devices connected including hubs:')
+        if (!execHasErrors(error, stderr)) {
+          var list = stdout.replace(/@.*|\+-o Root\s{2}.*\n|\+-o |^\s{2}/gm, '')
+            .split('\n')
+          list.forEach(function(device) {
+            log.info(device)
+          })
+        }
+      }
+    )
+  }
+  else if (os.platform() === 'linux') {
+    childProcess.execFile('lsusb', [],
+      function(error, stdout, stderr) {
+        log.info('USB devices connected including hubs:')
+        if (!execHasErrors(error, stderr)) {
+          var list = stdout.replace(/Bus \d+ Device \d+: ID \w+:\w+ /gm, '')
+            .split('\n')
+          list.forEach(function(device) {
+            log.info(device)
+          })
+        }
+      }
+    )
+  }
+
+  // Show all the devices seen by adb
+  childProcess.execFile('adb', ['devices'],
+    function(error, stdout, stderr) {
+      log.info('Devices that ADB can see:')
+      if (!execHasErrors(error, stderr)) {
+        var s = stdout.replace(/List of devices attached \n|^\s*/gm, '')
+        if (s.length === 0) {
+          log.error('No devices')
+        }
+        else {
+          var list = s.split('\n')
+          list.forEach(function(device) {
+            log.info(device)
+          })
+        }
+      }
+    }
+  )
+}
+
+doctor.run = function(options) {
+  // Check devices
+  if (options.devices) {
+    doctor.checkDevices()
+    return
+  }
+
+  // Check OS architecture
+  doctor.checkOSArch()
+
+  // Check OS platform
+  doctor.checkOSPlatform()
+
+  // Check OS release
+  doctor.checkOSRelease()
+
+  // Check node version
+  doctor.checkNodeVersion()
+
+  // Check rethinkdb client
+  doctor.checkRethinkDBClient()
+
+  // Check local rethinkdb server
+  doctor.checkLocalRethinkDBServer()
+
+  // Check graphicsmagick
+  doctor.checkGraphicsMagick()
+
+  // Check zeromq
+  doctor.checkZeroMQ()
+
+  // Check protobuf
+  doctor.checkProtoBuf()
+
+  // Check adb
+  doctor.checkADB()
+
+  // TODO:
+  // Check yasm
+  // Check pkg-config
+  // Check python2
+  // Exit on errors
+  // Run on stf local
+
+  // Only for stf local:
+  // Check if rethinkdb is running
+  // Check if adb server is running
+}
diff --git a/crowdstf/lib/util/download.js b/crowdstf/lib/util/download.js
new file mode 100644
index 0000000..372345e
--- /dev/null
+++ b/crowdstf/lib/util/download.js
@@ -0,0 +1,67 @@
+var fs = require('fs')
+
+var Promise = require('bluebird')
+var request = require('request')
+var progress = require('request-progress')
+var temp = require('temp')
+
+module.exports = function download(url, options) {
+  var resolver = Promise.defer()
+  var path = temp.path(options)
+
+  function errorListener(err) {
+    resolver.reject(err)
+  }
+
+  function progressListener(state) {
+    if (state.total !== null) {
+      resolver.progress({
+        lengthComputable: true
+      , loaded: state.received
+      , total: state.total
+      })
+    }
+    else {
+      resolver.progress({
+        lengthComputable: false
+      , loaded: state.received
+      , total: state.received
+      })
+    }
+  }
+
+  function closeListener() {
+    resolver.resolve({
+      path: path
+    })
+  }
+
+  resolver.progress({
+    percent: 0
+  })
+
+  try {
+    var req = progress(request(url), {
+        throttle: 100 // Throttle events, not upload speed
+      })
+      .on('progress', progressListener)
+
+    resolver.promise.finally(function() {
+      req.removeListener('progress', progressListener)
+    })
+
+    var save = req.pipe(fs.createWriteStream(path))
+      .on('error', errorListener)
+      .on('close', closeListener)
+
+    resolver.promise.finally(function() {
+      save.removeListener('error', errorListener)
+      save.removeListener('close', closeListener)
+    })
+  }
+  catch (err) {
+    resolver.reject(err)
+  }
+
+  return resolver.promise
+}
diff --git a/crowdstf/lib/util/failcounter.js b/crowdstf/lib/util/failcounter.js
new file mode 100644
index 0000000..186abb1
--- /dev/null
+++ b/crowdstf/lib/util/failcounter.js
@@ -0,0 +1,33 @@
+var util = require('util')
+
+var EventEmitter = require('eventemitter3').EventEmitter
+
+function FailCounter(threshold, time) {
+  EventEmitter.call(this)
+  this.threshold = threshold
+  this.time = time
+  this.values = []
+}
+
+util.inherits(FailCounter, EventEmitter)
+
+FailCounter.prototype.inc = function() {
+  var now = Date.now()
+
+  while (this.values.length) {
+    if (now - this.values[0] >= this.time) {
+      this.values.shift()
+    }
+    else {
+      break
+    }
+  }
+
+  this.values.push(now)
+
+  if (this.values.length > this.threshold) {
+    this.emit('exceedLimit', this.threshold, this.time)
+  }
+}
+
+module.exports = FailCounter
diff --git a/crowdstf/lib/util/fakedevice.js b/crowdstf/lib/util/fakedevice.js
new file mode 100644
index 0000000..7c6f9b5
--- /dev/null
+++ b/crowdstf/lib/util/fakedevice.js
@@ -0,0 +1,57 @@
+var util = require('util')
+
+var uuid = require('node-uuid')
+var _ = require('lodash')
+
+var dbapi = require('../db/api')
+var devices = require('stf-device-db/dist/devices-latest')
+
+module.exports.generate = function(wantedModel) {
+  var serial = util.format(
+    'fake-%s'
+  , uuid.v4(null, new Buffer(16)).toString('base64')
+  )
+
+  return dbapi.saveDeviceInitialState(serial, {
+      provider: {
+        name: 'FAKE/1'
+      , channel: '*fake'
+      }
+    , status: 'OFFLINE'
+    })
+    .then(function() {
+      var model = wantedModel || _.sample(Object.keys(devices))
+      return dbapi.saveDeviceIdentity(serial, {
+        platform: 'Android'
+      , manufacturer: 'Foo Electronics'
+      , operator: 'Loss Networks'
+      , model: model
+      , version: '4.1.2'
+      , abi: 'armeabi-v7a'
+      , sdk: 8 + Math.floor(Math.random() * 12)
+      , display: {
+          density: 3
+        , fps: 60
+        , height: 1920
+        , id: 0
+        , rotation: 0
+        , secure: true
+        , url: '/404.jpg'
+        , width: 1080
+        , xdpi: 442
+        , ydpi: 439
+        }
+      , phone: {
+          iccid: '1234567890123456789'
+        , imei: '123456789012345'
+        , network: 'LTE'
+        , phoneNumber: '0000000000'
+        }
+      , product: model
+      })
+    })
+    .then(function() {
+      return dbapi.setDeviceAbsent(serial)
+    })
+    .return(serial)
+}
diff --git a/crowdstf/lib/util/grouputil.js b/crowdstf/lib/util/grouputil.js
new file mode 100644
index 0000000..b93f205
--- /dev/null
+++ b/crowdstf/lib/util/grouputil.js
@@ -0,0 +1,72 @@
+var util = require('util')
+
+var Promise = require('bluebird')
+var semver = require('semver')
+var minimatch = require('minimatch')
+
+var wire = require('../wire')
+
+function RequirementMismatchError(name) {
+  Error.call(this)
+  this.name = 'RequirementMismatchError'
+  this.message = util.format('Requirement mismatch for "%s"', name)
+  Error.captureStackTrace(this, RequirementMismatchError)
+}
+
+util.inherits(RequirementMismatchError, Error)
+
+module.exports.RequirementMismatchError = RequirementMismatchError
+
+function AlreadyGroupedError() {
+  Error.call(this)
+  this.name = 'AlreadyGroupedError'
+  this.message = 'Already a member of another group'
+  Error.captureStackTrace(this, AlreadyGroupedError)
+}
+
+util.inherits(AlreadyGroupedError, Error)
+
+module.exports.AlreadyGroupedError = AlreadyGroupedError
+
+function NoGroupError() {
+  Error.call(this)
+  this.name = 'NoGroupError'
+  this.message = 'Not a member of any group'
+  Error.captureStackTrace(this, NoGroupError)
+}
+
+util.inherits(NoGroupError, Error)
+
+module.exports.NoGroupError = NoGroupError
+
+module.exports.match = Promise.method(function(capabilities, requirements) {
+  return requirements.every(function(req) {
+    var capability = capabilities[req.name]
+
+    if (!capability) {
+      throw new RequirementMismatchError(req.name)
+    }
+
+    switch (req.type) {
+      case wire.RequirementType.SEMVER:
+        if (!semver.satisfies(capability, req.value)) {
+          throw new RequirementMismatchError(req.name)
+        }
+        break
+      case wire.RequirementType.GLOB:
+        if (!minimatch(capability, req.value)) {
+          throw new RequirementMismatchError(req.name)
+        }
+        break
+      case wire.RequirementType.EXACT:
+        if (capability !== req.value) {
+          throw new RequirementMismatchError(req.name)
+        }
+        break
+      default:
+        throw new RequirementMismatchError(req.name)
+    }
+
+    return true
+  })
+})
diff --git a/crowdstf/lib/util/jwtutil.js b/crowdstf/lib/util/jwtutil.js
new file mode 100644
index 0000000..d241116
--- /dev/null
+++ b/crowdstf/lib/util/jwtutil.js
@@ -0,0 +1,39 @@
+var assert = require('assert')
+var jws = require('jws')
+var _ = require('lodash')
+
+module.exports.encode = function(options) {
+  assert.ok(options.payload, 'payload required')
+  assert.ok(options.secret, 'secret required')
+
+  var header = {
+    alg: 'HS256'
+  }
+
+  if (options.header) {
+    header = _.merge(header, options.header)
+  }
+
+  return jws.sign({
+    header: header
+  , payload: options.payload
+  , secret: options.secret
+  })
+}
+
+module.exports.decode = function(payload, secret) {
+  if (!jws.verify(payload, 'HS256', secret)) {
+    return null
+  }
+
+  var decoded = jws.decode(payload, {
+        json: true
+      })
+  var exp = decoded.header.exp
+
+  if (exp && exp <= Date.now()) {
+    return null
+  }
+
+  return decoded.payload
+}
diff --git a/crowdstf/lib/util/keyutil.js b/crowdstf/lib/util/keyutil.js
new file mode 100644
index 0000000..cf494c9
--- /dev/null
+++ b/crowdstf/lib/util/keyutil.js
@@ -0,0 +1,569 @@
+var util = require('util')
+
+var adb = require('adbkit')
+var Promise = require('bluebird')
+
+var keyutil = module.exports = Object.create(null)
+
+keyutil.parseKeyCharacterMap = function(stream) {
+  var resolver = Promise.defer()
+  var state = 'type_t'
+  var keymap = {
+        type: null
+      , keys: []
+      }
+  var lastKey, lastRule, lastModifier, lastBehavior
+
+  function fail(char, state) {
+    throw new Error(util.format(
+      'Unexpected character "%s" in state "%s"'
+    , char
+    , state
+    ))
+  }
+
+  function parse(char) {
+    switch (state) {
+      case 'comment_before_type_t':
+        if (char === '\n') {
+          state = 'type_t'
+          break
+        }
+        return true
+      case 'type_t':
+        if (char === '\n') {
+          return true
+        }
+        if (char === '#') {
+          state = 'comment_before_type_t'
+          return true
+        }
+        if (char === 'k') {
+          state = 'key_k'
+          return parse(char)
+        }
+        if (char === 't') {
+          state = 'type_y'
+          return true
+        }
+        return fail(char, state)
+      case 'type_y':
+        if (char === 'y') {
+          state = 'type_p'
+          return true
+        }
+        return fail(char, state)
+      case 'type_p':
+        if (char === 'p') {
+          state = 'type_e'
+          return true
+        }
+        return fail(char, state)
+      case 'type_e':
+        if (char === 'e') {
+          state = 'type_name_start'
+          keymap.type = ''
+          return true
+        }
+        return fail(char, state)
+      case 'type_name_start':
+        if (char === ' ') {
+          return true
+        }
+        if (char >= 'A' && char <= 'Z') {
+          keymap.type += char
+          state = 'type_name_continued'
+          return true
+        }
+        return fail(char, state)
+      case 'type_name_continued':
+        if (char === '\n') {
+          // Could have more of these, although it doesn't make much sense
+          state = 'type_t'
+          return true
+        }
+        if (char >= 'A' && char <= 'Z') {
+          keymap.type += char
+          return true
+        }
+        return fail(char, state)
+      case 'comment_before_key_k':
+        if (char === '\n') {
+          state = 'key_k'
+          break
+        }
+        return true
+      case 'key_k':
+        if (char === '\n') {
+          return true
+        }
+        if (char === '#') {
+          state = 'comment_before_key_k'
+          return true
+        }
+        if (char === 'k') {
+          state = 'key_e'
+          return true
+        }
+        return fail(char, state)
+      case 'key_e':
+        if (char === 'e') {
+          state = 'key_y'
+          return true
+        }
+        return fail(char, state)
+      case 'key_y':
+        if (char === 'y') {
+          state = 'key_name_start'
+          return true
+        }
+        return fail(char, state)
+      case 'key_name_start':
+        if (char === ' ') {
+          return true
+        }
+        if ((char >= '0' && char <= '9') ||
+            (char >= 'A' && char <= 'Z')) {
+          keymap.keys.push(lastKey = {
+            key: char
+          , rules: []
+          })
+          state = 'key_name_continued'
+          return true
+        }
+        return fail(char, state)
+      case 'key_name_continued':
+        if (char === ' ') {
+          state = 'key_start_block'
+          return true
+        }
+        if ((char >= '0' && char <= '9') ||
+            (char >= 'A' && char <= 'Z') ||
+            (char === '_')) {
+          lastKey.key += char
+          return true
+        }
+        return fail(char, state)
+      case 'key_start_block':
+        if (char === ' ') {
+          return true
+        }
+        if (char === '{') {
+          state = 'filter_name_start'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_name_start':
+        if (char === '\n' || char === '\t' || char === ' ') {
+          return true
+        }
+        if (char === '}') {
+          state = 'key_k'
+          return true
+        }
+        if (char >= 'a' && char <= 'z') {
+          lastKey.rules.push(lastRule = {
+            modifiers: [lastModifier = {
+              type: char
+            }]
+          , behaviors: []
+          })
+          state = 'filter_name_continued'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_name_continued':
+        if (char === ':') {
+          state = 'filter_behavior_start'
+          return true
+        }
+        if (char === ',') {
+          state = 'filter_name_or_start'
+          return true
+        }
+        if (char === '+') {
+          state = 'filter_name_and_start'
+          return true
+        }
+        if (char >= 'a' && char <= 'z') {
+          lastModifier.type += char
+          return true
+        }
+        return fail(char, state)
+      case 'filter_name_or_start':
+        if (char === ' ') {
+          return true
+        }
+        if (char >= 'a' && char <= 'z') {
+          lastKey.rules.push(lastRule = {
+            modifiers: [lastModifier = {
+              type: char
+            }]
+          , behaviors: lastRule.behaviors
+          })
+          state = 'filter_name_continued'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_name_and_start':
+        if (char === ' ') {
+          return true
+        }
+        if (char >= 'a' && char <= 'z') {
+          lastRule.modifiers.push(lastModifier = {
+            type: char
+          })
+          state = 'filter_name_continued'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_literal':
+        if (char === '\\') {
+          state = 'filter_behavior_literal_escape'
+          return true
+        }
+        if (char !== "'") {
+          lastRule.behaviors.push({
+            type: 'literal'
+          , value: char
+          })
+          state = 'filter_behavior_literal_end'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_literal_escape':
+        if (char === '\\' || char === '\'' || char === '"') {
+          lastRule.behaviors.push({
+            type: 'literal'
+          , value: char
+          })
+          state = 'filter_behavior_literal_end'
+          return true
+        }
+        if (char === 'n') {
+          lastRule.behaviors.push({
+            type: 'literal'
+          , value: '\n'
+          })
+          state = 'filter_behavior_literal_end'
+          return true
+        }
+        if (char === 't') {
+          lastRule.behaviors.push({
+            type: 'literal'
+          , value: '\t'
+          })
+          state = 'filter_behavior_literal_end'
+          return true
+        }
+        if (char === 'u') {
+          state = 'filter_behavior_literal_unicode_1'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_literal_end':
+        if (char === '\'') {
+          state = 'filter_behavior_start'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_start':
+        if (char === '\n') {
+          state = 'filter_name_start'
+          return true
+        }
+        if (char === ' ') {
+          return true
+        }
+        if (char === "'") {
+          state = 'filter_behavior_literal'
+          return true
+        }
+        if (char === 'n') {
+          state = 'filter_behavior_none_2'
+          return true
+        }
+        if (char === 'f') {
+          state = 'filter_behavior_fallback_2'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_fallback_2':
+        if (char === 'a') {
+          state = 'filter_behavior_fallback_3'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_fallback_3':
+        if (char === 'l') {
+          state = 'filter_behavior_fallback_4'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_fallback_4':
+        if (char === 'l') {
+          state = 'filter_behavior_fallback_5'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_fallback_5':
+        if (char === 'b') {
+          state = 'filter_behavior_fallback_6'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_fallback_6':
+        if (char === 'a') {
+          state = 'filter_behavior_fallback_7'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_fallback_7':
+        if (char === 'c') {
+          state = 'filter_behavior_fallback_8'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_fallback_8':
+        if (char === 'k') {
+          state = 'filter_behavior_fallback_key_start'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_fallback_key_start':
+        if (char === ' ') {
+          return true
+        }
+        if ((char >= '0' && char <= '9') ||
+            (char >= 'A' && char <= 'Z')) {
+          lastRule.behaviors.push(lastBehavior = {
+            type: 'fallback'
+          , key: char
+          })
+          state = 'filter_behavior_fallback_key_continued'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_fallback_key_continued':
+        if (char === ' ') {
+          state = 'filter_behavior_start'
+          return true
+        }
+        if (char === '\n') {
+          state = 'filter_name_start'
+          return true
+        }
+        if ((char >= '0' && char <= '9') ||
+            (char >= 'A' && char <= 'Z') ||
+            (char === '_')) {
+          lastBehavior.key += char
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_none_2':
+        if (char === 'o') {
+          state = 'filter_behavior_none_3'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_none_3':
+        if (char === 'n') {
+          state = 'filter_behavior_none_4'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_none_4':
+        if (char === 'e') {
+          lastRule.behaviors.push({
+            type: 'none'
+          })
+          state = 'filter_behavior_start'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_literal_unicode_1':
+        if ((char >= '0' && char <= '9') ||
+            (char >= 'a' && char <= 'f')) {
+          lastRule.behaviors.push(lastBehavior = {
+            type: 'literal'
+          , value: parseInt(char, 16) << 12
+          })
+          state = 'filter_behavior_literal_unicode_2'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_literal_unicode_2':
+        if ((char >= '0' && char <= '9') ||
+            (char >= 'a' && char <= 'f')) {
+          lastBehavior.value += parseInt(char, 16) << 8
+          state = 'filter_behavior_literal_unicode_3'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_literal_unicode_3':
+        if ((char >= '0' && char <= '9') ||
+            (char >= 'a' && char <= 'f')) {
+          lastBehavior.value += parseInt(char, 16) << 4
+          state = 'filter_behavior_literal_unicode_4'
+          return true
+        }
+        return fail(char, state)
+      case 'filter_behavior_literal_unicode_4':
+        if ((char >= '0' && char <= '9') ||
+            (char >= 'a' && char <= 'f')) {
+          lastBehavior.value += parseInt(char, 16)
+          lastBehavior.value = String.fromCharCode(lastBehavior.value)
+          state = 'filter_behavior_literal_end'
+          return true
+        }
+        return fail(char, state)
+      default:
+        throw new Error(util.format('Unexpected state "%s"', state))
+    }
+  }
+
+  function errorListener(err) {
+    resolver.reject(err)
+  }
+
+  function readableListener() {
+    var chunk = stream.read()
+    var i = 0
+    var l = chunk.length
+
+    try {
+      while (i < l) {
+        parse(String.fromCharCode(chunk[i++]))
+      }
+    }
+    catch (err) {
+      resolver.reject(err)
+    }
+  }
+
+  function endListener() {
+    resolver.resolve(keymap)
+  }
+
+  stream.on('error', errorListener)
+  stream.on('readable', readableListener)
+  stream.on('end', endListener)
+
+  return resolver.promise.finally(function() {
+    stream.removeListener('error', errorListener)
+    stream.removeListener('readable', readableListener)
+    stream.removeListener('end', endListener)
+  })
+}
+
+keyutil.namedKey = function(name) {
+  var key = adb.Keycode['KEYCODE_' + name.toUpperCase()]
+  if (typeof key === 'undefined') {
+    throw new Error(util.format('Unknown key "%s"', name))
+  }
+  return key
+}
+
+keyutil.buildCharMap = function(keymap) {
+  var charmap = Object.create(null)
+
+  keymap.keys.forEach(function(key) {
+    key.rules.forEach(function(rule) {
+      var combination = {
+        key: keyutil.namedKey(key.key)
+      , modifiers: []
+      , complexity: 0
+      }
+
+      var shouldHandle = rule.modifiers.every(function(modifier) {
+        switch (modifier.type) {
+          case 'label':
+            return false // ignore
+          case 'base':
+            return true
+          case 'shift':
+          case 'lshift':
+            combination.modifiers.push(adb.Keycode.KEYCODE_SHIFT_LEFT)
+            combination.complexity += 10
+            return true
+          case 'rshift':
+            combination.modifiers.push(adb.Keycode.KEYCODE_SHIFT_RIGHT)
+            combination.complexity += 10
+            return true
+          case 'alt':
+          case 'lalt':
+            combination.modifiers.push(adb.Keycode.KEYCODE_ALT_LEFT)
+            combination.complexity += 20
+            return true
+          case 'ralt':
+            combination.modifiers.push(adb.Keycode.KEYCODE_ALT_RIGHT)
+            combination.complexity += 20
+            return true
+          case 'ctrl':
+          case 'lctrl':
+            combination.modifiers.push(adb.Keycode.KEYCODE_CTRL_LEFT)
+            combination.complexity += 20
+            return true
+          case 'rctrl':
+            combination.modifiers.push(adb.Keycode.KEYCODE_CTRL_RIGHT)
+            combination.complexity += 20
+            return true
+          case 'meta':
+          case 'lmeta':
+            combination.modifiers.push(adb.Keycode.KEYCODE_META_LEFT)
+            combination.complexity += 20
+            return true
+          case 'rmeta':
+            combination.modifiers.push(adb.Keycode.KEYCODE_META_RIGHT)
+            combination.complexity += 20
+            return true
+          case 'sym':
+            combination.modifiers.push(adb.Keycode.KEYCODE_SYM)
+            combination.complexity += 10
+            return true
+          case 'fn':
+            combination.modifiers.push(adb.Keycode.KEYCODE_FUNCTION)
+            combination.complexity += 30
+            return true
+          case 'capslock':
+            combination.modifiers.push(adb.Keycode.KEYCODE_CAPS_LOCK)
+            combination.complexity += 30
+            return true
+          case 'numlock':
+            combination.modifiers.push(adb.Keycode.KEYCODE_NUM_LOCK)
+            combination.complexity += 30
+            return true
+          case 'scrolllock':
+            combination.modifiers.push(adb.Keycode.KEYCODE_SCROLL_LOCK)
+            combination.complexity += 30
+            return true
+        }
+      })
+
+      if (!shouldHandle) {
+        return
+      }
+
+      rule.behaviors.forEach(function(behavior) {
+        switch (behavior.type) {
+          case 'literal':
+            if (!charmap[behavior.value]) {
+              charmap[behavior.value] = [combination]
+            }
+            else {
+              charmap[behavior.value].push(combination)
+
+              // Could be more efficient, but we only have 1-4 combinations
+              // per key, so we don't really care.
+              charmap[behavior.value].sort(function(a, b) {
+                return a.complexity - b.complexity
+              })
+            }
+            break
+        }
+      })
+    })
+  })
+
+  return charmap
+}
diff --git a/crowdstf/lib/util/ldaputil.js b/crowdstf/lib/util/ldaputil.js
new file mode 100644
index 0000000..36b9bc2
--- /dev/null
+++ b/crowdstf/lib/util/ldaputil.js
@@ -0,0 +1,121 @@
+var util = require('util')
+
+var ldap = require('ldapjs')
+var Promise = require('bluebird')
+
+function InvalidCredentialsError(user) {
+  Error.call(this, util.format('Invalid credentials for user "%s"', user))
+  this.name = 'InvalidCredentialsError'
+  this.user = user
+  Error.captureStackTrace(this, InvalidCredentialsError)
+}
+
+util.inherits(InvalidCredentialsError, Error)
+
+// Export
+module.exports.InvalidCredentialsError = InvalidCredentialsError
+
+// Export
+module.exports.login = function(options, username, password) {
+  function tryConnect() {
+    var resolver = Promise.defer()
+    var client = ldap.createClient({
+          url: options.url
+        , timeout: options.timeout
+        , maxConnections: 1
+        })
+
+    if (options.bind.dn) {
+      client.bind(options.bind.dn, options.bind.credentials, function(err) {
+        if (err) {
+          resolver.reject(err)
+        }
+        else {
+          resolver.resolve(client)
+        }
+      })
+    }
+    else {
+      resolver.resolve(client)
+    }
+
+    return resolver.promise
+  }
+
+  function tryFind(client) {
+    var resolver = Promise.defer()
+    var query = {
+          scope: options.search.scope
+        , filter: new ldap.AndFilter({
+            filters: [
+              new ldap.EqualityFilter({
+                attribute: 'objectClass'
+              , value: options.search.objectClass
+              })
+            , new ldap.EqualityFilter({
+                attribute: options.search.field
+              , value: username
+              })
+            ]
+          })
+        }
+
+    client.search(options.search.dn, query, function(err, search) {
+      if (err) {
+        return resolver.reject(err)
+      }
+
+      function entryListener(entry) {
+        resolver.resolve(entry)
+      }
+
+      function endListener() {
+        resolver.reject(new InvalidCredentialsError(username))
+      }
+
+      function errorListener(err) {
+        resolver.reject(err)
+      }
+
+      search.on('searchEntry', entryListener)
+      search.on('end', endListener)
+      search.on('error', errorListener)
+
+      resolver.promise.finally(function() {
+        search.removeListener('searchEntry', entryListener)
+        search.removeListener('end', endListener)
+        search.removeListener('error', errorListener)
+      })
+    })
+
+    return resolver.promise
+  }
+
+  function tryBind(client, entry) {
+    return new Promise(function(resolve, reject) {
+      client.bind(entry.object.dn, password, function(err) {
+        if (err) {
+          reject(new InvalidCredentialsError(username))
+        }
+        else {
+          resolve(entry.object)
+        }
+      })
+    })
+  }
+
+  return tryConnect().then(function(client) {
+    return tryFind(client)
+      .then(function(entry) {
+        return tryBind(client, entry)
+      })
+      .finally(function() {
+        client.unbind()
+      })
+  })
+}
+
+// Export
+module.exports.email = function(user) {
+  return user.mail || user.email || user.userPrincipalName
+}
diff --git a/crowdstf/lib/util/lifecycle.js b/crowdstf/lib/util/lifecycle.js
new file mode 100644
index 0000000..2e68ffc
--- /dev/null
+++ b/crowdstf/lib/util/lifecycle.js
@@ -0,0 +1,73 @@
+var Promise = require('bluebird')
+
+var logger = require('./logger')
+var log = logger.createLogger('util:lifecycle')
+var _ = require('lodash')
+
+function Lifecycle() {
+  this.observers = []
+  this.ending = false
+  process.on('SIGINT', this.graceful.bind(this))
+  process.on('SIGTERM', this.graceful.bind(this))
+}
+
+Lifecycle.prototype.share = function(name, emitter, options) {
+  var opts = _.assign({
+      end: true
+    , error: true
+    }
+  , options
+  )
+
+  if (opts.end) {
+    emitter.on('end', function() {
+      if (!this.ending) {
+        log.fatal('%s ended; we shall share its fate', name)
+        this.fatal()
+      }
+    }.bind(this))
+  }
+
+  if (opts.error) {
+    emitter.on('error', function(err) {
+      if (!this.ending) {
+        log.fatal('%s had an error', name, err.stack)
+        this.fatal()
+      }
+    }.bind(this))
+  }
+
+  if (emitter.end) {
+    this.observe(function() {
+      emitter.end()
+    })
+  }
+
+  return emitter
+}
+
+Lifecycle.prototype.graceful = function() {
+  log.info('Winding down for graceful exit')
+
+  this.ending = true
+
+  var wait = Promise.all(this.observers.map(function(fn) {
+    return fn()
+  }))
+
+  return wait.then(function() {
+    process.exit(0)
+  })
+}
+
+Lifecycle.prototype.fatal = function() {
+  log.fatal('Shutting down due to fatal error')
+  this.ending = true
+  process.exit(1)
+}
+
+Lifecycle.prototype.observe = function(promise) {
+  this.observers.push(promise)
+}
+
+module.exports = new Lifecycle()
diff --git a/crowdstf/lib/util/logger.js b/crowdstf/lib/util/logger.js
new file mode 100644
index 0000000..1f3d2d7
--- /dev/null
+++ b/crowdstf/lib/util/logger.js
@@ -0,0 +1,140 @@
+/* eslint quote-props:0 */
+var util = require('util')
+var events = require('events')
+
+var chalk = require('chalk')
+
+var Logger = new events.EventEmitter()
+
+Logger.Level = {
+  DEBUG: 1
+, VERBOSE: 2
+, INFO: 3
+, IMPORTANT: 4
+, WARNING: 5
+, ERROR: 6
+, FATAL: 7
+}
+
+// Exposed for other modules
+Logger.LevelLabel = {
+  1: 'DBG'
+, 2: 'VRB'
+, 3: 'INF'
+, 4: 'IMP'
+, 5: 'WRN'
+, 6: 'ERR'
+, 7: 'FTL'
+}
+
+Logger.globalIdentifier = '*'
+
+function Log(tag) {
+  this.tag = tag
+  this.names = {
+    1: 'DBG'
+    , 2: 'VRB'
+    , 3: 'INF'
+    , 4: 'IMP'
+    , 5: 'WRN'
+    , 6: 'ERR'
+    , 7: 'FTL'
+  }
+  this.styles = {
+    1: 'grey'
+    , 2: 'cyan'
+    , 3: 'green'
+    , 4: 'magenta'
+    , 5: 'yellow'
+    , 6: 'red'
+    , 7: 'red'
+  }
+  this.localIdentifier = null
+  events.EventEmitter.call(this)
+}
+
+util.inherits(Log, events.EventEmitter)
+
+Logger.createLogger = function(tag) {
+  return new Log(tag)
+}
+
+Logger.setGlobalIdentifier = function(identifier) {
+  Logger.globalIdentifier = identifier
+  return Logger
+}
+
+Log.Entry = function(timestamp, priority, tag, pid, identifier, message) {
+  this.timestamp = timestamp
+  this.priority = priority
+  this.tag = tag
+  this.pid = pid
+  this.identifier = identifier
+  this.message = message
+}
+
+Log.prototype.setLocalIdentifier = function(identifier) {
+  this.localIdentifier = identifier
+}
+
+Log.prototype.debug = function() {
+  this._write(this._entry(Logger.Level.DEBUG, arguments))
+}
+
+Log.prototype.verbose = function() {
+  this._write(this._entry(Logger.Level.VERBOSE, arguments))
+}
+
+Log.prototype.info = function() {
+  this._write(this._entry(Logger.Level.INFO, arguments))
+}
+
+Log.prototype.important = function() {
+  this._write(this._entry(Logger.Level.IMPORTANT, arguments))
+}
+
+Log.prototype.warn = function() {
+  this._write(this._entry(Logger.Level.WARNING, arguments))
+}
+
+Log.prototype.error = function() {
+  this._write(this._entry(Logger.Level.ERROR, arguments))
+}
+
+Log.prototype.fatal = function() {
+  this._write(this._entry(Logger.Level.FATAL, arguments))
+}
+
+Log.prototype._entry = function(priority, args) {
+  return new Log.Entry(
+      Date.now()
+    , priority
+    , this.tag
+    , process.pid
+    , this.localIdentifier || Logger.globalIdentifier
+    , util.format.apply(util, args)
+  )
+}
+
+Log.prototype._format = function(entry) {
+  return util.format('%s/%s %d [%s] %s'
+    , this._name(entry.priority)
+    , entry.tag
+    , entry.pid
+    , entry.identifier
+    , entry.message
+  )
+}
+
+Log.prototype._name = function(priority) {
+  return chalk[this.styles[priority]](this.names[priority])
+}
+
+/* eslint no-console: 0 */
+Log.prototype._write = function(entry) {
+  console.error(this._format(entry))
+  this.emit('entry', entry)
+  Logger.emit('entry', entry)
+}
+
+exports = module.exports = Logger
diff --git a/crowdstf/lib/util/pathutil.js b/crowdstf/lib/util/pathutil.js
new file mode 100644
index 0000000..5c2091c
--- /dev/null
+++ b/crowdstf/lib/util/pathutil.js
@@ -0,0 +1,37 @@
+var path = require('path')
+var fs = require('fs')
+var util = require('util')
+
+// Export
+module.exports.root = function(target) {
+  return path.resolve(__dirname, '../..', target)
+}
+
+// Export
+module.exports.resource = function(target) {
+  return path.resolve(__dirname, '../../res', target)
+}
+
+// Export
+module.exports.vendor = function(target) {
+  return path.resolve(__dirname, '../../vendor', target)
+}
+
+// Export
+module.exports.module = function(target) {
+  return path.resolve(__dirname, '../../node_modules', target)
+}
+
+// Export
+module.exports.requiredMatch = function(candidates) {
+  for (var i = 0, l = candidates.length; i < l; ++i) {
+    if (fs.existsSync(candidates[i])) {
+      return candidates[i]
+    }
+  }
+
+  throw new Error(util.format(
+    'At least one of these paths should exist: %s'
+  , candidates.join(', ')
+  ))
+}
diff --git a/crowdstf/lib/util/procutil.js b/crowdstf/lib/util/procutil.js
new file mode 100644
index 0000000..25421f1
--- /dev/null
+++ b/crowdstf/lib/util/procutil.js
@@ -0,0 +1,94 @@
+var util = require('util')
+var cp = require('child_process')
+
+var Promise = require('bluebird')
+
+var log = require('./logger').createLogger('util:procutil')
+
+function ExitError(code) {
+  Error.call(this)
+  this.name = 'ExitError'
+  this.code = code
+  this.message = util.format('Exit code "%d"', code)
+  Error.captureStackTrace(this, ExitError)
+}
+
+util.inherits(ExitError, Error)
+
+// Export
+module.exports.ExitError = ExitError
+
+// Export
+module.exports.fork = function(filename, args) {
+  log.info('Forking "%s %s"', filename, args.join(' '))
+
+  var resolver = Promise.defer()
+  var proc = cp.fork.apply(cp, arguments)
+
+  function sigintListener() {
+    proc.kill('SIGINT')
+  }
+
+  function sigtermListener() {
+    proc.kill('SIGTERM')
+  }
+
+  process.on('SIGINT', sigintListener)
+  process.on('SIGTERM', sigtermListener)
+
+  proc.on('error', function(err) {
+    resolver.reject(err)
+    proc.kill()
+  })
+
+  proc.on('exit', function(code, signal) {
+    if (signal) {
+      resolver.resolve(code)
+    }
+    else if (code > 0 && code !== 130 && code !== 143) {
+      resolver.reject(new ExitError(code))
+    }
+    else {
+      resolver.resolve(code)
+    }
+  })
+
+  return resolver.promise.cancellable()
+    .finally(function() {
+      process.removeListener('SIGINT', sigintListener)
+      process.removeListener('SIGTERM', sigtermListener)
+    })
+    .catch(Promise.CancellationError, function() {
+      return new Promise(function(resolve) {
+        proc.on('exit', function() {
+          resolve()
+        })
+        proc.kill()
+      })
+    })
+}
+
+// Export
+module.exports.gracefullyKill = function(proc, timeout) {
+  function killer(signal) {
+    var deferred = Promise.defer()
+
+    function onExit() {
+      deferred.resolve()
+    }
+
+    proc.once('exit', onExit)
+    proc.kill(signal)
+
+    return deferred.promise.finally(function() {
+      proc.removeListener('exit', onExit)
+    })
+  }
+
+  return killer('SIGTERM')
+    .timeout(timeout)
+    .catch(function() {
+      return killer('SIGKILL')
+        .timeout(timeout)
+    })
+}
diff --git a/crowdstf/lib/util/promiseutil.js b/crowdstf/lib/util/promiseutil.js
new file mode 100644
index 0000000..21f969d
--- /dev/null
+++ b/crowdstf/lib/util/promiseutil.js
@@ -0,0 +1,25 @@
+var Promise = require('bluebird')
+
+module.exports.periodicNotify = function(promise, interval) {
+  var resolver = Promise.defer()
+
+  function notify() {
+    resolver.progress()
+  }
+
+  var timer = setInterval(notify, interval)
+
+  function resolve(value) {
+    resolver.resolve(value)
+  }
+
+  function reject(err) {
+    resolver.reject(err)
+  }
+
+  promise.then(resolve, reject)
+
+  return resolver.promise.finally(function() {
+    clearInterval(timer)
+  })
+}
diff --git a/crowdstf/lib/util/refresh.js b/crowdstf/lib/util/refresh.js
new file mode 100644
index 0000000..c2be0ff
--- /dev/null
+++ b/crowdstf/lib/util/refresh.js
@@ -0,0 +1,21 @@
+var fs = require('fs')
+
+var watchers = Object.create(null)
+
+function refresh() {
+  process.kill('SIGHUP')
+}
+
+function collect() {
+  Object.keys(require.cache).forEach(function(path) {
+    if (!watchers[path]) {
+      if (path.indexOf('node_modules') === -1) {
+        watchers[path] = fs.watch(path, refresh)
+      }
+    }
+  })
+}
+
+module.exports = function() {
+  collect()
+}
diff --git a/crowdstf/lib/util/requtil.js b/crowdstf/lib/util/requtil.js
new file mode 100644
index 0000000..f242f3a
--- /dev/null
+++ b/crowdstf/lib/util/requtil.js
@@ -0,0 +1,51 @@
+var util = require('util')
+
+var Promise = require('bluebird')
+
+function ValidationError(message, errors) {
+  Error.call(this, message)
+  this.name = 'ValidationError'
+  this.errors = errors
+  Error.captureStackTrace(this, ValidationError)
+}
+
+util.inherits(ValidationError, Error)
+
+module.exports.ValidationError = ValidationError
+
+module.exports.validate = function(req, rules) {
+  return new Promise(function(resolve, reject) {
+    rules()
+
+    var errors = req.validationErrors()
+    if (!errors) {
+      resolve()
+    }
+    else {
+      reject(new ValidationError('validation error', errors))
+    }
+  })
+}
+
+module.exports.limit = function(limit, handler) {
+  var queue = []
+  var running = 0
+
+  /* eslint no-use-before-define: 0 */
+  function maybeNext() {
+    while (running < limit && queue.length) {
+      running += 1
+      handler.apply(null, queue.shift()).finally(done)
+    }
+  }
+
+  function done() {
+    running -= 1
+    maybeNext()
+  }
+
+  return function() {
+    queue.push(arguments)
+    maybeNext()
+  }
+}
diff --git a/crowdstf/lib/util/riskystream.js b/crowdstf/lib/util/riskystream.js
new file mode 100644
index 0000000..a3c67d2
--- /dev/null
+++ b/crowdstf/lib/util/riskystream.js
@@ -0,0 +1,61 @@
+var util = require('util')
+
+var Promise = require('bluebird')
+var EventEmitter = require('eventemitter3').EventEmitter
+
+function RiskyStream(stream) {
+  EventEmitter.call(this)
+
+  this.endListener = function() {
+    this.ended = true
+    this.stream.removeListener('end', this.endListener)
+
+    if (!this.expectingEnd) {
+      this.emit('unexpectedEnd')
+    }
+
+    this.emit('end')
+  }.bind(this)
+
+  this.stream = stream
+    .on('end', this.endListener)
+  this.expectingEnd = false
+  this.ended = false
+}
+
+util.inherits(RiskyStream, EventEmitter)
+
+RiskyStream.prototype.end = function() {
+  this.expectEnd()
+  return this.stream.end()
+}
+
+RiskyStream.prototype.expectEnd = function() {
+  this.expectingEnd = true
+  return this
+}
+
+RiskyStream.prototype.waitForEnd = function() {
+  var stream = this.stream
+  var endListener
+
+  this.expectEnd()
+
+  return new Promise(function(resolve) {
+      if (stream.ended) {
+        return resolve(true)
+      }
+
+      stream.on('end', endListener = function() {
+        resolve(true)
+      })
+
+      // Make sure we actually have a chance to get the 'end' event.
+      stream.resume()
+    })
+    .finally(function() {
+      stream.removeListener('end', endListener)
+    })
+}
+
+module.exports = RiskyStream
diff --git a/crowdstf/lib/util/srv.js b/crowdstf/lib/util/srv.js
new file mode 100644
index 0000000..0a78595
--- /dev/null
+++ b/crowdstf/lib/util/srv.js
@@ -0,0 +1,125 @@
+var url = require('url')
+var util = require('util')
+
+var Promise = require('bluebird')
+var dns = Promise.promisifyAll(require('dns'))
+
+var srv = module.exports = Object.create(null)
+
+function groupByPriority(records) {
+  function sortByPriority(a, b) {
+    return a.priority - b.priority
+  }
+
+  return records.sort(sortByPriority).reduce(function(acc, record) {
+    if (acc.length) {
+      var last = acc[acc.length - 1]
+      if (last[0].priority !== record.priority) {
+        acc.push([record])
+      }
+      else {
+        last.push(record)
+      }
+    }
+    else {
+      acc.push([record])
+    }
+    return acc
+  }, [])
+}
+
+function shuffleWeighted(records) {
+  function sortByWeight(a, b) {
+    return b.weight - a.weight
+  }
+
+  function totalWeight(records) {
+    return records.reduce(function(sum, record) {
+      return sum + record.weight
+    }, 0)
+  }
+
+  function pick(records, sum) {
+    var rand = Math.random() * sum
+    var counter = 0
+
+    for (var i = 0, l = records.length; i < l; ++i) {
+      counter += records[i].weight
+      if (rand < counter) {
+        var picked = records.splice(i, 1)
+        return picked.concat(pick(records, sum - picked[0].weight))
+      }
+    }
+
+    return []
+  }
+
+  return pick(records.sort(sortByWeight), totalWeight(records))
+}
+
+function flatten(groupedRecords) {
+  return groupedRecords.reduce(function(acc, group) {
+    return acc.concat(group)
+  }, [])
+}
+
+function NEXT() {
+  Error.call(this)
+  this.name = 'NEXT'
+  Error.captureStackTrace(this, NEXT)
+}
+
+util.inherits(NEXT, Error)
+
+srv.NEXT = NEXT
+
+srv.sort = function(records) {
+  return flatten(groupByPriority(records).map(shuffleWeighted))
+}
+
+srv.resolve = function(domain) {
+  var parsedUrl = url.parse(domain)
+
+  if (!parsedUrl.protocol) {
+    return Promise.reject(new Error(
+      'Must include protocol in "%s"'
+    , domain
+    ))
+  }
+
+  if (/^srv\+/.test(parsedUrl.protocol)) {
+    parsedUrl.protocol = parsedUrl.protocol.substr(4)
+    return dns.resolveSrvAsync(parsedUrl.hostname)
+      .then(module.exports.sort)
+      .then(function(records) {
+        return records.map(function(record) {
+          parsedUrl.host = util.format('%s:%d', record.name, record.port)
+          parsedUrl.hostname = record.name
+          parsedUrl.port = record.port
+          record.url = url.format(parsedUrl)
+          return record
+        })
+      })
+  }
+  else {
+    return Promise.resolve([{
+      url: domain
+    , name: parsedUrl.hostname
+    , port: parsedUrl.port
+    }])
+  }
+}
+
+srv.attempt = function(records, fn) {
+  function next(i) {
+    if (i >= records.length) {
+      throw new Error('No more records left to try')
+    }
+
+    return fn(records[i]).catch(srv.NEXT, function() {
+      return next(i + 1)
+    })
+  }
+
+  return next(0)
+}
diff --git a/crowdstf/lib/util/statequeue.js b/crowdstf/lib/util/statequeue.js
new file mode 100644
index 0000000..d8b8d18
--- /dev/null
+++ b/crowdstf/lib/util/statequeue.js
@@ -0,0 +1,30 @@
+function StateQueue() {
+  this.queue = []
+}
+
+StateQueue.prototype.next = function() {
+  return this.queue.shift()
+}
+
+StateQueue.prototype.empty = function() {
+  return this.queue.length === 0
+}
+
+StateQueue.prototype.push = function(state) {
+  var found = false
+
+  // Not super efficient, but this shouldn't be running all the time anyway.
+  for (var i = 0, l = this.queue.length; i < l; ++i) {
+    if (this.queue[i] === state) {
+      this.queue.splice(i + 1)
+      found = true
+      break
+    }
+  }
+
+  if (!found) {
+    this.queue.push(state)
+  }
+}
+
+module.exports = StateQueue
diff --git a/crowdstf/lib/util/storage.js b/crowdstf/lib/util/storage.js
new file mode 100644
index 0000000..05cabc1
--- /dev/null
+++ b/crowdstf/lib/util/storage.js
@@ -0,0 +1,66 @@
+var events = require('events')
+var util = require('util')
+var fs = require('fs')
+
+var uuid = require('node-uuid')
+
+function Storage() {
+  events.EventEmitter.call(this)
+  this.files = Object.create(null)
+  this.timer = setInterval(this.check.bind(this), 60000)
+}
+
+util.inherits(Storage, events.EventEmitter)
+
+Storage.prototype.store = function(file) {
+  var id = uuid.v4()
+  this.set(id, file)
+  return id
+}
+
+Storage.prototype.set = function(id, file) {
+  this.files[id] = {
+    timeout: 600000
+  , lastActivity: Date.now()
+  , data: file
+  }
+
+  return file
+}
+
+Storage.prototype.remove = function(id) {
+  var file = this.files[id]
+  if (file) {
+    delete this.files[id]
+    fs.unlink(file.data.path, function() {})
+  }
+}
+
+Storage.prototype.retrieve = function(id) {
+  var file = this.files[id]
+  if (file) {
+    file.lastActivity = Date.now()
+    return file.data
+  }
+  return null
+}
+
+Storage.prototype.check = function() {
+  var now = Date.now()
+
+  Object.keys(this.files).forEach(function(id) {
+    var file = this.files[id]
+    var inactivePeriod = now - file.lastActivity
+
+    if (inactivePeriod >= file.timeout) {
+      this.remove(id)
+      this.emit('timeout', id, file.data)
+    }
+  }, this)
+}
+
+Storage.prototype.stop = function() {
+  clearInterval(this.timer)
+}
+
+module.exports = Storage
diff --git a/crowdstf/lib/util/streamutil.js b/crowdstf/lib/util/streamutil.js
new file mode 100644
index 0000000..4c6fb28
--- /dev/null
+++ b/crowdstf/lib/util/streamutil.js
@@ -0,0 +1,84 @@
+var util = require('util')
+
+var Promise = require('bluebird')
+var split = require('split')
+
+function NoSuchLineError(message) {
+  Error.call(this, message)
+  this.name = 'NoSuchLineError'
+  Error.captureStackTrace(this, NoSuchLineError)
+}
+
+util.inherits(NoSuchLineError, Error)
+
+module.exports.NoSuchLineError = NoSuchLineError
+
+module.exports.readAll = function(stream) {
+  var resolver = Promise.defer()
+  var collected = new Buffer(0)
+
+  function errorListener(err) {
+    resolver.reject(err)
+  }
+
+  function endListener() {
+    resolver.resolve(collected)
+  }
+
+  function readableListener() {
+    var chunk
+    while ((chunk = stream.read())) {
+      collected = Buffer.concat([collected, chunk])
+    }
+  }
+
+  stream.on('error', errorListener)
+  stream.on('readable', readableListener)
+  stream.on('end', endListener)
+
+  return resolver.promise.finally(function() {
+    stream.removeListener('error', errorListener)
+    stream.removeListener('readable', readableListener)
+    stream.removeListener('end', endListener)
+  })
+}
+
+module.exports.findLine = function(stream, re) {
+  var resolver = Promise.defer()
+  var piped = stream.pipe(split())
+
+  function errorListener(err) {
+    resolver.reject(err)
+  }
+
+  function endListener() {
+    resolver.reject(new NoSuchLineError())
+  }
+
+  function lineListener(line) {
+    if (re.test(line)) {
+      resolver.resolve(line)
+    }
+  }
+
+  piped.on('error', errorListener)
+  piped.on('data', lineListener)
+  piped.on('end', endListener)
+
+  return resolver.promise.finally(function() {
+    piped.removeListener('error', errorListener)
+    piped.removeListener('data', lineListener)
+    piped.removeListener('end', endListener)
+    stream.unpipe(piped)
+  })
+}
+
+module.exports.talk = function(log, format, stream) {
+  stream.pipe(split())
+    .on('data', function(chunk) {
+      var line = chunk.toString().trim()
+      if (line.length) {
+        log.info(format, line)
+      }
+    })
+}
diff --git a/crowdstf/lib/util/ttlset.js b/crowdstf/lib/util/ttlset.js
new file mode 100644
index 0000000..408dd7d
--- /dev/null
+++ b/crowdstf/lib/util/ttlset.js
@@ -0,0 +1,124 @@
+var util = require('util')
+
+var EventEmitter = require('eventemitter3').EventEmitter
+
+function TtlItem(value) {
+  this.next = null
+  this.prev = null
+  this.time = null
+  this.value = value
+}
+
+function TtlSet(ttl) {
+  EventEmitter.call(this)
+  this.head = null
+  this.tail = null
+  this.mapping = Object.create(null)
+  this.ttl = ttl
+  this.timer = null
+}
+
+util.inherits(TtlSet, EventEmitter)
+
+TtlSet.SILENT = 1
+
+TtlSet.prototype.bump = function(value, time, flags) {
+  var item = this._remove(this.mapping[value]) || this._create(value, flags)
+
+  item.time = time || Date.now()
+  item.prev = this.tail
+
+  this.tail = item
+
+  if (item.prev) {
+    item.prev.next = item
+  }
+  else {
+    this.head = item
+    this._scheduleCheck()
+  }
+}
+
+TtlSet.prototype.drop = function(value, flags) {
+  this._drop(this.mapping[value], flags)
+}
+
+TtlSet.prototype.stop = function() {
+  clearTimeout(this.timer)
+}
+
+TtlSet.prototype._scheduleCheck = function() {
+  clearTimeout(this.timer)
+  if (this.head) {
+    var delay = Math.max(0, this.ttl - (Date.now() - this.head.time))
+    this.timer = setTimeout(this._check.bind(this), delay)
+  }
+}
+
+TtlSet.prototype._check = function() {
+  var now = Date.now()
+
+  var item
+  while ((item = this.head)) {
+    if (now - item.time > this.ttl) {
+      this._drop(item, 0)
+    }
+    else {
+      break
+    }
+  }
+
+  this._scheduleCheck()
+}
+
+TtlSet.prototype._create = function(value, flags) {
+  var item = new TtlItem(value)
+
+  this.mapping[value] = item
+
+  if ((flags & TtlSet.SILENT) !== TtlSet.SILENT) {
+    this.emit('insert', value)
+  }
+
+  return item
+}
+
+TtlSet.prototype._drop = function(item, flags) {
+  if (item) {
+    this._remove(item)
+
+    delete this.mapping[item.value]
+
+    if ((flags & TtlSet.SILENT) !== TtlSet.SILENT) {
+      this.emit('drop', item.value)
+    }
+  }
+}
+
+TtlSet.prototype._remove = function(item) {
+  if (!item) {
+    return null
+  }
+
+  if (item.prev) {
+    item.prev.next = item.next
+  }
+
+  if (item.next) {
+    item.next.prev = item.prev
+  }
+
+  if (item === this.head) {
+    this.head = item.next
+  }
+
+  if (item === this.tail) {
+    this.tail = item.prev
+  }
+
+  item.next = item.prev = null
+
+  return item
+}
+
+module.exports = TtlSet
diff --git a/crowdstf/lib/util/urlutil.js b/crowdstf/lib/util/urlutil.js
new file mode 100644
index 0000000..186c82e
--- /dev/null
+++ b/crowdstf/lib/util/urlutil.js
@@ -0,0 +1,19 @@
+var url = require('url')
+
+/* eslint guard-for-in:0 */
+module.exports.addParams = function(originalUrl, params) {
+  var parsed = url.parse(originalUrl, true)
+  parsed.search = null
+  // TODO: change to ES6 loop
+  for (var key in params) {
+    parsed.query[key] = params[key]
+  }
+  return url.format(parsed)
+}
+
+module.exports.removeParam = function(originalUrl, param) {
+  var parsed = url.parse(originalUrl, true)
+  parsed.search = null
+  delete parsed.query[param]
+  return url.format(parsed)
+}
diff --git a/crowdstf/lib/util/vncauth.js b/crowdstf/lib/util/vncauth.js
new file mode 100644
index 0000000..1b9ec5d
--- /dev/null
+++ b/crowdstf/lib/util/vncauth.js
@@ -0,0 +1,44 @@
+var crypto = require('crypto')
+
+// See http://graphics.stanford.edu/~seander/bithacks.html#ReverseByteWith32Bits
+function reverseByteBits(b) {
+  return (((b * 0x0802 & 0x22110) |
+  (b * 0x8020 & 0x88440)) * 0x10101 >> 16 & 0xFF)
+}
+
+function reverseBufferByteBits(b) {
+  var result = new Buffer(b.length)
+
+  for (var i = 0; i < result.length; ++i) {
+    result[i] = reverseByteBits(b[i])
+  }
+
+  return result
+}
+
+function normalizePassword(password) {
+  var key = new Buffer(8).fill(0)
+
+  // Make sure the key is always 8 bytes long. VNC passwords cannot be
+  // longer than 8 bytes. Shorter passwords are padded with zeroes.
+  reverseBufferByteBits(password).copy(key, 0, 0, 8)
+
+  return key
+}
+
+function encrypt(challenge, password) {
+  var key = normalizePassword(password)
+  var iv = new Buffer(0).fill(0)
+
+  // Note: do not call .final(), .update() is the one that gives us the
+  // desired result.
+  return crypto.createCipheriv('des-ecb', key, iv).update(challenge)
+}
+
+module.exports.encrypt = encrypt
+
+function verify(response, challenge, password) {
+  return encrypt(challenge, password).equals(response)
+}
+
+module.exports.verify = verify
diff --git a/crowdstf/lib/util/zmqutil.js b/crowdstf/lib/util/zmqutil.js
new file mode 100644
index 0000000..e551ee1
--- /dev/null
+++ b/crowdstf/lib/util/zmqutil.js
@@ -0,0 +1,26 @@
+// ISSUE-100 (https://github.com/openstf/stf/issues/100)
+
+// In some networks TCP Connection dies if kept idle for long.
+// Setting TCP_KEEPALIVE option true, to all the zmq sockets
+// won't let it die
+
+var zmq = require('zmq')
+
+var log = require('./logger').createLogger('util:zmqutil')
+
+module.exports.socket = function() {
+  var sock = zmq.socket.apply(zmq, arguments)
+
+  ;['ZMQ_TCP_KEEPALIVE', 'ZMQ_TCP_KEEPALIVE_IDLE'].forEach(function(opt) {
+    if (process.env[opt]) {
+      try {
+        sock.setsockopt(zmq[opt], Number(process.env[opt]))
+      }
+      catch (err) {
+        log.warn('ZeroMQ library too old, no support for %s', opt)
+      }
+    }
+  })
+
+  return sock
+}
diff --git a/crowdstf/lib/wire/channelmanager.js b/crowdstf/lib/wire/channelmanager.js
new file mode 100644
index 0000000..9d8aa8a
--- /dev/null
+++ b/crowdstf/lib/wire/channelmanager.js
@@ -0,0 +1,70 @@
+var events = require('events')
+var util = require('util')
+
+function ChannelManager() {
+  events.EventEmitter.call(this)
+  this.channels = Object.create(null)
+}
+
+util.inherits(ChannelManager, events.EventEmitter)
+
+ChannelManager.prototype.register = function(id, options) {
+  var channel = this.channels[id] = {
+    timeout: options.timeout
+  , alias: options.alias
+  , lastActivity: Date.now()
+  , timer: null
+  }
+
+  if (channel.alias) {
+    // The alias can only be active for a single channel at a time
+    if (this.channels[channel.alias]) {
+      throw new Error(util.format(
+        'Cannot create alias "%s" for "%s"; the channel already exists'
+      , channel.alias
+      , id
+      ))
+    }
+
+    this.channels[channel.alias] = channel
+  }
+
+  // Set timer with initial check
+  this.check(id)
+}
+
+ChannelManager.prototype.unregister = function(id) {
+  var channel = this.channels[id]
+  if (channel) {
+    delete this.channels[id]
+    clearTimeout(channel.timer)
+    if (channel.alias) {
+      delete this.channels[channel.alias]
+    }
+  }
+}
+
+ChannelManager.prototype.keepalive = function(id) {
+  var channel = this.channels[id]
+  if (channel) {
+    channel.lastActivity = Date.now()
+  }
+}
+
+ChannelManager.prototype.check = function(id) {
+  var channel = this.channels[id]
+  var inactivePeriod = Date.now() - channel.lastActivity
+
+  if (inactivePeriod >= channel.timeout) {
+    this.unregister(id)
+    this.emit('timeout', id)
+  }
+  else if (channel.timeout < Infinity) {
+    channel.timer = setTimeout(
+      this.check.bind(this, id)
+    , channel.timeout - inactivePeriod
+    )
+  }
+}
+
+module.exports = ChannelManager
diff --git a/crowdstf/lib/wire/index.js b/crowdstf/lib/wire/index.js
new file mode 100644
index 0000000..2b04c31
--- /dev/null
+++ b/crowdstf/lib/wire/index.js
@@ -0,0 +1,21 @@
+var path = require('path')
+
+var ProtoBuf = require('protobufjs')
+
+var wire = ProtoBuf.loadProtoFile(path.join(__dirname, 'wire.proto')).build()
+
+wire.ReverseMessageType = Object.keys(wire.MessageType)
+  .reduce(
+    function(acc, type) {
+      var code = wire.MessageType[type]
+      if (!wire[type]) {
+        throw new Error('wire.MessageType has unknown value "' + type + '"')
+      }
+      wire[type].$code = wire[type].prototype.$code = code
+      acc[code] = type
+      return acc
+    }
+  , Object.create(null)
+  )
+
+module.exports = wire
diff --git a/crowdstf/lib/wire/messagestream.js b/crowdstf/lib/wire/messagestream.js
new file mode 100644
index 0000000..956f0fa
--- /dev/null
+++ b/crowdstf/lib/wire/messagestream.js
@@ -0,0 +1,73 @@
+var util = require('util')
+var stream = require('stream')
+
+function DelimitedStream() {
+  stream.Transform.call(this)
+  this._length = 0
+  this._lengthIndex = 0
+  this._readingLength = true
+  this._buffer = new Buffer(0)
+}
+
+util.inherits(DelimitedStream, stream.Transform)
+
+DelimitedStream.prototype._transform = function(chunk, encoding, done) {
+  this._buffer = Buffer.concat([this._buffer, chunk])
+
+  while (this._buffer.length) {
+    if (this._readingLength) {
+      var byte = this._buffer[0]
+      this._length += (byte & 0x7f) << (7 * this._lengthIndex)
+      if (byte & (1 << 7)) {
+        this._lengthIndex += 1
+        this._readingLength = true
+      }
+      else {
+        this._lengthIndex = 0
+        this._readingLength = false
+      }
+      this._buffer = this._buffer.slice(1)
+    }
+    else {
+      if (this._length <= this._buffer.length) {
+        this.push(this._buffer.slice(0, this._length))
+        this._buffer = this._buffer.slice(this._length)
+        this._length = 0
+        this._readingLength = true
+      }
+      else {
+        // Wait for more chunks
+        break
+      }
+    }
+  }
+
+  done()
+}
+
+module.exports.DelimitedStream = DelimitedStream
+
+function DelimitingStream() {
+  stream.Transform.call(this)
+}
+
+util.inherits(DelimitingStream, stream.Transform)
+
+DelimitingStream.prototype._transform = function(chunk, encoding, done) {
+  var length = chunk.length
+  var lengthBytes = []
+
+  while (length > 0x7f) {
+    lengthBytes.push((1 << 7) + (length & 0x7f))
+    length >>= 7
+  }
+
+  lengthBytes.push(length)
+
+  this.push(new Buffer(lengthBytes))
+  this.push(chunk)
+
+  done()
+}
+
+module.exports.DelimitingStream = DelimitingStream
diff --git a/crowdstf/lib/wire/router.js b/crowdstf/lib/wire/router.js
new file mode 100644
index 0000000..840ebfd
--- /dev/null
+++ b/crowdstf/lib/wire/router.js
@@ -0,0 +1,56 @@
+var EventEmitter = require('eventemitter3').EventEmitter
+var util = require('util')
+
+var wire = require('./')
+var log = require('../util/logger').createLogger('wire:router')
+var on = EventEmitter.prototype.on
+
+function Router() {
+  if (!(this instanceof Router)) {
+    return new Router()
+  }
+
+  EventEmitter.call(this)
+}
+
+util.inherits(Router, EventEmitter)
+
+Router.prototype.on = function(message, handler) {
+  return on.call(this, message.$code, handler)
+}
+
+Router.prototype.removeListener = function(message, handler) {
+  return EventEmitter.prototype.removeListener.call(
+    this
+  , message.$code
+  , handler
+  )
+}
+
+Router.prototype.handler = function() {
+  return function(channel, data) {
+    var wrapper = wire.Envelope.decode(data)
+    var type = wire.ReverseMessageType[wrapper.type]
+
+    if (type) {
+      this.emit(
+        wrapper.type
+      , wrapper.channel || channel
+      , wire[type].decode(wrapper.message)
+      , data
+      )
+      this.emit(
+        'message'
+      , channel
+      )
+    }
+    else {
+      log.warn(
+        'Unknown message type "%d", perhaps we need an update?'
+      , wrapper.type
+      )
+    }
+  }.bind(this)
+}
+
+module.exports = Router
diff --git a/crowdstf/lib/wire/seqqueue.js b/crowdstf/lib/wire/seqqueue.js
new file mode 100644
index 0000000..410b3b3
--- /dev/null
+++ b/crowdstf/lib/wire/seqqueue.js
@@ -0,0 +1,64 @@
+function SeqQueue(size, maxWaiting) {
+  this.lo = 0
+  this.size = size
+  this.maxWaiting = maxWaiting
+  this.waiting = 0
+  this.list = new Array(size)
+  this.locked = true
+}
+
+SeqQueue.prototype.start = function(seq) {
+  this.locked = false
+  // The loop in maybeConsume() will make sure that the value wraps correctly
+  // if necessary.
+  this.lo = seq + 1
+  this.maybeConsume()
+}
+
+SeqQueue.prototype.stop = function() {
+  this.locked = true
+  this.maybeConsume()
+}
+
+SeqQueue.prototype.push = function(seq, handler) {
+  if (seq >= this.size) {
+    return
+  }
+
+  this.list[seq] = handler
+  this.waiting += 1
+  this.maybeConsume()
+}
+
+SeqQueue.prototype.maybeConsume = function() {
+  if (this.locked) {
+    return
+  }
+
+  while (this.waiting) {
+    // Did we reach the end of the loop? If so, start from the beginning.
+    if (this.lo >= this.size) {
+      this.lo = 0
+    }
+
+    var handler = this.list[this.lo]
+    // Have we received it yet?
+    if (handler) {
+      this.list[this.lo] = undefined
+      handler()
+      this.lo += 1
+      this.waiting -= 1
+    }
+    // Are we too much behind? If so, just move on.
+    else if (this.waiting >= this.maxWaiting) {
+      this.lo += 1
+      this.waiting -= 1
+    }
+    // We don't have it yet, stop.
+    else {
+      break
+    }
+  }
+}
+
+module.exports = SeqQueue
diff --git a/crowdstf/lib/wire/util.js b/crowdstf/lib/wire/util.js
new file mode 100644
index 0000000..5ebffc3
--- /dev/null
+++ b/crowdstf/lib/wire/util.js
@@ -0,0 +1,72 @@
+var uuid = require('node-uuid')
+
+var wire = require('./')
+
+var wireutil = {
+  global: '*ALL'
+, makePrivateChannel: function() {
+    return uuid.v4(null, new Buffer(16)).toString('base64')
+  }
+, toDeviceStatus: function(type) {
+    return wire.DeviceStatus[{
+      device: 'ONLINE'
+    , emulator: 'ONLINE'
+    , unauthorized: 'UNAUTHORIZED'
+    , offline: 'OFFLINE'
+    }[type]]
+  }
+, toDeviceRequirements: function(requirements) {
+    return Object.keys(requirements).map(function(name) {
+      var item = requirements[name]
+      return new wire.DeviceRequirement(
+        name
+      , item.value
+      , wire.RequirementType[item.match.toUpperCase()]
+      )
+    })
+  }
+, envelope: function(message) {
+    return new wire.Envelope(message.$code, message.encode()).encodeNB()
+  }
+, transaction: function(channel, message) {
+    return new wire.Envelope(
+        message.$code
+      , message.encode()
+      , channel
+      )
+      .encodeNB()
+  }
+, reply: function(source) {
+    var seq = 0
+    return {
+      okay: function(data, body) {
+        return wireutil.envelope(new wire.TransactionDoneMessage(
+          source
+        , seq++
+        , true
+        , data === null ? null : (data || 'success')
+        , body ? JSON.stringify(body) : null
+        ))
+      }
+    , fail: function(data, body) {
+        return wireutil.envelope(new wire.TransactionDoneMessage(
+          source
+        , seq++
+        , false
+        , data || 'fail'
+        , body ? JSON.stringify(body) : null
+        ))
+      }
+    , progress: function(data, progress) {
+        return wireutil.envelope(new wire.TransactionProgressMessage(
+          source
+        , seq++
+        , data
+        , ~~progress
+        ))
+      }
+    }
+  }
+}
+
+module.exports = wireutil
diff --git a/crowdstf/lib/wire/wire.proto b/crowdstf/lib/wire/wire.proto
new file mode 100644
index 0000000..868e507
--- /dev/null
+++ b/crowdstf/lib/wire/wire.proto
@@ -0,0 +1,553 @@
+// Message wrapper
+
+enum MessageType {
+  CopyMessage                = 33;
+  DeviceIntroductionMessage  = 74;
+  DeviceAbsentMessage        = 1;
+  DeviceIdentityMessage      = 2;
+  DeviceLogcatEntryMessage   = 3;
+  DeviceLogMessage           = 4;
+  DeviceReadyMessage         = 5;
+  DevicePresentMessage       = 6;
+  DevicePropertiesMessage    = 7;
+  DeviceRegisteredMessage    = 8;
+  DeviceStatusMessage        = 9;
+  GroupMessage               = 10;
+  InstallMessage             = 30;
+  PhysicalIdentifyMessage    = 29;
+  JoinGroupMessage           = 11;
+  JoinGroupByAdbFingerprintMessage = 69;
+  JoinGroupByVncAuthResponseMessage = 90;
+  VncAuthResponsesUpdatedMessage = 91;
+  AutoGroupMessage           = 70;
+  AdbKeysUpdatedMessage      = 71;
+  KeyDownMessage             = 12;
+  KeyPressMessage            = 13;
+  KeyUpMessage               = 14;
+  LaunchActivityMessage      = 31;
+  LeaveGroupMessage          = 15;
+  LogcatApplyFiltersMessage  = 16;
+  PasteMessage               = 32;
+  ProbeMessage               = 17;
+  ShellCommandMessage        = 18;
+  ShellKeepAliveMessage      = 19;
+  TouchDownMessage           = 21;
+  TouchMoveMessage           = 22;
+  TouchUpMessage             = 23;
+  TouchCommitMessage         = 65;
+  TouchResetMessage          = 66;
+  GestureStartMessage        = 67;
+  GestureStopMessage         = 68;
+  TransactionDoneMessage     = 24;
+  TransactionProgressMessage = 25;
+  TypeMessage                = 26;
+  UngroupMessage             = 27;
+  UninstallMessage           = 34;
+  RotateMessage              = 35;
+  ForwardTestMessage         = 36;
+  ForwardCreateMessage       = 37;
+  ForwardRemoveMessage       = 38;
+  LogcatStartMessage         = 39;
+  LogcatStopMessage          = 40;
+  BrowserOpenMessage         = 41;
+  BrowserClearMessage        = 42;
+  AirplaneModeEvent          = 43;
+  BatteryEvent               = 44;
+  DeviceBrowserMessage       = 45;
+  ConnectivityEvent          = 46;
+  PhoneStateEvent            = 47;
+  RotationEvent              = 48;
+  StoreOpenMessage           = 49;
+  ScreenCaptureMessage       = 50;
+  DeviceHeartbeatMessage     = 73;
+  RebootMessage              = 52;
+  ConnectStartMessage        = 53;
+  ConnectStopMessage         = 54;
+  RingerSetMessage           = 56;
+  RingerGetMessage           = 64;
+  WifiSetEnabledMessage      = 57;
+  WifiGetStatusMessage       = 58;
+  AccountAddMenuMessage      = 59;
+  AccountAddMessage          = 60;
+  AccountCheckMessage        = 63;
+  AccountGetMessage          = 62;
+  AccountRemoveMessage       = 55;
+  SdStatusMessage            = 61;
+  ReverseForwardsEvent       = 72;
+  FileSystemListMessage      = 81;
+  FileSystemGetMessage       = 82;
+}
+
+message FileSystemListMessage {
+  required string dir = 1;
+}
+
+message FileSystemGetMessage {
+  required string file = 1;
+}
+
+message Envelope {
+  required MessageType type = 1;
+  required bytes message = 2;
+  optional string channel = 3;
+}
+
+message TransactionProgressMessage {
+  required string source = 1;
+  required uint32 seq = 2;
+  optional string data = 3;
+  optional uint32 progress = 4 [default = 0];
+}
+
+message TransactionDoneMessage {
+  required string source = 1;
+  required uint32 seq = 2;
+  required bool success = 3;
+  optional string data = 4;
+  optional string body = 5;
+}
+
+// Logging
+
+message DeviceLogMessage {
+  required string serial = 1;
+  required double timestamp = 2;
+  required uint32 priority = 3;
+  required string tag = 4;
+  required uint32 pid = 5;
+  required string message = 6;
+  required string identifier = 7;
+}
+
+// Introductions
+
+message ProviderMessage {
+  required string channel = 1;
+  required string name = 2;
+}
+
+message DeviceHeartbeatMessage {
+  required string serial = 1;
+}
+
+message DeviceIntroductionMessage {
+  required string serial = 1;
+  required DeviceStatus status = 2;
+  required ProviderMessage provider = 3;
+}
+
+message DeviceRegisteredMessage {
+  required string serial = 1;
+}
+
+message DevicePresentMessage {
+  required string serial = 1;
+}
+
+message DeviceAbsentMessage {
+  required string serial = 1;
+}
+
+message DeviceReadyMessage {
+  required string serial = 1;
+  required string channel = 2;
+}
+
+message ProbeMessage {
+}
+
+enum DeviceStatus {
+  OFFLINE = 1;
+  UNAUTHORIZED = 2;
+  ONLINE = 3;
+}
+
+message DeviceStatusMessage {
+  required string serial = 1;
+  required DeviceStatus status = 2;
+}
+
+message DeviceDisplayMessage {
+  required int32 id = 1;
+  required int32 width = 2;
+  required int32 height = 3;
+  required int32 rotation = 4;
+  required float xdpi = 5;
+  required float ydpi = 6;
+  required float fps = 7;
+  required float density = 8;
+  required bool secure = 9;
+  required string url = 10;
+  optional float size = 11;
+}
+
+message DeviceBrowserAppMessage {
+  required string id = 1;
+  required string type = 2;
+  required string name = 3;
+  required bool selected = 4;
+  required bool system = 5;
+}
+
+message DeviceBrowserMessage {
+  required string serial = 1;
+  required bool selected = 2;
+  repeated DeviceBrowserAppMessage apps = 3;
+}
+
+message DevicePhoneMessage {
+  optional string imei = 1;
+  optional string phoneNumber = 2;
+  optional string iccid = 3;
+  optional string network = 4;
+}
+
+message DeviceIdentityMessage {
+  required string serial = 1;
+  required string platform = 2;
+  required string manufacturer = 3;
+  optional string operator = 4;
+  required string model = 5;
+  required string version = 6;
+  required string abi = 7;
+  required string sdk = 8;
+  required DeviceDisplayMessage display = 9;
+  required DevicePhoneMessage phone = 11;
+  optional string product = 12;
+}
+
+message DeviceProperty {
+  required string name = 1;
+  required string value = 2;
+}
+
+message DevicePropertiesMessage {
+  required string serial = 1;
+  repeated DeviceProperty properties = 2;
+}
+
+// Grouping
+
+enum RequirementType {
+  SEMVER = 1;
+  GLOB = 2;
+  EXACT = 3;
+}
+
+message DeviceRequirement {
+  required string name = 1;
+  required string value = 2;
+  required RequirementType type = 3;
+}
+
+message OwnerMessage {
+  required string email = 1;
+  required string name = 2;
+  required string group = 3;
+}
+
+message GroupMessage {
+  required OwnerMessage owner = 1;
+  optional uint32 timeout = 2;
+  repeated DeviceRequirement requirements = 3;
+}
+
+message AutoGroupMessage {
+  required OwnerMessage owner = 1;
+  required string identifier = 2;
+}
+
+message UngroupMessage {
+  repeated DeviceRequirement requirements = 2;
+}
+
+message JoinGroupMessage {
+  required string serial = 1;
+  required OwnerMessage owner = 2;
+}
+
+message JoinGroupByAdbFingerprintMessage {
+  required string serial = 1;
+  required string fingerprint = 2;
+  optional string comment = 3;
+  optional string currentGroup = 4;
+}
+
+message JoinGroupByVncAuthResponseMessage {
+  required string serial = 1;
+  required string response = 2;
+  optional string currentGroup = 4;
+}
+
+message AdbKeysUpdatedMessage {
+}
+
+message VncAuthResponsesUpdatedMessage {
+}
+
+message LeaveGroupMessage {
+  required string serial = 1;
+  required OwnerMessage owner = 2;
+  required string reason = 3;
+}
+
+// Input
+
+message PhysicalIdentifyMessage {
+}
+
+message TouchDownMessage {
+  required uint32 seq = 1;
+  required uint32 contact = 2;
+  required float x = 3;
+  required float y = 4;
+  optional float pressure = 5;
+}
+
+message TouchMoveMessage {
+  required uint32 seq = 1;
+  required uint32 contact = 2;
+  required float x = 3;
+  required float y = 4;
+  optional float pressure = 5;
+}
+
+message TouchUpMessage {
+  required uint32 seq = 1;
+  required uint32 contact = 2;
+}
+
+message TouchCommitMessage {
+  required uint32 seq = 1;
+}
+
+message TouchResetMessage {
+  required uint32 seq = 1;
+}
+
+message GestureStartMessage {
+  required uint32 seq = 1;
+}
+
+message GestureStopMessage {
+  required uint32 seq = 1;
+}
+
+message TypeMessage {
+  required string text = 1;
+}
+
+message PasteMessage {
+  required string text = 1;
+}
+
+message CopyMessage {
+}
+
+message KeyDownMessage {
+  required string key = 1;
+}
+
+message KeyUpMessage {
+  required string key = 1;
+}
+
+message KeyPressMessage {
+  required string key = 1;
+}
+
+message RebootMessage {
+}
+
+// Output
+
+message DeviceLogcatEntryMessage {
+  required string serial = 1;
+  required double date = 2;
+  required uint32 pid = 3;
+  required uint32 tid = 4;
+  required uint32 priority = 5;
+  required string tag = 6;
+  required string message = 7;
+}
+
+message LogcatFilter {
+  required string tag = 1;
+  required uint32 priority = 2;
+}
+
+message LogcatStartMessage {
+  repeated LogcatFilter filters = 1;
+}
+
+message LogcatStopMessage {
+}
+
+message LogcatApplyFiltersMessage {
+  repeated LogcatFilter filters = 1;
+}
+
+// Commands
+
+message ShellCommandMessage {
+  required string command = 1;
+  required uint32 timeout = 2;
+}
+
+message ShellKeepAliveMessage {
+  required uint32 timeout = 1;
+}
+
+message InstallMessage {
+  required string href = 1;
+  required bool launch = 2;
+  optional string manifest = 3;
+}
+
+message UninstallMessage {
+  required string packageName = 1;
+}
+
+message LaunchActivityMessage {
+  required string action = 1;
+  required string component = 2;
+  repeated string category = 3;
+  optional uint32 flags = 4;
+}
+
+message RotateMessage {
+  required int32 rotation = 1;
+}
+
+message ForwardTestMessage {
+  required string targetHost = 1;
+  required uint32 targetPort = 2;
+}
+
+message ForwardCreateMessage {
+  required string id = 1;
+  required uint32 devicePort = 2;
+  required string targetHost = 3;
+  required uint32 targetPort = 4;
+}
+
+message ForwardRemoveMessage {
+  required string id = 1;
+}
+
+message ReverseForward {
+  required string id = 1;
+  required uint32 devicePort = 2;
+  required string targetHost = 3;
+  required uint32 targetPort = 4;
+}
+
+message ReverseForwardsEvent {
+  required string serial = 1;
+  repeated ReverseForward forwards = 2;
+}
+
+message BrowserOpenMessage {
+  required string url = 1;
+  optional string browser = 2;
+}
+
+message BrowserClearMessage {
+  optional string browser = 1;
+}
+
+message StoreOpenMessage {
+}
+
+message ScreenCaptureMessage {
+}
+
+message ConnectStartMessage {
+}
+
+message ConnectStopMessage {
+}
+
+message AccountAddMenuMessage {
+}
+
+message AccountAddMessage {
+  required string user = 1;
+  required string password = 2;
+}
+
+message AccountCheckMessage {
+  required string type = 1;
+  required string account = 2;
+}
+
+message AccountGetMessage {
+  optional string type = 1;
+}
+
+message AccountRemoveMessage {
+  required string type = 1;
+  optional string account = 2;
+}
+
+message SdStatusMessage {
+}
+
+enum RingerMode {
+    SILENT = 0;
+    VIBRATE = 1;
+    NORMAL = 2;
+}
+
+message RingerSetMessage {
+  required RingerMode mode = 1;
+}
+
+message RingerGetMessage {
+}
+
+message WifiSetEnabledMessage {
+  required bool enabled = 1;
+}
+
+message WifiGetStatusMessage {
+}
+
+// Events, these must be kept in sync with STFService/wire.proto
+
+message AirplaneModeEvent {
+  required string serial = 1;
+  required bool enabled = 2;
+}
+
+message BatteryEvent {
+  required string serial = 1;
+  required string status = 2;
+  required string health = 3;
+  required string source = 4;
+  required uint32 level = 5;
+  required uint32 scale = 6;
+  required double temp = 7;
+  required double voltage = 8;
+}
+
+message ConnectivityEvent {
+  required string serial = 1;
+  required bool connected = 2;
+  optional string type = 3;
+  optional string subtype = 4;
+  optional bool failover = 5;
+  optional bool roaming = 6;
+}
+
+message PhoneStateEvent {
+  required string serial = 1;
+  required string state = 2;
+  required bool manual = 3;
+  optional string operator = 4;
+}
+
+message RotationEvent {
+  required string serial = 1;
+  required int32 rotation = 2;
+}
diff --git a/crowdstf/package.json b/crowdstf/package.json
new file mode 100644
index 0000000..053aa2e
--- /dev/null
+++ b/crowdstf/package.json
@@ -0,0 +1,158 @@
+{
+  "name": "stf",
+  "version": "1.1.1",
+  "description": "Smartphone Test Farm",
+  "keywords": [
+    "adb",
+    "android",
+    "stf",
+    "test",
+    "remote"
+  ],
+  "bugs": {
+    "url": "https://github.com/openstf/stf/issues"
+  },
+  "license": "Apache-2.0",
+  "author": {
+    "name": "CyberAgent, Inc.",
+    "email": "npm@cyberagent.co.jp",
+    "url": "http://www.cyberagent.co.jp/"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/openstf/stf.git"
+  },
+  "bin": {
+    "stf": "./bin/stf"
+  },
+  "scripts": {
+    "test": "gulp test",
+    "prepublish": "bower install && not-in-install && gulp build || in-install"
+  },
+  "dependencies": {
+    "adbkit": "^2.4.1",
+    "adbkit-apkreader": "^1.0.0",
+    "adbkit-monkey": "^1.0.1",
+    "aws-sdk": "^2.2.3",
+    "basic-auth": "^1.0.3",
+    "bluebird": "^2.9.34",
+    "body-parser": "^1.13.3",
+    "bufferutil": "^1.2.1",
+    "chalk": "~1.1.1",
+    "commander": "^2.9.0",
+    "compression": "^1.5.2",
+    "cookie-session": "^1.2.0",
+    "csurf": "^1.7.0",
+    "debug": "^2.2.0",
+    "eventemitter3": "^0.1.6",
+    "express": "^4.13.3",
+    "express-validator": "^2.18.0",
+    "formidable": "^1.0.17",
+    "gm": "^1.21.1",
+    "hipchatter": "^0.2.0",
+    "http-proxy": "^1.11.2",
+    "in-publish": "^2.0.0",
+    "jade": "^1.9.2",
+    "jpeg-turbo": "^0.4.0",
+    "jws": "^3.1.0",
+    "ldapjs": "^1.0.0",
+    "lodash": "^3.10.1",
+    "markdown-serve": "^0.3.2",
+    "mime": "^1.3.4",
+    "minimatch": "^3.0.0",
+    "my-local-ip": "^1.0.0",
+    "node-uuid": "^1.4.3",
+    "passport": "^0.3.2",
+    "openid": "^0.5.13",
+    "passport-oauth2": "^1.1.2",
+    "passport-saml": "^0.15.0",
+    "protobufjs": "^3.8.2",
+    "proxy-addr": "^1.0.10",
+    "request": "^2.67.0",
+    "request-progress": "^2.0.1",
+    "rethinkdb": "^2.0.2",
+    "semver": "^5.0.1",
+    "serve-favicon": "^2.2.0",
+    "serve-static": "^1.9.2",
+    "slack-client": "^2.0.0-beta.3",
+    "socket.io": "1.4.5",
+    "split": "^1.0.0",
+    "stf-appstore-db": "^1.0.0",
+    "stf-browser-db": "^1.0.2",
+    "stf-device-db": "^1.2.0",
+    "stf-syrup": "^1.0.0",
+    "stf-wiki": "^1.0.0",
+    "temp": "^0.8.1",
+    "transliteration": "^0.1.1",
+    "utf-8-validate": "^1.2.1",
+    "ws": "^1.0.1",
+    "url-join": "0.0.1",
+    "zmq": "^2.14.0"
+  },
+  "devDependencies": {
+    "async": "^1.5.2",
+    "bower": "^1.7.2",
+    "chai": "^3.4.1",
+    "css-loader": "^0.23.1",
+    "del": "^2.0.1",
+    "eslint": "^2.0.0-beta.1",
+    "event-stream": "^3.3.2",
+    "exports-loader": "^0.6.2",
+    "extract-text-webpack-plugin": "^1.0.1",
+    "file-loader": "^0.8.5",
+    "gulp": "^3.8.11",
+    "gulp-angular-gettext": "^2.1.0",
+    "gulp-eslint": "^1.1.1",
+    "gulp-jade": "^1.0.0",
+    "gulp-jsonlint": "^1.0.2",
+    "gulp-protractor": "^2.1.0",
+    "gulp-run": "^1.6.12",
+    "gulp-util": "^3.0.7",
+    "html-loader": "^0.4.0",
+    "imports-loader": "^0.6.5",
+    "jasmine-core": "^2.4.1",
+    "jasmine-reporters": "^2.1.1",
+    "json-loader": "^0.5.4",
+    "karma": "^0.13.19",
+    "karma-chrome-launcher": "^0.2.2",
+    "karma-firefox-launcher": "^0.1.7",
+    "karma-ie-launcher": "^0.2.0",
+    "karma-jasmine": "^0.3.5",
+    "karma-junit-reporter": "^0.3.8",
+    "karma-opera-launcher": "^0.3.0",
+    "karma-phantomjs-launcher": "^1.0.0",
+    "karma-safari-launcher": "^0.1.1",
+    "karma-webpack": "^1.6.0",
+    "less": "^2.4.0",
+    "less-loader": "^2.2.2",
+    "memory-fs": "^0.3.0",
+    "node-libs-browser": "^1.0.0",
+    "node-sass": "^3.4.2",
+    "phantomjs-prebuilt": "^2.1.3",
+    "protractor": "^3.0.0",
+    "protractor-html-screenshot-reporter": "0.0.21",
+    "raw-loader": "^0.5.1",
+    "sass-loader": "^3.1.2",
+    "script-loader": "^0.6.1",
+    "sinon": "^1.17.2",
+    "sinon-chai": "^2.7.0",
+    "socket.io-client": "1.4.5",
+    "style-loader": "^0.13.0",
+    "template-html-loader": "^0.0.3",
+    "url-loader": "^0.5.7",
+    "webpack": "^1.12.11",
+    "webpack-dev-server": "^1.14.1"
+  },
+  "engineStrict": true,
+  "engines": {
+    "node": ">= 4.2"
+  },
+  "externalDependencies": {
+    "rethinkdb": ">= 2.2",
+    "zeromq": ">= 4",
+    "protobuf": "~2",
+    "gm": "~1",
+    "adb": "~1"
+  },
+  "preferGlobal": true
+}
diff --git a/crowdstf/res/.eslintrc b/crowdstf/res/.eslintrc
new file mode 100644
index 0000000..8490316
--- /dev/null
+++ b/crowdstf/res/.eslintrc
@@ -0,0 +1,28 @@
+// TODO: Some day use eslint-plugin-angular
+// https://github.com/Gillespie59/eslint-plugin-angular
+{
+  "env": {
+    "commonjs": true,
+    "browser": true,
+    "node": false,
+    "jasmine": true
+  },
+  "rules": {
+    // TODO: for now lets just mute them
+    "comma-style": 0,
+    "padded-blocks": 0,
+    "no-unused-vars": 0,
+    "lines-around-comment": 0,
+    "no-use-before-define": 0,
+    "brace-style": 0,
+    "new-cap": 0,
+    "spaced-comment": 0,
+    "quote-props": 0,
+    "operator-linebreak": 0
+  },
+  "globals": {
+    "angular": 1,
+    "inject": 1,
+    "waitUrl": 1
+  }
+}
diff --git a/crowdstf/res/app/app.js b/crowdstf/res/app/app.js
new file mode 100644
index 0000000..5a3a4b7
--- /dev/null
+++ b/crowdstf/res/app/app.js
@@ -0,0 +1,32 @@
+require.ensure([], function(require) {
+  require('angular')
+  require('angular-route')
+  require('angular-touch')
+
+  angular.module('app', [
+    'ngRoute',
+    'ngTouch',
+    require('gettext').name,
+    require('angular-hotkeys').name,
+    require('./layout').name,
+    require('./device-list').name,
+    require('./control-panes').name,
+    require('./menu').name,
+    require('./settings').name,
+    require('./docs').name,
+    require('./user').name,
+    require('./../common/lang').name,
+    require('stf/standalone').name
+  ])
+    .config(function($routeProvider, $locationProvider) {
+      $locationProvider.hashPrefix('!')
+      $routeProvider
+        .otherwise({
+          redirectTo: '/devices'
+        })
+    })
+
+    .config(function(hotkeysProvider) {
+      hotkeysProvider.templateTitle = 'Keyboard Shortcuts:'
+    })
+})
diff --git a/crowdstf/res/app/components/stf/admin-mode/admin-mode-directive.js b/crowdstf/res/app/components/stf/admin-mode/admin-mode-directive.js
new file mode 100644
index 0000000..3c47abf
--- /dev/null
+++ b/crowdstf/res/app/components/stf/admin-mode/admin-mode-directive.js
@@ -0,0 +1,11 @@
+module.exports = function adminModeDirective($rootScope, SettingsService) {
+  return {
+    restrict: 'AE',
+    link: function() {
+      SettingsService.bind($rootScope, {
+        target: 'adminMode',
+        defaultValue: false
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/admin-mode/admin-mode-spec.js b/crowdstf/res/app/components/stf/admin-mode/admin-mode-spec.js
new file mode 100644
index 0000000..0bee139
--- /dev/null
+++ b/crowdstf/res/app/components/stf/admin-mode/admin-mode-spec.js
@@ -0,0 +1,3 @@
+describe('AdminModeService', function() {
+  beforeEach(angular.mock.module(require('./').name))
+})
diff --git a/crowdstf/res/app/components/stf/admin-mode/index.js b/crowdstf/res/app/components/stf/admin-mode/index.js
new file mode 100644
index 0000000..6f1a9cd
--- /dev/null
+++ b/crowdstf/res/app/components/stf/admin-mode/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.admin-mode', [
+
+])
+  .directive('adminMode', require('./admin-mode-directive'))
diff --git a/crowdstf/res/app/components/stf/angular-draggabilly/angular-draggabilly-directive.js b/crowdstf/res/app/components/stf/angular-draggabilly/angular-draggabilly-directive.js
new file mode 100644
index 0000000..2053694
--- /dev/null
+++ b/crowdstf/res/app/components/stf/angular-draggabilly/angular-draggabilly-directive.js
@@ -0,0 +1,18 @@
+module.exports =
+  function angularDraggabillyDirective(DraggabillyService, $parse) {
+    return {
+      restrict: 'AE',
+      link: function(scope, element, attrs) {
+        var parsedAttrs = $parse(attrs.angularDraggabilly)()
+        if (typeof parsedAttrs !== 'object') {
+          parsedAttrs = {}
+        }
+
+        var options = angular.extend({
+        }, parsedAttrs)
+
+        /* eslint no-unused-vars: 0 */
+        var draggie = new DraggabillyService(element[0], options)
+      }
+    }
+  }
diff --git a/crowdstf/res/app/components/stf/angular-draggabilly/angular-draggabilly-spec.js b/crowdstf/res/app/components/stf/angular-draggabilly/angular-draggabilly-spec.js
new file mode 100644
index 0000000..3c32e5a
--- /dev/null
+++ b/crowdstf/res/app/components/stf/angular-draggabilly/angular-draggabilly-spec.js
@@ -0,0 +1,21 @@
+describe('angularDraggabilly', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div angular-draggabilly name="name">hi</div>')(scope)
+     expect(element.text()).toBe('hello, world')
+     */
+  })
+})
diff --git a/crowdstf/res/app/components/stf/angular-draggabilly/index.js b/crowdstf/res/app/components/stf/angular-draggabilly/index.js
new file mode 100644
index 0000000..3d64ef5
--- /dev/null
+++ b/crowdstf/res/app/components/stf/angular-draggabilly/index.js
@@ -0,0 +1,9 @@
+var draggabilly = require('draggabilly')
+
+module.exports = angular.module('stf.angular-draggabilly', [
+
+])
+  .factory('DraggabillyService', function() {
+    return draggabilly
+  })
+  .directive('angularDraggabilly', require('./angular-draggabilly-directive'))
diff --git a/crowdstf/res/app/components/stf/angular-packery/angular-packery-directive.js b/crowdstf/res/app/components/stf/angular-packery/angular-packery-directive.js
new file mode 100644
index 0000000..10b240d
--- /dev/null
+++ b/crowdstf/res/app/components/stf/angular-packery/angular-packery-directive.js
@@ -0,0 +1,66 @@
+var _ = require('lodash')
+
+module.exports = function angularPackeryDirective(PackeryService,
+  DraggabillyService, $timeout, $parse) {
+
+  return {
+    restrict: 'AE',
+    link: function(scope, element, attrs) {
+      var container = element[0]
+      var parsedAttrs = $parse(attrs.angularPackery)()
+      if (typeof parsedAttrs !== 'object') {
+        parsedAttrs = {}
+      }
+
+      var options = angular.extend({
+        isInitLayout: false,
+        itemSelector: '.packery-item',
+        columnWidth: '.packery-item',
+        transitionDuration: '300ms'
+      }, parsedAttrs)
+
+      var pckry = new PackeryService(container, options)
+      pckry.on('layoutComplete', onLayoutComplete)
+      pckry.bindResize()
+      bindDraggable()
+
+      $timeout(function() {
+        pckry.layout()
+      }, 0)
+      $timeout(function() {
+        pckry.layout()
+      }, 100)
+
+      function bindDraggable() {
+        if (options.draggable) {
+          var draggableOptions = {}
+          if (options.draggableHandle) {
+            draggableOptions.handle = options.draggableHandle
+          }
+          var itemElems = pckry.getItemElements()
+          for (var i = 0, len = itemElems.length; i < len; ++i) {
+            var elem = itemElems[i]
+            var draggie = new DraggabillyService(elem, draggableOptions)
+            pckry.bindDraggabillyEvents(draggie)
+          }
+        }
+      }
+
+      function onLayoutComplete() {
+        return true
+      }
+
+      function onPanelsResized() {
+        pckry.layout()
+      }
+
+      scope.$on('panelsResized', _.throttle(onPanelsResized, 300))
+
+      scope.$on('$destroy', function() {
+        pckry.unbindResize()
+        pckry.off('layoutComplete', onLayoutComplete)
+        pckry.destroy()
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/angular-packery/angular-packery-spec.js b/crowdstf/res/app/components/stf/angular-packery/angular-packery-spec.js
new file mode 100644
index 0000000..0ca65cc
--- /dev/null
+++ b/crowdstf/res/app/components/stf/angular-packery/angular-packery-spec.js
@@ -0,0 +1,23 @@
+describe('angularPackery', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div angular-packery name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/angular-packery/angular-packery.css b/crowdstf/res/app/components/stf/angular-packery/angular-packery.css
new file mode 100644
index 0000000..419795f
--- /dev/null
+++ b/crowdstf/res/app/components/stf/angular-packery/angular-packery.css
@@ -0,0 +1,42 @@
+/*
+  Screen Breakpoints:
+
+  screen-xs: 480px
+  screen-sm: 768px
+  screen-md: 992px
+  screen-lg: 1200px
+  screen-xl: 1500px
+*/
+
+div[angular-packery] {
+  overflow-y: hidden;
+  overflow-x: hidden;
+}
+
+div[angular-packery]:after {
+  content: ' ';
+  display: block;
+  clear: both;
+}
+
+.col-md-4-x.packery-item {
+  width: 33.33333333333333%;
+}
+
+@media screen and (min-width: 1500px) {
+  .col-md-4-x.packery-item {
+    width: 25%;
+  }
+}
+
+@media screen and (max-width: 1200px) {
+  .col-md-4-x.packery-item {
+    width: 50%;
+  }
+}
+
+@media screen and (max-width: 768px) {
+  .col-md-4-x.packery-item {
+    width: 100%;
+  }
+}
diff --git a/crowdstf/res/app/components/stf/angular-packery/index.js b/crowdstf/res/app/components/stf/angular-packery/index.js
new file mode 100644
index 0000000..40cf575
--- /dev/null
+++ b/crowdstf/res/app/components/stf/angular-packery/index.js
@@ -0,0 +1,14 @@
+require('./angular-packery.css')
+
+require('packery/js/rect.js')
+require('packery/js/packer.js')
+require('packery/js/item.js')
+var packery = require('packery/js/packery.js')
+
+module.exports = angular.module('stf.angular-packery', [
+  require('stf/angular-draggabilly').name
+])
+  .factory('PackeryService', function() {
+    return packery
+  })
+  .directive('angularPackery', require('./angular-packery-directive'))
diff --git a/crowdstf/res/app/components/stf/app-state/app-state-provider.js b/crowdstf/res/app/components/stf/app-state/app-state-provider.js
new file mode 100644
index 0000000..6653817
--- /dev/null
+++ b/crowdstf/res/app/components/stf/app-state/app-state-provider.js
@@ -0,0 +1,24 @@
+module.exports = function AppStateProvider() {
+  var values = {
+    config: {
+      websocketUrl: ''
+    },
+    user: {
+      settings: {}
+    }
+  }
+
+  /* global GLOBAL_APPSTATE:false */
+  if (typeof GLOBAL_APPSTATE !== 'undefined') {
+    values = angular.extend(values, GLOBAL_APPSTATE)
+  }
+
+  return {
+    set: function(constants) {
+      angular.extend(values, constants)
+    },
+    $get: function() {
+      return values
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/app-state/index.js b/crowdstf/res/app/components/stf/app-state/index.js
new file mode 100644
index 0000000..937801a
--- /dev/null
+++ b/crowdstf/res/app/components/stf/app-state/index.js
@@ -0,0 +1,3 @@
+module.exports = angular.module('stf.app-state', [
+])
+  .provider('AppState', require('./app-state-provider.js'))
diff --git a/crowdstf/res/app/components/stf/basic-mode/basic-mode-directive.js b/crowdstf/res/app/components/stf/basic-mode/basic-mode-directive.js
new file mode 100644
index 0000000..8fa74c3
--- /dev/null
+++ b/crowdstf/res/app/components/stf/basic-mode/basic-mode-directive.js
@@ -0,0 +1,15 @@
+module.exports = function basicModeDirective($rootScope, BrowserInfo) {
+  return {
+    restrict: 'AE',
+    link: function(scope, element) {
+      $rootScope.basicMode = !!BrowserInfo.mobile
+      if ($rootScope.basicMode) {
+        element.addClass('basic-mode')
+      }
+
+      if (BrowserInfo.mobile) {
+        element.addClass('mobile')
+      }
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/basic-mode/basic-mode-spec.js b/crowdstf/res/app/components/stf/basic-mode/basic-mode-spec.js
new file mode 100644
index 0000000..a2bc561
--- /dev/null
+++ b/crowdstf/res/app/components/stf/basic-mode/basic-mode-spec.js
@@ -0,0 +1,23 @@
+describe('basicMode', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div basic-mode name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/basic-mode/basic-mode.css b/crowdstf/res/app/components/stf/basic-mode/basic-mode.css
new file mode 100644
index 0000000..b7f38e6
--- /dev/null
+++ b/crowdstf/res/app/components/stf/basic-mode/basic-mode.css
@@ -0,0 +1,41 @@
+.basic-mode .devices-icon-view {
+  padding: 0;
+}
+
+.basic-mode .devices-icon-view li {
+  margin: 3px;
+}
+
+.basic-mode .stf-vnc-bottom .btn-primary:hover,
+.basic-mode .stf-vnc-bottom .btn-primary.active {
+  background: #007aff;
+  color: #fff;
+}
+
+.basic-mode .stf-menu .stf-top-bar {
+  overflow: hidden;
+}
+
+.basic-mode .basic-remote-control {
+  width: 100%;
+}
+
+.basic-mode .stf-device-list .device-search {
+  width: 16em;
+}
+
+.guest-landscape .basic-mode .stf-vnc-bottom {
+  display: none;
+}
+
+.guest-landscape .basic-mode .pane-top-bar {
+  display: none;
+}
+
+.guest-landscape .basic-mode .pane-center {
+  top: 0 !important;
+}
+
+.guest-landscape .basic-mode .fill-height {
+  width: 100%;
+}
diff --git a/crowdstf/res/app/components/stf/basic-mode/index.js b/crowdstf/res/app/components/stf/basic-mode/index.js
new file mode 100644
index 0000000..160f604
--- /dev/null
+++ b/crowdstf/res/app/components/stf/basic-mode/index.js
@@ -0,0 +1,5 @@
+require('./basic-mode.css')
+
+module.exports = angular.module('stf.basic-mode', [
+])
+  .directive('basicMode', require('./basic-mode-directive'))
diff --git a/crowdstf/res/app/components/stf/browser-info/browser-info-service.js b/crowdstf/res/app/components/stf/browser-info/browser-info-service.js
new file mode 100644
index 0000000..f166772
--- /dev/null
+++ b/crowdstf/res/app/components/stf/browser-info/browser-info-service.js
@@ -0,0 +1,67 @@
+// NOTE: Most of the detection stuff is from Modernizr 3.0
+module.exports = function BrowserInfoServiceFactory() {
+  var service = {}
+
+  function createElement() {
+    return document.createElement.apply(document, arguments)
+  }
+
+  function addTest(key, test) {
+    service[key] = (typeof test == 'function') ? test() : test
+  }
+
+  addTest('touch', function() {
+    return ('ontouchstart' in window) || window.DocumentTouch &&
+    document instanceof window.DocumentTouch
+  })
+
+  addTest('retina', function() {
+    var mediaQuery = '(-webkit-min-device-pixel-ratio: 1.5), ' +
+      '(min--moz-device-pixel-ratio: 1.5), (-o-min-device-pixel-ratio: 3/2), ' +
+      '(min-resolution: 1.5dppx)'
+    if (window.devicePixelRatio > 1) {
+      return true
+    }
+    return !!(window.matchMedia && window.matchMedia(mediaQuery).matches)
+  })
+
+  addTest('small', function() {
+    var windowWidth = window.screen.width < window.outerWidth ?
+      window.screen.width : window.outerWidth
+    return windowWidth < 800
+  })
+
+  addTest('mobile', function() {
+    return !!(service.small && service.touch)
+  })
+
+  addTest('os', function() {
+    var ua = navigator.userAgent
+    if (ua.match(/Android/i)) {
+      return 'android'
+    }
+    else if (ua.match(/iPhone|iPad|iPod/i)) {
+      return 'ios'
+    }
+    else {
+      return 'pc'
+    }
+  })
+
+  addTest('webgl', function() {
+    var canvas = createElement('canvas')
+    if ('supportsContext' in canvas) {
+      return canvas.supportsContext('webgl') ||
+      canvas.supportsContext('experimental-webgl')
+    }
+    return !!window.WebGLRenderingContext
+  })
+
+  addTest('ua', navigator.userAgent)
+
+  addTest('devicemotion', 'DeviceMotionEvent' in window)
+
+  addTest('deviceorientation', 'DeviceOrientationEvent' in window)
+
+  return service
+}
diff --git a/crowdstf/res/app/components/stf/browser-info/browser-info-spec.js b/crowdstf/res/app/components/stf/browser-info/browser-info-spec.js
new file mode 100644
index 0000000..de93ba8
--- /dev/null
+++ b/crowdstf/res/app/components/stf/browser-info/browser-info-spec.js
@@ -0,0 +1,11 @@
+describe('BrowserInfo', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  it('should ...', inject(function() {
+
+	// expect(BrowserInfo.doSomething()).toEqual('something')
+
+  }))
+
+})
diff --git a/crowdstf/res/app/components/stf/browser-info/index.js b/crowdstf/res/app/components/stf/browser-info/index.js
new file mode 100644
index 0000000..7dbfac3
--- /dev/null
+++ b/crowdstf/res/app/components/stf/browser-info/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.browser-info', [
+
+])
+  .factory('BrowserInfo', require('./browser-info-service'))
diff --git a/crowdstf/res/app/components/stf/common-ui/badge-icon/badge-icon-directive.js b/crowdstf/res/app/components/stf/common-ui/badge-icon/badge-icon-directive.js
new file mode 100644
index 0000000..58ba96c
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/badge-icon/badge-icon-directive.js
@@ -0,0 +1,11 @@
+module.exports = function badgeIconDirective() {
+  return {
+    restrict: 'EA',
+    replace: true,
+    scope: {
+    },
+    template: require('./badge-icon.jade'),
+    link: function() {
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/badge-icon/badge-icon-spec.js b/crowdstf/res/app/components/stf/common-ui/badge-icon/badge-icon-spec.js
new file mode 100644
index 0000000..b1b7485
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/badge-icon/badge-icon-spec.js
@@ -0,0 +1,23 @@
+describe('badgeIcon', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div badge-icon name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/badge-icon/badge-icon.css b/crowdstf/res/app/components/stf/common-ui/badge-icon/badge-icon.css
new file mode 100644
index 0000000..8b36701
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/badge-icon/badge-icon.css
@@ -0,0 +1,7 @@
+.stf-badge-icon {
+
+}
+
+.stf-badge-icon .stf-badge-icon-warning {
+  color: #fec42d;
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/badge-icon/badge-icon.jade b/crowdstf/res/app/components/stf/common-ui/badge-icon/badge-icon.jade
new file mode 100644
index 0000000..369f965
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/badge-icon/badge-icon.jade
@@ -0,0 +1,2 @@
+div.stf-badge-icon
+  i.fa.fa-warning.stf-badge-icon-warning(tooltip-placement='bottom', uib-tooltip='An error has ocurred')
diff --git a/crowdstf/res/app/components/stf/common-ui/badge-icon/index.js b/crowdstf/res/app/components/stf/common-ui/badge-icon/index.js
new file mode 100644
index 0000000..506df4e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/badge-icon/index.js
@@ -0,0 +1,6 @@
+require('./badge-icon.css')
+
+module.exports = angular.module('stf.badge-icon', [
+
+])
+  .directive('badgeIcon', require('./badge-icon-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/blur-element/blur-element-directive.js b/crowdstf/res/app/components/stf/common-ui/blur-element/blur-element-directive.js
new file mode 100644
index 0000000..fc4572e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/blur-element/blur-element-directive.js
@@ -0,0 +1,20 @@
+module.exports = function blurElementDirective($parse, $timeout) {
+  return {
+    restrict: 'A',
+    link: function(scope, element, attrs) {
+      var model = $parse(attrs.blurElement)
+
+      scope.$watch(model, function(value) {
+        if (value === true) {
+          $timeout(function() {
+            element[0].blur()
+          })
+        }
+      })
+
+      element.bind('blur', function() {
+        scope.$apply(model.assign(scope, false))
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/blur-element/blur-element-spec.js b/crowdstf/res/app/components/stf/common-ui/blur-element/blur-element-spec.js
new file mode 100644
index 0000000..96ddef5
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/blur-element/blur-element-spec.js
@@ -0,0 +1,23 @@
+describe('blurElement', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div blur-element name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/blur-element/index.js b/crowdstf/res/app/components/stf/common-ui/blur-element/index.js
new file mode 100644
index 0000000..82ef62d
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/blur-element/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.blur-element', [
+
+])
+  .directive('blurElement', require('./blur-element-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/clear-button/clear-button-directive.js b/crowdstf/res/app/components/stf/common-ui/clear-button/clear-button-directive.js
new file mode 100644
index 0000000..1b8aea6
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/clear-button/clear-button-directive.js
@@ -0,0 +1,8 @@
+module.exports = function clearButtonDirective() {
+  return {
+    restrict: 'EA',
+    replace: true,
+    scope: {},
+    template: require('./clear-button.jade')
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/clear-button/clear-button-spec.js b/crowdstf/res/app/components/stf/common-ui/clear-button/clear-button-spec.js
new file mode 100644
index 0000000..a636179
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/clear-button/clear-button-spec.js
@@ -0,0 +1,22 @@
+describe('clearButton', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should display a text label', function() {
+    var element = compile('<clear-button />')(scope)
+    expect(element.find('span').text()).toBe('Clear')
+  })
+
+  it('should display a trash icon', function() {
+    var element = compile('<clear-button />')(scope)
+    expect(element.find('i')[0].getAttribute('class')).toMatch('fa-trash-o')
+  })
+
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/clear-button/clear-button.jade b/crowdstf/res/app/components/stf/common-ui/clear-button/clear-button.jade
new file mode 100644
index 0000000..d63f2da
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/clear-button/clear-button.jade
@@ -0,0 +1,3 @@
+button.btn.btn-sm.btn-danger-outline.pull-right
+  i.fa.fa-trash-o
+  span(translate) Clear
\ No newline at end of file
diff --git a/crowdstf/res/app/components/stf/common-ui/clear-button/index.js b/crowdstf/res/app/components/stf/common-ui/clear-button/index.js
new file mode 100644
index 0000000..fb4023b
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/clear-button/index.js
@@ -0,0 +1,2 @@
+module.exports = angular.module('stf.clear-button', [])
+  .directive('clearButton', require('./clear-button-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/counter/counter-directive.js b/crowdstf/res/app/components/stf/common-ui/counter/counter-directive.js
new file mode 100644
index 0000000..153dd2e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/counter/counter-directive.js
@@ -0,0 +1,62 @@
+module.exports = function counterDirective($timeout) {
+  return {
+    replace: false,
+    scope: true,
+    link: function(scope, element, attrs) {
+      var el = element[0]
+      var num, refreshInterval, duration, steps, step, countTo, increment
+
+      var calculate = function() {
+        refreshInterval = 32
+        step = 0
+        scope.timoutId = null
+        countTo = parseInt(attrs.countTo, 10) || 0
+        scope.value = parseInt(attrs.countFrom, 10) || 0
+        duration = parseFloat(attrs.duration) || 0
+
+        steps = Math.ceil(duration / refreshInterval)
+
+        increment = ((countTo - scope.value) / steps)
+        num = scope.value
+      }
+
+      var tick = function() {
+        scope.timoutId = $timeout(function() {
+          num += increment
+          step++
+          if (step >= steps) {
+            $timeout.cancel(scope.timoutId)
+            num = countTo
+            el.innerText = countTo
+          }
+          else {
+            el.innerText = Math.round(num)
+            tick()
+          }
+        }, refreshInterval)
+
+      }
+
+      var start = function() {
+        if (scope.timoutId) {
+          $timeout.cancel(scope.timoutId)
+        }
+        calculate()
+        tick()
+      }
+
+      attrs.$observe('countTo', function(val) {
+        if (val) {
+          start()
+        }
+      })
+
+      attrs.$observe('countFrom', function() {
+        start()
+      })
+
+      return true
+    }
+  }
+
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/counter/counter-spec.js b/crowdstf/res/app/components/stf/common-ui/counter/counter-spec.js
new file mode 100644
index 0000000..d914ff0
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/counter/counter-spec.js
@@ -0,0 +1,23 @@
+describe('counter', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div counter name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/counter/index.js b/crowdstf/res/app/components/stf/common-ui/counter/index.js
new file mode 100644
index 0000000..9b1d60e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/counter/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.counter', [
+
+])
+  .directive('countFrom', require('./counter-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/enable-autofill/README.md b/crowdstf/res/app/components/stf/common-ui/enable-autofill/README.md
new file mode 100644
index 0000000..e225eb0
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/enable-autofill/README.md
@@ -0,0 +1,28 @@
+# enable-autofill
+
+This directive enables autofill (HTML5 autocomplete) on the selected form.
+
+Currently this is only needed in `Chrome` because `autofill` only works on form `POST` method.
+
+Based on [this](http://stackoverflow.com/questions/16445463/how-to-get-chrome-to-autofill-with-asynchronous-post/22191041#22191041).
+
+## Usage
+
+```html
+<form enable-autofill action="about:blank">
+	<input type="text" autocomplete="on">
+</form>  
+```
+
+This will create the following DOM:
+
+```html
+<iframe src="about:blank" name="_autofill" style="display:none">
+<form method="post" action="about:blank" target="_autofill">
+	<input type="text" autocomplete="on">
+</form>  
+```
+
+Yes, it is a bit ugly but it is currently the only way. 
+
+It will create only one `iframe` which will be reused.
\ No newline at end of file
diff --git a/crowdstf/res/app/components/stf/common-ui/enable-autofill/enable-autofill-directive.js b/crowdstf/res/app/components/stf/common-ui/enable-autofill/enable-autofill-directive.js
new file mode 100644
index 0000000..6b58f10
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/enable-autofill/enable-autofill-directive.js
@@ -0,0 +1,54 @@
+module.exports = function enableAutofillDirective($rootElement, $cookies) {
+  return {
+    restrict: 'A',
+    compile: function compile(tElement, tAttrs) {
+
+      // Creates hidden iFrame for auto-fill forms if there isn't one already
+      if ($rootElement.find('iframe').attr('name') !== '_autofill') {
+        $rootElement.append(angular.element(
+          '<iframe src="about:blank" name="_autofill" style="display:none">'
+        ))
+      }
+
+      // Add attribute method POST to the current form
+      if (!tAttrs.method) {
+        tElement.attr('method', 'post')
+      } else {
+        if (!tAttrs.method.match(/post/i)) {
+          throw new Error('Auto-fill only works with form POST method')
+        }
+      }
+
+      // Add attribute target to the current form
+      if (!tAttrs.target) {
+        tElement.attr('target', '_autofill')
+      }
+
+      // Add action attribute if not present
+      if (!tAttrs.action) {
+
+        // Use a dummy url because 'about:blank' trick doesn't work with HTTPS
+        // Also 'javascript: void(0)' doesn't work neither
+        var dummyUrl = '/app/api/v1/dummy'
+
+        // Adds the CSRF token to the url from cookies if present
+        var xsrfToken = $cookies['XSRF-TOKEN']
+        if (xsrfToken) {
+          // Note: At least for Express CSURF, it only works with url-set tokens
+          // it doesn't happen to work with hidden form input elements
+          dummyUrl += '?_csrf=' + xsrfToken
+        }
+
+        tElement.attr('action', dummyUrl)
+      }
+
+      return {
+        pre: function(scope, element, attrs) {
+          // Angular needs this so the form action doesn't get removed
+          // Also, trying to set a url at this time doesn't work neither
+          attrs.action = ''
+        }
+      }
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/enable-autofill/enable-autofill-spec.js b/crowdstf/res/app/components/stf/common-ui/enable-autofill/enable-autofill-spec.js
new file mode 100644
index 0000000..6f7cad3
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/enable-autofill/enable-autofill-spec.js
@@ -0,0 +1,23 @@
+describe('enableAutofill', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div enable-autofill name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/enable-autofill/index.js b/crowdstf/res/app/components/stf/common-ui/enable-autofill/index.js
new file mode 100644
index 0000000..16efc1a
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/enable-autofill/index.js
@@ -0,0 +1,6 @@
+require('angular-cookies')
+
+module.exports = angular.module('stf.enable-autofill', [
+  'ngCookies'
+])
+  .directive('enableAutofill', require('./enable-autofill-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/error-message/error-message-directive.js b/crowdstf/res/app/components/stf/common-ui/error-message/error-message-directive.js
new file mode 100644
index 0000000..f7ccaac
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/error-message/error-message-directive.js
@@ -0,0 +1,15 @@
+module.exports = function errorMessageDirective() {
+  return {
+    restrict: 'EA',
+    replace: true,
+    scope: {
+      message: '@'
+    },
+    template: require('./error-message.jade'),
+    link: function(scope) {
+      scope.closeMessage = function() {
+        scope.message = ''
+      }
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/error-message/error-message-spec.js b/crowdstf/res/app/components/stf/common-ui/error-message/error-message-spec.js
new file mode 100644
index 0000000..3c711f1
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/error-message/error-message-spec.js
@@ -0,0 +1,23 @@
+describe('errorMessage', function() {
+
+  beforeEach(angular.mock.module(require('./index').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div error-message name="name">hi</div>')(scope)
+     expect(element.text()).toBe('hello, world')
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/error-message/error-message.css b/crowdstf/res/app/components/stf/common-ui/error-message/error-message.css
new file mode 100644
index 0000000..adc12ee
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/error-message/error-message.css
@@ -0,0 +1,3 @@
+.stf-error-message {
+  padding-bottom: 15px;
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/error-message/error-message.jade b/crowdstf/res/app/components/stf/common-ui/error-message/error-message.jade
new file mode 100644
index 0000000..771efeb
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/error-message/error-message.jade
@@ -0,0 +1,5 @@
+.stf-error-message
+  alert(type='danger', close='closeMessage()', ng-if='message')
+    strong(translate) Oops!
+    | &#x20;
+    span(ng-bind='message')
diff --git a/crowdstf/res/app/components/stf/common-ui/error-message/index.js b/crowdstf/res/app/components/stf/common-ui/error-message/index.js
new file mode 100644
index 0000000..490bff4
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/error-message/index.js
@@ -0,0 +1,6 @@
+require('./error-message.css')
+
+module.exports = angular.module('stf.error-message', [
+
+])
+  .directive('errorMessage', require('./error-message-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/fallback-image/fallback-image-directive.js b/crowdstf/res/app/components/stf/common-ui/fallback-image/fallback-image-directive.js
new file mode 100644
index 0000000..0c0ecf3
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/fallback-image/fallback-image-directive.js
@@ -0,0 +1,10 @@
+module.exports = function fallbackImageDirective() {
+  return {
+    restrict: 'A',
+    link: function postLink(scope, element, attrs) {
+      element.on('error', function() {
+        angular.element(this).attr('src', attrs.fallbackImage)
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/fallback-image/fallback-image-spec.js b/crowdstf/res/app/components/stf/common-ui/fallback-image/fallback-image-spec.js
new file mode 100644
index 0000000..082c8c2
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/fallback-image/fallback-image-spec.js
@@ -0,0 +1,23 @@
+describe('fallbackImage', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div fallback-image name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/fallback-image/index.js b/crowdstf/res/app/components/stf/common-ui/fallback-image/index.js
new file mode 100644
index 0000000..9c0d982
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/fallback-image/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.fallback-image', [
+
+])
+  .directive('fallbackImage', require('./fallback-image-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/filter-button/filter-button-directive.js b/crowdstf/res/app/components/stf/common-ui/filter-button/filter-button-directive.js
new file mode 100644
index 0000000..e7a4b63
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/filter-button/filter-button-directive.js
@@ -0,0 +1,9 @@
+module.exports = function filterButtonDirective() {
+  return {
+    require: 'ngModel',
+    restrict: 'EA',
+    replace: true,
+    scope: {},
+    template: require('./filter-button.jade')
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/filter-button/filter-button-spec.js b/crowdstf/res/app/components/stf/common-ui/filter-button/filter-button-spec.js
new file mode 100644
index 0000000..a414d83
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/filter-button/filter-button-spec.js
@@ -0,0 +1,23 @@
+describe('filterButton', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your
+     directive, send that through compile() then compare the results.
+
+     var element = compile('<div clear-button name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/filter-button/filter-button.jade b/crowdstf/res/app/components/stf/common-ui/filter-button/filter-button.jade
new file mode 100644
index 0000000..bc475a9
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/filter-button/filter-button.jade
@@ -0,0 +1,3 @@
+button(uib-btn-checkbox, title='{{"Filter"|translate}}').btn.btn-sm.btn-primary-outline.pull-right
+  i.fa.fa-filter
+  span {{"Filter"|translate}}
diff --git a/crowdstf/res/app/components/stf/common-ui/filter-button/index.js b/crowdstf/res/app/components/stf/common-ui/filter-button/index.js
new file mode 100644
index 0000000..25e7039
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/filter-button/index.js
@@ -0,0 +1,2 @@
+module.exports = angular.module('stf.filter-button', [])
+  .directive('filterButton', require('./filter-button-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/focus-element/focus-element-directive.js b/crowdstf/res/app/components/stf/common-ui/focus-element/focus-element-directive.js
new file mode 100644
index 0000000..8c3b503
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/focus-element/focus-element-directive.js
@@ -0,0 +1,22 @@
+module.exports = function focusElementDirective($parse, $timeout) {
+  return {
+    restrict: 'A',
+    link: function(scope, element, attrs) {
+      var model = $parse(attrs.focusElement)
+
+      scope.$watch(model, function(value) {
+        if (value === true) {
+          $timeout(function() {
+            element[0].focus()
+          })
+        }
+      })
+
+      element.bind('blur', function() {
+        if (model && model.assign) {
+          scope.$apply(model.assign(scope, false))
+        }
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/focus-element/focus-element-spec.js b/crowdstf/res/app/components/stf/common-ui/focus-element/focus-element-spec.js
new file mode 100644
index 0000000..5277364
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/focus-element/focus-element-spec.js
@@ -0,0 +1,23 @@
+describe('focusElement', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div focus-element name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/focus-element/index.js b/crowdstf/res/app/components/stf/common-ui/focus-element/index.js
new file mode 100644
index 0000000..9e7f384
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/focus-element/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.focus-element', [
+
+])
+  .directive('focusElement', require('./focus-element-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/help-icon/help-icon-directive.js b/crowdstf/res/app/components/stf/common-ui/help-icon/help-icon-directive.js
new file mode 100644
index 0000000..00f52e2
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/help-icon/help-icon-directive.js
@@ -0,0 +1,11 @@
+module.exports = function clearButtonDirective() {
+  return {
+    restrict: 'EA',
+    replace: true,
+    scope: {
+      topic: '@',
+      tooltip: '@'
+    },
+    template: require('./help-icon.jade')
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/help-icon/help-icon.jade b/crowdstf/res/app/components/stf/common-ui/help-icon/help-icon.jade
new file mode 100644
index 0000000..dba2eac
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/help-icon/help-icon.jade
@@ -0,0 +1,2 @@
+a(ng-href='/#!/docs/{{topic}}', uib-tooltip='{{tooltip}}', tooltip-placement='left').btn.btn-xs.btn-primary-outline.pull-right.transparent-border
+    i.fa.fa-question-circle
diff --git a/crowdstf/res/app/components/stf/common-ui/help-icon/index.js b/crowdstf/res/app/components/stf/common-ui/help-icon/index.js
new file mode 100644
index 0000000..5fcb513
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/help-icon/index.js
@@ -0,0 +1,2 @@
+module.exports = angular.module('stf.help-icon', [])
+  .directive('helpIcon', require('./help-icon-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/icon-inside-input/icon-inside-input-directive.js b/crowdstf/res/app/components/stf/common-ui/icon-inside-input/icon-inside-input-directive.js
new file mode 100644
index 0000000..ea52778
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/icon-inside-input/icon-inside-input-directive.js
@@ -0,0 +1,19 @@
+module.exports = function iconInsideInputDirective() {
+  return {
+    restrict: 'A',
+    link: function(scope, element, attrs) {
+      // NOTE: this doesn't work on Chrome with auto-fill, known Chrome bug
+      element.css({
+        'background-repeat': 'no-repeat',
+        'background-position': '8px 8px',
+        'padding-left': '30px'
+      })
+
+      attrs.$observe('iconInsideInput', function(value) {
+        element.css({
+          'background-image': 'url(' + value + ')'
+        })
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/icon-inside-input/icon-inside-input-spec.js b/crowdstf/res/app/components/stf/common-ui/icon-inside-input/icon-inside-input-spec.js
new file mode 100644
index 0000000..e62c209
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/icon-inside-input/icon-inside-input-spec.js
@@ -0,0 +1,23 @@
+describe('iconInsideInput', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div icon-inside-input name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/icon-inside-input/index.js b/crowdstf/res/app/components/stf/common-ui/icon-inside-input/index.js
new file mode 100644
index 0000000..1602179
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/icon-inside-input/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.icon-inside-input', [
+
+])
+  .directive('iconInsideInput', require('./icon-inside-input-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/include-cached/compile-cache-service.js b/crowdstf/res/app/components/stf/common-ui/include-cached/compile-cache-service.js
new file mode 100644
index 0000000..a1bdc05
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/include-cached/compile-cache-service.js
@@ -0,0 +1,16 @@
+module.exports = function($http, $templateCache, $compile) {
+  var cache = {}
+
+  return function(src, scope, cloneAttachFn) {
+    var compileFn = cache[src]
+    if (compileFn) {
+      compileFn(scope, cloneAttachFn)
+    } else {
+      $http.get(src, {cache: $templateCache}).success(function(response) {
+        var responseContents = angular.element('<div></div>').html(response).contents()
+        compileFn = cache[src] = $compile(responseContents)
+        compileFn(scope, cloneAttachFn)
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/include-cached/include-cached-directive.js b/crowdstf/res/app/components/stf/common-ui/include-cached/include-cached-directive.js
new file mode 100644
index 0000000..b176907
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/include-cached/include-cached-directive.js
@@ -0,0 +1,17 @@
+module.exports = function includeCachedDirective(CompileCacheService) {
+  return {
+    restrict: 'ECA',
+    terminal: true,
+    compile: function(element, attrs) {
+      var srcExp = attrs.ngIncludeCached || attrs.src
+
+      return function(scope, element) {
+        var src = scope.$eval(srcExp)
+        var newScope = scope.$new()
+        CompileCacheService(src, newScope, function(compiledElm) {
+          element.append(compiledElm)
+        })
+      }
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/include-cached/include-cached-spec.js b/crowdstf/res/app/components/stf/common-ui/include-cached/include-cached-spec.js
new file mode 100644
index 0000000..cdded23
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/include-cached/include-cached-spec.js
@@ -0,0 +1,23 @@
+describe('includeCached', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div include-cached name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/include-cached/index.js b/crowdstf/res/app/components/stf/common-ui/include-cached/index.js
new file mode 100644
index 0000000..fcea6c8
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/include-cached/index.js
@@ -0,0 +1,5 @@
+module.exports = angular.module('stf.include-cached', [
+
+])
+  .factory('CompileCacheService', require('./compile-cache-service'))
+  .directive('ngIncludeCached', require('./include-cached-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/index.js b/crowdstf/res/app/components/stf/common-ui/index.js
new file mode 100644
index 0000000..6264cf6
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/index.js
@@ -0,0 +1,22 @@
+module.exports = angular.module('stf/common-ui', [
+  require('./safe-apply').name,
+  require('./clear-button').name,
+  require('./filter-button').name,
+  require('./nothing-to-show').name,
+  require('./error-message').name,
+  require('./table').name,
+  require('./notifications').name,
+  require('./ng-enter').name,
+  require('./tooltips').name,
+  require('./modals').name,
+  require('./include-cached').name,
+  require('./text-focus-select').name,
+  require('./counter').name,
+  require('./badge-icon').name,
+  require('./enable-autofill').name,
+  require('./icon-inside-input').name,
+  require('./focus-element').name,
+  require('./blur-element').name,
+  require('./stacked-icon').name,
+  require('./help-icon').name
+])
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal-service.js b/crowdstf/res/app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal-service.js
new file mode 100644
index 0000000..9e4d3ac
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal-service.js
@@ -0,0 +1,41 @@
+module.exports =
+  function AddAdbKeyModalServiceFactory($uibModal) {
+    var service = {}
+
+    var ModalInstanceCtrl = function($scope, $uibModalInstance, data) {
+      $scope.modal = {}
+      $scope.modal.showAdd = true
+      $scope.modal.fingerprint = data.fingerprint
+      $scope.modal.title = data.title
+
+      $scope.ok = function() {
+        $uibModalInstance.close(true)
+      }
+
+      $scope.$watch('modal.showAdd', function(newValue) {
+        if (newValue === false) {
+          $scope.ok()
+        }
+      })
+
+      $scope.cancel = function() {
+        $uibModalInstance.dismiss('cancel')
+      }
+    }
+
+    service.open = function(data) {
+      var modalInstance = $uibModal.open({
+        template: require('./add-adb-key-modal.jade'),
+        controller: ModalInstanceCtrl,
+        resolve: {
+          data: function() {
+            return data
+          }
+        }
+      })
+
+      return modalInstance.result
+    }
+
+    return service
+  }
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal-spec.js b/crowdstf/res/app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal-spec.js
new file mode 100644
index 0000000..8334016
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal-spec.js
@@ -0,0 +1,11 @@
+describe('FatalMessageService', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  it('should ...', inject(function() {
+
+	//expect(FatalMessageService.doSomething()).toEqual('something');
+
+  }))
+
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.jade b/crowdstf/res/app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.jade
new file mode 100644
index 0000000..5859dfc
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.jade
@@ -0,0 +1,29 @@
+.stf-add-adb-key-modal.stf-modal
+  form(ng-submit='ok()')
+    .modal-header
+      button(type='button', ng-click='cancel()').close &times;
+      h4.modal-title
+        i.fa.fa-android
+        .button-spacer
+        span(translate) Add the following ADB Key to STF?
+    .modal-body
+      label.control-label
+        i.fa.fa-key.fa-fw
+        span(translate) Fingerprint
+      pre(ng-bind='modal.fingerprint').selectable
+
+      label.control-label
+        i.fa.fa-laptop.fa-fw
+        span(translate) Device
+      pre(ng-bind='modal.title').selectable
+
+    .modal-footer
+      a(ng-href='/#!/docs/ADB-Keys', target='_blank').pull-left.btn.btn-sm
+        i.fa.fa-lg.fa-question-circle.fa-fw
+
+      button.btn.btn-primary-outline.btn-sm.pull-right(type='submit')
+        i.fa.fa-plus.fa-fw
+        strong(translate) Add Key
+
+      button.btn.btn-default-outline.btn-sm.pull-right(ng-click='cancel()')
+        span(translate) Cancel
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/add-adb-key-modal/index.js b/crowdstf/res/app/components/stf/common-ui/modals/add-adb-key-modal/index.js
new file mode 100644
index 0000000..45210a7
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/add-adb-key-modal/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.add-adb-key-modal', [
+  require('stf/common-ui/modals/common').name
+])
+  .factory('AddAdbKeyModalService', require('./add-adb-key-modal-service'))
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/common/index.js b/crowdstf/res/app/components/stf/common-ui/modals/common/index.js
new file mode 100644
index 0000000..3c0d7a3
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/common/index.js
@@ -0,0 +1,5 @@
+require('./modals.css')
+
+module.exports = angular.module('stf.modals.common', [
+  require('ui-bootstrap').name
+])
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/common/modals.css b/crowdstf/res/app/components/stf/common-ui/modals/common/modals.css
new file mode 100644
index 0000000..b98f8e3
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/common/modals.css
@@ -0,0 +1,72 @@
+.stf-modal {
+  border-radius: 5px;
+}
+
+.stf-modal .modal-title {
+  padding-left: 5px;
+}
+
+.stf-modal .modal-body {
+  background: #fff;
+}
+
+.stf-modal .modal-footer {
+  background: #fff;
+  padding: 20px;
+  margin-top: 0;
+  border-top: 1px solid #e5e5e5;
+}
+
+
+.stf-modal .dialog-header-error {
+  background-color: #d2322d;
+}
+
+.stf-modal .dialog-header-wait {
+  background-color: #428bca;
+}
+
+.stf-modal .dialog-header-notify {
+  background-color: #eeeeee;
+}
+
+.stf-modal .dialog-header-confirm {
+  background-color: #333333;
+}
+
+.stf-modal .dialog-header-error span,
+.stf-modal .dialog-header-error h4,
+.stf-modal .dialog-header-wait span,
+.stf-modal .dialog-header-wait h4,
+.stf-modal .dialog-header-confirm span,
+.stf-modal .dialog-header-confirm h4 {
+  color: #ffffff;
+}
+
+.modal-size-xl .modal-dialog  {
+  width: 860px;
+}
+
+.modal-size-80p .modal-dialog {
+  width: 80%;
+  height: 100%;
+}
+
+.modal-size-80p .modal-body {
+  height: 100%;
+  max-height: 800px;
+}
+
+.stf-modal .big-thumbnail {
+  text-align: center;
+}
+
+.stf-modal .device-name {
+  color: #3FA9F5;
+  font-size: 16px;
+  margin: 10px 0;
+  font-family: 'HelveticaNeue-Light', Helvetica, Arial, sans-serif;
+}
+
+.stf-modal .device-photo-small {
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/external-url-modal-service.js b/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/external-url-modal-service.js
new file mode 100644
index 0000000..50b97e2
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/external-url-modal-service.js
@@ -0,0 +1,42 @@
+module.exports = function ServiceFactory($uibModal, $sce) {
+  var service = {}
+
+  var ModalInstanceCtrl = function($scope, $uibModalInstance, url, title, icon) {
+    $scope.ok = function() {
+      $uibModalInstance.close(true)
+    }
+
+    $scope.url = $sce.trustAsResourceUrl(url)
+    $scope.title = title
+    $scope.icon = icon
+
+    $scope.cancel = function() {
+      $uibModalInstance.dismiss('cancel')
+    }
+  }
+
+  service.open = function(url, title, icon) {
+    var modalInstance = $uibModal.open({
+      template: require('./external-url-modal.jade'),
+      controller: ModalInstanceCtrl,
+      windowClass: 'modal-size-80p',
+      resolve: {
+        title: function() {
+          return title
+        },
+        url: function() {
+          return url
+        },
+        icon: function() {
+          return icon
+        }
+      }
+    })
+
+    modalInstance.result.then(function() {
+    }, function() {
+    })
+  }
+
+  return service
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/external-url-modal-spec.js b/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/external-url-modal-spec.js
new file mode 100644
index 0000000..819bfe7
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/external-url-modal-spec.js
@@ -0,0 +1,11 @@
+describe('ExternalUrlModalService', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  it('should ...', inject(function() {
+
+	//expect(FatalMessageService.doSomething()).toEqual('something');
+
+  }))
+
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/external-url-modal.css b/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/external-url-modal.css
new file mode 100644
index 0000000..f2030bc
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/external-url-modal.css
@@ -0,0 +1,3 @@
+.stf-external-url-modal {
+
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/external-url-modal.jade b/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/external-url-modal.jade
new file mode 100644
index 0000000..adcf879
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/external-url-modal.jade
@@ -0,0 +1,8 @@
+.stf-external-url-modal.stf-modal
+  .modal-header
+    button(type='button', ng-click='cancel()').close &times;
+    h4.modal-title(ng-show='title')
+      i.fa.fa-fw(ng-class='icon')
+      span(ng-bind='title')
+  .modal-body
+    iframe(ng-src='{{url}}', width='100%', height='100%', frameborder='0')
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/index.js b/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/index.js
new file mode 100644
index 0000000..51c905c
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/index.js
@@ -0,0 +1,7 @@
+require('./external-url-modal.css')
+
+module.exports = angular.module('stf.external-url-modal', [
+  require('stf/common-ui/modals/common').name
+])
+  .factory('ExternalUrlModalService', require('./external-url-modal-service'))
+  .directive('onLoadEvent', require('./on-load-event-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/on-load-event-directive.js b/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/on-load-event-directive.js
new file mode 100644
index 0000000..e888d57
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/external-url-modal/on-load-event-directive.js
@@ -0,0 +1,10 @@
+// TODO: Test this
+module.exports = function() {
+  return function(scope, element, attrs) {
+    scope.$watch(attrs.pageVisible, function() {
+      element.bind('load', function() {
+        scope.$apply(attrs.pageLoad)
+      })
+    })
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/fatal-message-service.js b/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/fatal-message-service.js
new file mode 100644
index 0000000..aa55239
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/fatal-message-service.js
@@ -0,0 +1,80 @@
+module.exports =
+  function FatalMessageServiceFactory($uibModal, $location, $route, $interval,
+    StateClassesService) {
+    var FatalMessageService = {}
+
+    var intervalDeviceInfo
+
+    var ModalInstanceCtrl = function($scope, $uibModalInstance, device,
+      tryToReconnect) {
+      $scope.ok = function() {
+        $uibModalInstance.close(true)
+        $route.reload()
+      }
+
+      function update() {
+        $scope.device = device
+        $scope.stateColor = StateClassesService.stateColor(device.state)
+      }
+
+      update()
+
+      // TODO: remove this please
+      intervalDeviceInfo = $interval(update, 750)
+
+      if (tryToReconnect) {
+        // TODO: this is ugly, find why its not updated correctly (also on the device list)
+        intervalDeviceInfo = $interval(function() {
+          update()
+
+          if (device.usable) {
+            // Try to reconnect
+            $scope.ok()
+          }
+        }, 1000, 500)
+      }
+
+      $scope.second = function() {
+        $uibModalInstance.dismiss()
+        $location.path('/devices/')
+      }
+
+      $scope.cancel = function() {
+        $uibModalInstance.dismiss('cancel')
+      }
+
+      var destroyInterval = function() {
+        if (angular.isDefined(intervalDeviceInfo)) {
+          $interval.cancel(intervalDeviceInfo)
+          intervalDeviceInfo = undefined
+        }
+      }
+
+      $scope.$on('$destroy', function() {
+        destroyInterval()
+      })
+    }
+
+    FatalMessageService.open = function(device, tryToReconnect) {
+      var modalInstance = $uibModal.open({
+        template: require('./fatal-message.jade'),
+        controller: ModalInstanceCtrl,
+        resolve: {
+          device: function() {
+            return device
+          },
+          tryToReconnect: function() {
+            return tryToReconnect
+          }
+        }
+      })
+
+      modalInstance.result.then(function() {
+      }, function() {
+
+      })
+    }
+
+
+    return FatalMessageService
+  }
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/fatal-message-spec.js b/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/fatal-message-spec.js
new file mode 100644
index 0000000..8334016
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/fatal-message-spec.js
@@ -0,0 +1,11 @@
+describe('FatalMessageService', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  it('should ...', inject(function() {
+
+	//expect(FatalMessageService.doSomething()).toEqual('something');
+
+  }))
+
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/fatal-message.jade b/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/fatal-message.jade
new file mode 100644
index 0000000..365a27c
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/fatal-message.jade
@@ -0,0 +1,24 @@
+.stf-fatal-message.stf-modal
+  .modal-header.dialog-header-errorX
+    button(type='button', ng-click='cancel()').close &times;
+    h4.modal-title.text-danger
+      i.fa.fa-warning
+      .button-spacer
+      span(translate) Device was disconnected
+  .modal-body
+    h4(translate, ng-bind='device.likelyLeaveReason | likelyLeaveReason')
+    br
+    .big-thumbnail
+      .device-photo-small
+        img(ng-src='/static/app/devices/icon/x120/{{ device.image || "E30HT.jpg" }}')
+      .device-name(ng-bind='device.enhancedName')
+      h3.device-status(ng-class='stateColor')
+        span(ng-bind='device.enhancedStatePassive | translate')
+
+  .modal-footer
+    button.btn.btn-primary-outline.pull-left(type='button', ng-click='ok()')
+      i.fa.fa-refresh
+      span(translate) Try to reconnect
+    button.btn.btn-success-outline(ng-click='second()')
+      i.fa.fa-sitemap
+      span(translate) Go to Device List
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/index.js b/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/index.js
new file mode 100644
index 0000000..17aee45
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/index.js
@@ -0,0 +1,7 @@
+require('angular-route')
+
+module.exports = angular.module('stf.fatal-message', [
+  require('stf/common-ui/modals/common').name,
+  'ngRoute'
+])
+  .factory('FatalMessageService', require('./fatal-message-service'))
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/index.js b/crowdstf/res/app/components/stf/common-ui/modals/index.js
new file mode 100644
index 0000000..5b09337
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/index.js
@@ -0,0 +1,6 @@
+module.exports = angular.module('stf.modals', [
+  require('./fatal-message').name,
+  require('./socket-disconnected').name,
+  require('./version-update').name,
+  require('./add-adb-key-modal').name
+])
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/index.js b/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/index.js
new file mode 100644
index 0000000..4247cd8
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/index.js
@@ -0,0 +1,6 @@
+require('./lightbox-image.css')
+
+module.exports = angular.module('stf.lightbox-image', [
+  require('stf/common-ui/modals/common').name
+])
+  .factory('LightboxImageService', require('./lightbox-image-service'))
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/lightbox-image-service.js b/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/lightbox-image-service.js
new file mode 100644
index 0000000..dc81fdc
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/lightbox-image-service.js
@@ -0,0 +1,38 @@
+module.exports = function ServiceFactory($uibModal) {
+  var service = {}
+
+  var ModalInstanceCtrl = function($scope, $uibModalInstance, title, imageUrl) {
+    $scope.ok = function() {
+      $uibModalInstance.close(true)
+    }
+
+    $scope.title = title
+    $scope.imageUrl = imageUrl
+
+    $scope.cancel = function() {
+      $uibModalInstance.dismiss('cancel')
+    }
+  }
+
+  service.open = function(title, imageUrl) {
+    var modalInstance = $uibModal.open({
+      template: require('./lightbox-image.jade'),
+      controller: ModalInstanceCtrl,
+      windowClass: 'modal-size-xl',
+      resolve: {
+        title: function() {
+          return title
+        },
+        imageUrl: function() {
+          return imageUrl
+        }
+      }
+    })
+
+    modalInstance.result.then(function() {
+    }, function() {
+    })
+  }
+
+  return service
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/lightbox-image-spec.js b/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/lightbox-image-spec.js
new file mode 100644
index 0000000..0d5a245
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/lightbox-image-spec.js
@@ -0,0 +1,11 @@
+describe('LightboxImageService', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  it('should ...', inject(function() {
+
+	//expect(XLightboxImageService.doSomething()).toEqual('something');
+
+  }))
+
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/lightbox-image.css b/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/lightbox-image.css
new file mode 100644
index 0000000..65d0938
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/lightbox-image.css
@@ -0,0 +1,5 @@
+.stf-lightbox-image .modal-body {
+  text-align: center;
+  background: white;
+}
+
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/lightbox-image.jade b/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/lightbox-image.jade
new file mode 100644
index 0000000..167639b
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/lightbox-image/lightbox-image.jade
@@ -0,0 +1,9 @@
+.stf-lightbox-image.stf-modal
+  .modal-header
+    button(type='button', ng-click='cancel()').close &times;
+    h4.modal-title
+      i.fa.fa-mobile.fa-fw
+      span {{ title }}
+  .modal-body
+    img(ng-if='imageUrl', ng-src='{{imageUrl}}')
+    nothing-to-show(message='{{"No photo available"|translate}}', icon='fa-picture-o', ng-if='!imageUrl')
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/socket-disconnected/index.js b/crowdstf/res/app/components/stf/common-ui/modals/socket-disconnected/index.js
new file mode 100644
index 0000000..aeb9552
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/socket-disconnected/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.socket-disconnected', [
+  require('stf/common-ui/modals/common').name
+])
+  .factory('SocketDisconnectedService', require('./socket-disconnected-service'))
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected-service.js b/crowdstf/res/app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected-service.js
new file mode 100644
index 0000000..3f6ee61
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected-service.js
@@ -0,0 +1,36 @@
+module.exports =
+  function SocketDisconnectedServiceFactory($uibModal, $location, $window) {
+    var service = {}
+
+    var ModalInstanceCtrl = function($scope, $uibModalInstance, message) {
+      $scope.ok = function() {
+        $uibModalInstance.close(true)
+        $window.location.reload()
+      }
+
+      $scope.message = message
+
+      $scope.cancel = function() {
+        $uibModalInstance.dismiss('cancel')
+      }
+
+    }
+
+    service.open = function(message) {
+      var modalInstance = $uibModal.open({
+        template: require('./socket-disconnected.jade'),
+        controller: ModalInstanceCtrl,
+        resolve: {
+          message: function() {
+            return message
+          }
+        }
+      })
+
+      modalInstance.result.then(function() {
+      }, function() {
+      })
+    }
+
+    return service
+  }
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected-spec.js b/crowdstf/res/app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected-spec.js
new file mode 100644
index 0000000..0e3f29e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected-spec.js
@@ -0,0 +1,11 @@
+describe('SocketDisconnectedService', function() {
+
+  beforeEach(angular.mock.module(require('./index').name))
+
+  it('should ...', inject(function() {
+
+	//expect(SocketDisconnectedService.doSomething()).toEqual('something')
+
+  }))
+
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.jade b/crowdstf/res/app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.jade
new file mode 100644
index 0000000..c58514a
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.jade
@@ -0,0 +1,14 @@
+.stf-fatal-message.stf-modal
+  .modal-header.dialog-header-errorX
+    button(type='button', ng-click='cancel()').close &times;
+    h4.modal-title.text-danger
+      i.fa.fa-warning
+      .button-spacer
+      span(translate) Disconnected
+  .modal-body
+    nothing-to-show(message='{{ message | translate }}', icon='fa-plug fa-4x')
+
+  .modal-footer
+    button.btn.btn-primary-outline.pull-right(type='button', ng-click='ok()')
+      i.fa.fa-refresh
+      span(translate) Try to reconnect
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/version-update/index.js b/crowdstf/res/app/components/stf/common-ui/modals/version-update/index.js
new file mode 100644
index 0000000..020ed02
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/version-update/index.js
@@ -0,0 +1,5 @@
+module.exports = angular.module('stf.version-update-service', [
+  require('stf/common-ui/modals/common').name,
+  require('ui-bootstrap').name
+])
+  .factory('VersionUpdateService', require('./version-update-service'))
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/version-update/version-update-service.js b/crowdstf/res/app/components/stf/common-ui/modals/version-update/version-update-service.js
new file mode 100644
index 0000000..ae3a9a9
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/version-update/version-update-service.js
@@ -0,0 +1,27 @@
+module.exports = function ServiceFactory($uibModal, $location) {
+  var service = {}
+
+  var ModalInstanceCtrl = function($scope, $uibModalInstance) {
+    $scope.ok = function() {
+      $uibModalInstance.close(true)
+      $location.path('/')
+    }
+
+    $scope.cancel = function() {
+      $uibModalInstance.dismiss('cancel')
+    }
+  }
+
+  service.open = function() {
+    var modalInstance = $uibModal.open({
+      template: require('./version-update.jade'),
+      controller: ModalInstanceCtrl
+    })
+
+    modalInstance.result.then(function(/*selectedItem*/) {
+    }, function() {
+    })
+  }
+
+  return service
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/version-update/version-update-spec.js b/crowdstf/res/app/components/stf/common-ui/modals/version-update/version-update-spec.js
new file mode 100644
index 0000000..d2bd875
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/version-update/version-update-spec.js
@@ -0,0 +1,12 @@
+describe('VersionUpdateService', function() {
+
+  beforeEach(angular.mock.module(require('ui-bootstrap').name))
+  beforeEach(angular.mock.module(require('./').name))
+
+  it('should ...', inject(function() {
+
+	//expect(VersionUpdateService.doSomething()).toEqual('something');
+
+  }))
+
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/version-update/version-update.jade b/crowdstf/res/app/components/stf/common-ui/modals/version-update/version-update.jade
new file mode 100644
index 0000000..bdd7260
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/modals/version-update/version-update.jade
@@ -0,0 +1,11 @@
+.stf-fatal-message.stf-modal
+  .modal-header.dialog-header-notify
+    button(type='button', ng-click='cancel()').close &times;
+    h4.modal-title.text-danger
+      i.fa.fa-exclamation-circle.fa-fw
+      span(translate) Version Update
+  .modal-body(translate) A new version of STF is available
+  .modal-footer
+    button.btn.btn-primary(type='button', ng-click='ok()')
+      i.fa.fa-refresh
+      span(translate) Reload
diff --git a/crowdstf/res/app/components/stf/common-ui/ng-enter/index.js b/crowdstf/res/app/components/stf/common-ui/ng-enter/index.js
new file mode 100644
index 0000000..609d2b6
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/ng-enter/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.ng-enter', [
+
+])
+  .directive('ngEnter', require('./ng-enter-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/ng-enter/ng-enter-directive.js b/crowdstf/res/app/components/stf/common-ui/ng-enter/ng-enter-directive.js
new file mode 100644
index 0000000..1f8b977
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/ng-enter/ng-enter-directive.js
@@ -0,0 +1,12 @@
+module.exports = function ngEnterDirective() {
+  return function(scope, element, attrs) {
+    element.bind('keydown keypress', function(event) {
+      if (event.which === 13) {
+        scope.$apply(function() {
+          scope.$eval(attrs.ngEnter, {event: event})
+        })
+        event.preventDefault()
+      }
+    })
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/ng-enter/ng-enter-spec.js b/crowdstf/res/app/components/stf/common-ui/ng-enter/ng-enter-spec.js
new file mode 100644
index 0000000..e954632
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/ng-enter/ng-enter-spec.js
@@ -0,0 +1,23 @@
+describe('ngEnter', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div ng-enter name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/nice-tabs/README.md b/crowdstf/res/app/components/stf/common-ui/nice-tabs/README.md
new file mode 100644
index 0000000..e809600
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/nice-tabs/README.md
@@ -0,0 +1,34 @@
+# nice-tabs
+
+This are nice tabs. They wrap:
+- Angular Bootstrap tabs
+- Feature Font Awesome icon support
+- Load and preload templates for each tab
+- Save last selected tab to localForage
+- Support tab show/hide (?)
+
+
+
+
+
+### Current syntax
+```html
+<nice-tabs key='ControlBottomTabs' direction='below' tabs='tabs'></nice-tabs>
+```
+
+```javascript
+function Ctrl($scope) {
+	$scope.tabs = [
+    	{title: 'Tab One', icon: 'fa-bolt', templateUrl='terminal/tab-one.jade'},
+    	{title: 'Tab One', icon: 'fa-bolt', templateUrl='terminal/tab-one.jade'},
+	]
+}
+```
+
+### Declarative syntax (future):
+```html
+<nice-tabs key='ControlBottomTabs' direction='below'>
+      <nice-tab title='Tab One' icon='fa-bolt' templateUrl='"terminal/tab-one.jade"'></nice-tab>
+      <nice-tab title='Tab Two' icon='fa-bolt' templateUrl='"terminal/tab-two.jade"' ng-show='showOtherTabs'></nice-tab>
+</nice-tabs>
+```
diff --git a/crowdstf/res/app/components/stf/common-ui/nice-tabs/index.js b/crowdstf/res/app/components/stf/common-ui/nice-tabs/index.js
new file mode 100644
index 0000000..cbeb5c0
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/nice-tabs/index.js
@@ -0,0 +1,5 @@
+module.exports = angular.module('stf.nice-tabs', [
+
+])
+  .directive('niceTab', require('./nice-tab-directive'))
+  .directive('niceTabs', require('./nice-tabs-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tab-directive.js b/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tab-directive.js
new file mode 100644
index 0000000..3406c29
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tab-directive.js
@@ -0,0 +1,12 @@
+// Declarative syntax not implemented yet
+module.exports = function niceTabDirective() {
+  return {
+    restrict: 'E',
+    replace: true,
+    scope: {
+    },
+    template: require('./nice-tab.jade'),
+    link: function() {
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tab.jade b/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tab.jade
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tab.jade
diff --git a/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tabs-directive.js b/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tabs-directive.js
new file mode 100644
index 0000000..a666080
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tabs-directive.js
@@ -0,0 +1,33 @@
+module.exports = function niceTabsDirective() {
+  return {
+    restrict: 'EA',
+    replace: true,
+    template: require('./nice-tabs.jade'),
+    link: function(scope, element, attrs) {
+      // TODO: add support for 'key' for saving in Settings
+      // TODO: add support for 'direction=below' for below tabs
+
+      scope.$watch(attrs.tabs, function(newValue) {
+        scope.tabs = newValue
+      })
+
+      scope.$watch(attrs.filter, function(newValue) {
+        scope.filter = newValue
+      })
+
+      scope.tabFound = function(tab) {
+        if (!tab.filters) {
+          return true
+        }
+        var found = false
+
+        angular.forEach(tab.filters, function(value) {
+          if (value === scope.filter) {
+            found = true
+          }
+        })
+        return found
+      }
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tabs-spec.js b/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tabs-spec.js
new file mode 100644
index 0000000..f7bee4f
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tabs-spec.js
@@ -0,0 +1,23 @@
+describe('niceTabs', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div nice-tabs name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tabs.jade b/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tabs.jade
new file mode 100644
index 0000000..a10df13
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/nice-tabs/nice-tabs.jade
@@ -0,0 +1,8 @@
+.heading-for-tabs.tabs
+  uib-tabset
+    uib-tab(ng-repeat='tab in tabs', active='tab.active', ng-hide='!tabFound(tab)')
+      uib-tab-heading
+        i.fa(ng-class='tab.icon')
+        span {{tab.title | translate }}
+      div(ng-if='tab.active')
+        div(ng-include='tab.templateUrl')
diff --git a/crowdstf/res/app/components/stf/common-ui/nothing-to-show/index.js b/crowdstf/res/app/components/stf/common-ui/nothing-to-show/index.js
new file mode 100644
index 0000000..963b239
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/nothing-to-show/index.js
@@ -0,0 +1,4 @@
+require('./nothing-to-show.css')
+
+module.exports = angular.module('stf/common-ui/nothing-to-show', [])
+  .directive('nothingToShow', require('./nothing-to-show-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/nothing-to-show/nothing-to-show-directive.js b/crowdstf/res/app/components/stf/common-ui/nothing-to-show/nothing-to-show-directive.js
new file mode 100644
index 0000000..e02533c
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/nothing-to-show/nothing-to-show-directive.js
@@ -0,0 +1,15 @@
+module.exports = function() {
+  return {
+    restrict: 'EA',
+    transclude: true,
+    scope: {
+      icon: '@',
+      message: '@'
+    },
+    template: require('./nothing-to-show.html'),
+    link: function(scope, element, attrs) {
+      scope.icon = attrs.icon
+      scope.message = attrs.message
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/nothing-to-show/nothing-to-show.css b/crowdstf/res/app/components/stf/common-ui/nothing-to-show/nothing-to-show.css
new file mode 100644
index 0000000..324a0a2
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/nothing-to-show/nothing-to-show.css
@@ -0,0 +1,10 @@
+.nothing-to-show {
+  color: #b7b7b7;
+  min-height: 130px;
+  text-align: center;
+  padding: 15px 0;
+}
+
+.nothing-to-show p {
+  font-size: 20px;
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/nothing-to-show/nothing-to-show.html b/crowdstf/res/app/components/stf/common-ui/nothing-to-show/nothing-to-show.html
new file mode 100644
index 0000000..6191de3
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/nothing-to-show/nothing-to-show.html
@@ -0,0 +1,5 @@
+<div class="nothing-to-show">
+    <span ng-transclude></span>
+    <i class="fa fa-4x" ng-class="icon"></i>
+    <p ng-bind="message"></p>
+</div>
diff --git a/crowdstf/res/app/components/stf/common-ui/notifications/growl.css b/crowdstf/res/app/components/stf/common-ui/notifications/growl.css
new file mode 100644
index 0000000..a95a9c0
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/notifications/growl.css
@@ -0,0 +1,25 @@
+.growl {
+  position: fixed;
+  top: 60px;
+  right: 15px;
+  float: right;
+  z-index: 9999;
+}
+
+.growl-item.ng-enter,
+.growl-item.ng-leave {
+  -webkit-transition: 0.3s linear all;
+  -moz-transition: 0.3s linear all;
+  -o-transition: 0.3s linear all;
+  transition: 0.3s linear all;
+}
+
+.growl-item.ng-enter,
+.growl-item.ng-leave.ng-leave-active {
+  opacity: 0;
+}
+
+.growl-item.ng-leave,
+.growl-item.ng-enter.ng-enter-active {
+  opacity: 1;
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/notifications/index.js b/crowdstf/res/app/components/stf/common-ui/notifications/index.js
new file mode 100644
index 0000000..ff5d911
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/notifications/index.js
@@ -0,0 +1,8 @@
+require('angular-animate')
+require('./growl.css')
+require('angular-growl')
+
+module.exports = angular.module('stf/common-ui/notifications', [
+  'ngAnimate',
+  'angular-growl'
+])
diff --git a/crowdstf/res/app/components/stf/common-ui/refresh-page/index.js b/crowdstf/res/app/components/stf/common-ui/refresh-page/index.js
new file mode 100644
index 0000000..790e671
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/refresh-page/index.js
@@ -0,0 +1,2 @@
+module.exports = angular.module('stf.refresh-page', [])
+  .directive('refreshPage', require('./refresh-page-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/refresh-page/refresh-page-directive.js b/crowdstf/res/app/components/stf/common-ui/refresh-page/refresh-page-directive.js
new file mode 100644
index 0000000..8a8f05e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/refresh-page/refresh-page-directive.js
@@ -0,0 +1,14 @@
+module.exports = function refreshPageDirective($window) {
+  return {
+    restrict: 'E',
+    replace: true,
+    scope: {
+    },
+    template: require('./refresh-page.jade'),
+    link: function(scope) {
+      scope.reloadWindow = function() {
+        $window.location.reload()
+      }
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/refresh-page/refresh-page-spec.js b/crowdstf/res/app/components/stf/common-ui/refresh-page/refresh-page-spec.js
new file mode 100644
index 0000000..9f4431d
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/refresh-page/refresh-page-spec.js
@@ -0,0 +1,23 @@
+describe('refreshPage', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your
+     directive, send that through compile() then compare the results.
+
+     var element = compile('<div refresh-page name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/refresh-page/refresh-page.jade b/crowdstf/res/app/components/stf/common-ui/refresh-page/refresh-page.jade
new file mode 100644
index 0000000..182ed53
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/refresh-page/refresh-page.jade
@@ -0,0 +1,3 @@
+button.btn.btn-sm.btn-primary-outline(ng-click='reloadWindow()')
+  i.fa.fa-refresh
+  span(translate) Refresh
diff --git a/crowdstf/res/app/components/stf/common-ui/safe-apply/index.js b/crowdstf/res/app/components/stf/common-ui/safe-apply/index.js
new file mode 100644
index 0000000..2a3473b
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/safe-apply/index.js
@@ -0,0 +1,20 @@
+module.exports = angular.module('stf.safe-apply', [])
+  .config([
+    '$provide', function($provide) {
+      return $provide.decorator('$rootScope', [
+        '$delegate', function($delegate) {
+          $delegate.safeApply = function(fn) {
+            var phase = $delegate.$$phase
+            if (phase === '$apply' || phase === '$digest') {
+              if (fn && typeof fn === 'function') {
+                fn()
+              }
+            } else {
+              $delegate.$apply(fn)
+            }
+          }
+          return $delegate
+        }
+      ])
+    }
+  ])
diff --git a/crowdstf/res/app/components/stf/common-ui/stacked-icon/index.js b/crowdstf/res/app/components/stf/common-ui/stacked-icon/index.js
new file mode 100644
index 0000000..9cc7052
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/stacked-icon/index.js
@@ -0,0 +1,2 @@
+module.exports = angular.module('stf.stacked-icon', [])
+  .directive('stackedIcon', require('./stacked-icon-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/stacked-icon/stacked-icon-directive.js b/crowdstf/res/app/components/stf/common-ui/stacked-icon/stacked-icon-directive.js
new file mode 100644
index 0000000..4bd87ba
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/stacked-icon/stacked-icon-directive.js
@@ -0,0 +1,13 @@
+require('./stacked-icon.css')
+
+module.exports = function clearButtonDirective() {
+  return {
+    restrict: 'EA',
+    replace: true,
+    scope: {
+      icon: '@',
+      color: '@'
+    },
+    template: require('./stacked-icon.jade')
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/stacked-icon/stacked-icon.css b/crowdstf/res/app/components/stf/common-ui/stacked-icon/stacked-icon.css
new file mode 100644
index 0000000..0ad10b2
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/stacked-icon/stacked-icon.css
@@ -0,0 +1,12 @@
+.stf-stacked-icon .fa-square {
+  font-size: 22px !important;
+}
+
+.stf-stacked-icon.fa-stack {
+  height: 24px !important;
+  width: 21px !important;
+}
+
+.stf-stacked-icon .fa-stack-1x {
+  line-height: 24px !important;
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/stacked-icon/stacked-icon.jade b/crowdstf/res/app/components/stf/common-ui/stacked-icon/stacked-icon.jade
new file mode 100644
index 0000000..6d1527e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/stacked-icon/stacked-icon.jade
@@ -0,0 +1,3 @@
+span.fa-stack.fa-lgX.stf-stacked-icon
+  i.fa.fa-square.fa-stack-2x(ng-class='color')
+  i.fa(ng-class='icon').fa-stack-1x.fa-inverse
diff --git a/crowdstf/res/app/components/stf/common-ui/table/index.js b/crowdstf/res/app/components/stf/common-ui/table/index.js
new file mode 100644
index 0000000..eb1056f
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/table/index.js
@@ -0,0 +1,6 @@
+require('./table.css')
+require('script!ng-table/dist/ng-table')
+
+module.exports = angular.module('stf/common-ui/table', [
+  'ngTable'
+])
diff --git a/crowdstf/res/app/components/stf/common-ui/table/table.css b/crowdstf/res/app/components/stf/common-ui/table/table.css
new file mode 100644
index 0000000..727a87e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/table/table.css
@@ -0,0 +1,162 @@
+.ng-table th {
+  -webkit-touch-callout: none;
+  -webkit-user-select: none;
+  -khtml-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+  font-weight: bold;
+}
+
+.ng-table th.sortable {
+  cursor: pointer;
+}
+
+.ng-table th.sortable div {
+  padding-right: 18px;
+  position: relative;
+}
+
+.ng-table th.sortable div:after,
+.ng-table th.sortable div:before {
+  content: "";
+  border-width: 0 4px 4px;
+  border-style: solid;
+  border-color: #000 transparent;
+  visibility: visible;
+  right: 8px;
+  top: 50%;
+  position: absolute;
+  opacity: .3;
+  margin-top: -4px;
+}
+
+.ng-table th.sortable div:before {
+  margin-top: 2px;
+  border-bottom: none;
+  border-left: 4px solid transparent;
+  border-right: 4px solid transparent;
+  border-top: 4px solid #000;
+}
+
+.ng-table th.sortable div:hover:after,
+.ng-table th.sortable div:hover:before {
+  opacity: 1;
+  visibility: visible;
+}
+
+.ng-table th.sortable.sort-desc,
+.ng-table th.sortable.sort-asc {
+  background-color: rgba(183, 194, 219, 0.25);
+  text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75);
+}
+
+.ng-table th.sortable.sort-desc div:after,
+.ng-table th.sortable.sort-asc div:after {
+  margin-top: -2px;
+}
+
+.ng-table th.sortable.sort-desc div:before,
+.ng-table th.sortable.sort-asc div:before {
+  visibility: hidden;
+}
+
+.ng-table th.sortable.sort-asc div:after,
+.ng-table th.sortable.sort-asc div:hover:after {
+  visibility: visible;
+  filter: alpha(opacity=60);
+  -khtml-opacity: 0.6;
+  -moz-opacity: 0.6;
+  opacity: 0.6;
+}
+
+.ng-table th.sortable.sort-desc div:after {
+  border-bottom: none;
+  border-left: 4px solid transparent;
+  border-right: 4px solid transparent;
+  border-top: 4px solid #000;
+  visibility: visible;
+  -webkit-box-shadow: none;
+  -moz-box-shadow: none;
+  box-shadow: none;
+  filter: alpha(opacity=60);
+  -khtml-opacity: 0.6;
+  -moz-opacity: 0.6;
+  opacity: 0.6;
+}
+
+.ng-table th.filter .input-filter {
+  margin: 0;
+  display: block;
+  width: 100%;
+  min-height: 30px;
+  -webkit-box-sizing: border-box;
+  -moz-box-sizing: border-box;
+  box-sizing: border-box;
+}
+
+.ng-table + .pagination {
+  margin-top: 0;
+}
+
+@media only screen and (max-width: 800px) {
+  .ng-table-responsive {
+    border-bottom: 1px solid #999999;
+  }
+
+  .ng-table-responsive tr {
+    border-top: 1px solid #999999;
+    border-left: 1px solid #999999;
+    border-right: 1px solid #999999;
+  }
+
+  .ng-table-responsive td:before {
+    position: absolute;
+    padding: 8px;
+    left: 0;
+    top: 0;
+    width: 50%;
+    white-space: nowrap;
+    text-align: left;
+    font-weight: bold;
+  }
+
+  .ng-table-responsive thead tr th {
+    text-align: left;
+  }
+
+  .ng-table-responsive thead tr.ng-table-filters th {
+    padding: 0;
+  }
+
+  .ng-table-responsive thead tr.ng-table-filters th form > div {
+    padding: 8px;
+  }
+
+  .ng-table-responsive td {
+    border: none;
+    border-bottom: 1px solid #eeeeee;
+    position: relative;
+    padding-left: 50%;
+    white-space: normal;
+    text-align: left;
+  }
+
+  .ng-table-responsive td:before {
+    content: attr(data-title-text);
+  }
+
+  .ng-table-responsive,
+  .ng-table-responsive thead,
+  .ng-table-responsive tbody,
+  .ng-table-responsive th,
+  .ng-table-responsive td,
+  .ng-table-responsive tr {
+    display: block;
+  }
+}
+
+/* We don't use any table pagination */
+.ng-table-pager {
+  display: none;
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/text-focus-select/index.js b/crowdstf/res/app/components/stf/common-ui/text-focus-select/index.js
new file mode 100644
index 0000000..43dc881
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/text-focus-select/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.text-focus-select', [
+
+])
+  .directive('textFocusSelect', require('./text-focus-select-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/text-focus-select/text-focus-select-directive.js b/crowdstf/res/app/components/stf/common-ui/text-focus-select/text-focus-select-directive.js
new file mode 100644
index 0000000..62a0cd4
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/text-focus-select/text-focus-select-directive.js
@@ -0,0 +1,11 @@
+module.exports = function textFocusSelectDirective() {
+  return {
+    restrict: 'AC',
+    link: function(scope, element) {
+      // TODO: try with focus event
+      element.bind('click', function() {
+        this.select()
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/text-focus-select/text-focus-select-spec.js b/crowdstf/res/app/components/stf/common-ui/text-focus-select/text-focus-select-spec.js
new file mode 100644
index 0000000..ebb54e7
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/text-focus-select/text-focus-select-spec.js
@@ -0,0 +1,23 @@
+describe('textFocusSelect', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div text-focus-select name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/common-ui/tooltips/README.md b/crowdstf/res/app/components/stf/common-ui/tooltips/README.md
new file mode 100644
index 0000000..e71cf30
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/tooltips/README.md
@@ -0,0 +1,16 @@
+# stf-tooltips
+
+Based on Angular Bootstrap.
+
+Usage:
+
+```html
+help-title='{{"Run Command"|translate}}'
+help-key='Enter'
+```
+
+Maps to:
+
+```html
+tooltip-html-unsafe='{{"Run Command<br /><br /><code>Enter</code>"|translate}}'
+```
\ No newline at end of file
diff --git a/crowdstf/res/app/components/stf/common-ui/tooltips/index.js b/crowdstf/res/app/components/stf/common-ui/tooltips/index.js
new file mode 100644
index 0000000..9dc4aaf
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/tooltips/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.tooltips', [
+
+])
+  .directive('tooltips', require('./tooltips-directive'))
diff --git a/crowdstf/res/app/components/stf/common-ui/tooltips/tooltips-directive.js b/crowdstf/res/app/components/stf/common-ui/tooltips/tooltips-directive.js
new file mode 100644
index 0000000..d005682
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/tooltips/tooltips-directive.js
@@ -0,0 +1,7 @@
+module.exports = function tooltipsDirective() {
+  return {
+    restrict: 'A',
+    link: function() {
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/common-ui/tooltips/tooltips-spec.js b/crowdstf/res/app/components/stf/common-ui/tooltips/tooltips-spec.js
new file mode 100644
index 0000000..fb05dec
--- /dev/null
+++ b/crowdstf/res/app/components/stf/common-ui/tooltips/tooltips-spec.js
@@ -0,0 +1,23 @@
+describe('tooltips', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div tooltips name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/control/control-service.js b/crowdstf/res/app/components/stf/control/control-service.js
new file mode 100644
index 0000000..5246860
--- /dev/null
+++ b/crowdstf/res/app/components/stf/control/control-service.js
@@ -0,0 +1,303 @@
+module.exports = function ControlServiceFactory(
+  $upload
+, $http
+, socket
+, TransactionService
+, $rootScope
+, gettext
+, KeycodesMapped
+) {
+  var controlService = {
+  }
+
+  function ControlService(target, channel) {
+    function sendOneWay(action, data) {
+      socket.emit(action, channel, data)
+    }
+
+    function sendTwoWay(action, data) {
+      var tx = TransactionService.create(target)
+      socket.emit(action, channel, tx.channel, data)
+      return tx.promise
+    }
+
+    function keySender(type, fixedKey) {
+      return function(key) {
+        if (typeof key === 'string') {
+          sendOneWay(type, {
+            key: key
+          })
+        }
+        else {
+          var mapped = fixedKey || KeycodesMapped[key]
+          if (mapped) {
+            sendOneWay(type, {
+              key: mapped
+            })
+          }
+        }
+      }
+    }
+
+    this.gestureStart = function(seq) {
+      sendOneWay('input.gestureStart', {
+        seq: seq
+      })
+    }
+
+    this.gestureStop = function(seq) {
+      sendOneWay('input.gestureStop', {
+        seq: seq
+      })
+    }
+
+    this.touchDown = function(seq, contact, x, y, pressure) {
+      sendOneWay('input.touchDown', {
+        seq: seq
+      , contact: contact
+      , x: x
+      , y: y
+      , pressure: pressure
+      })
+    }
+
+    this.touchMove = function(seq, contact, x, y, pressure) {
+      sendOneWay('input.touchMove', {
+        seq: seq
+      , contact: contact
+      , x: x
+      , y: y
+      , pressure: pressure
+      })
+    }
+
+    this.touchUp = function(seq, contact) {
+      sendOneWay('input.touchUp', {
+        seq: seq
+      , contact: contact
+      })
+    }
+
+    this.touchCommit = function(seq) {
+      sendOneWay('input.touchCommit', {
+        seq: seq
+      })
+    }
+
+    this.touchReset = function(seq) {
+      sendOneWay('input.touchReset', {
+        seq: seq
+      })
+    }
+
+    this.keyDown = keySender('input.keyDown')
+    this.keyUp = keySender('input.keyUp')
+    this.keyPress = keySender('input.keyPress')
+
+    this.home = keySender('input.keyPress', 'home')
+    this.menu = keySender('input.keyPress', 'menu')
+    this.back = keySender('input.keyPress', 'back')
+
+    this.type = function(text) {
+      return sendOneWay('input.type', {
+        text: text
+      })
+    }
+
+    this.paste = function(text) {
+      return sendTwoWay('clipboard.paste', {
+        text: text
+      })
+    }
+
+    this.copy = function() {
+      return sendTwoWay('clipboard.copy')
+    }
+
+    //@TODO: Refactor this please
+    var that = this
+    this.getClipboardContent = function() {
+      that.copy().then(function(result) {
+        $rootScope.$apply(function() {
+          if (result.success) {
+            if (result.lastData) {
+              that.clipboardContent = result.lastData
+            } else {
+              that.clipboardContent = gettext('No clipboard data')
+            }
+          } else {
+            that.clipboardContent = gettext('Error while getting data')
+          }
+        })
+      })
+    }
+
+    this.shell = function(command) {
+      return sendTwoWay('shell.command', {
+        command: command
+      , timeout: 10000
+      })
+    }
+
+    this.identify = function() {
+      return sendTwoWay('device.identify')
+    }
+
+    this.install = function(options) {
+      return sendTwoWay('device.install', options)
+    }
+
+    this.uninstall = function(pkg) {
+      return sendTwoWay('device.uninstall', {
+        packageName: pkg
+      })
+    }
+
+    this.reboot = function() {
+      return sendTwoWay('device.reboot')
+    }
+
+    this.rotate = function(rotation, lock) {
+      return sendOneWay('display.rotate', {
+        rotation: rotation,
+        lock: lock
+      })
+    }
+
+    this.testForward = function(forward) {
+      return sendTwoWay('forward.test', {
+        targetHost: forward.targetHost
+      , targetPort: Number(forward.targetPort)
+      })
+    }
+
+    this.createForward = function(forward) {
+      return sendTwoWay('forward.create', {
+        id: forward.id
+      , devicePort: Number(forward.devicePort)
+      , targetHost: forward.targetHost
+      , targetPort: Number(forward.targetPort)
+      })
+    }
+
+    this.removeForward = function(forward) {
+      return sendTwoWay('forward.remove', {
+        id: forward.id
+      })
+    }
+
+    this.startLogcat = function(filters) {
+      return sendTwoWay('logcat.start', {
+        filters: filters
+      })
+    }
+
+    this.stopLogcat = function() {
+      return sendTwoWay('logcat.stop')
+    }
+
+    this.startRemoteConnect = function() {
+      return sendTwoWay('connect.start')
+    }
+
+    this.stopRemoteConnect = function() {
+      return sendTwoWay('connect.stop')
+    }
+
+    this.openBrowser = function(url, browser) {
+      return sendTwoWay('browser.open', {
+        url: url
+      , browser: browser ? browser.id : null
+      })
+    }
+
+    this.clearBrowser = function(browser) {
+      return sendTwoWay('browser.clear', {
+        browser: browser.id
+      })
+    }
+
+    this.openStore = function() {
+      return sendTwoWay('store.open')
+    }
+
+    this.screenshot = function() {
+      return sendTwoWay('screen.capture')
+    }
+
+    this.fsretrieve = function(file) {
+      return sendTwoWay('fs.retrieve', {
+        file: file
+      })
+    }
+
+    this.fslist = function(dir) {
+      return sendTwoWay('fs.list', {
+        dir: dir
+      })
+    }
+
+    this.checkAccount = function(type, account) {
+      return sendTwoWay('account.check', {
+        type: type
+      , account: account
+      })
+    }
+
+    this.removeAccount = function(type, account) {
+      return sendTwoWay('account.remove', {
+        type: type
+      , account: account
+      })
+    }
+
+    this.addAccountMenu = function() {
+      return sendTwoWay('account.addmenu')
+    }
+
+    this.addAccount = function(user, password) {
+      return sendTwoWay('account.add', {
+        user: user
+      , password: password
+      })
+    }
+
+    this.getAccounts = function(type) {
+      return sendTwoWay('account.get', {
+        type: type
+      })
+    }
+
+    this.getSdStatus = function() {
+      return sendTwoWay('sd.status')
+    }
+
+    this.setRingerMode = function(mode) {
+      return sendTwoWay('ringer.set', {
+        mode: mode
+      })
+    }
+
+    this.getRingerMode = function() {
+      return sendTwoWay('ringer.get')
+    }
+
+    this.setWifiEnabled = function(enabled) {
+      return sendTwoWay('wifi.set', {
+        enabled: enabled
+      })
+    }
+
+    this.getWifiStatus = function() {
+      return sendTwoWay('wifi.get')
+    }
+
+    window.cc = this
+  }
+
+  controlService.create = function(target, channel) {
+    return new ControlService(target, channel)
+  }
+
+  return controlService
+}
diff --git a/crowdstf/res/app/components/stf/control/index.js b/crowdstf/res/app/components/stf/control/index.js
new file mode 100644
index 0000000..223392b
--- /dev/null
+++ b/crowdstf/res/app/components/stf/control/index.js
@@ -0,0 +1,6 @@
+module.exports = angular.module('stf/control', [
+  require('stf/socket').name,
+  require('stf/transaction').name,
+  require('stf/keycodes').name
+])
+  .factory('ControlService', require('./control-service'))
diff --git a/crowdstf/res/app/components/stf/device-context-menu/device-context-menu-directive.js b/crowdstf/res/app/components/stf/device-context-menu/device-context-menu-directive.js
new file mode 100644
index 0000000..33839df
--- /dev/null
+++ b/crowdstf/res/app/components/stf/device-context-menu/device-context-menu-directive.js
@@ -0,0 +1,26 @@
+module.exports = function deviceContextMenuDirective($window) {
+  return {
+    restrict: 'EA',
+    replace: false,
+    //scope: {
+    //  control: '&',
+    //  device: '&'
+    //},
+    transclude: true,
+    template: require('./device-context-menu.jade'),
+    link: function(scope) {
+      //var device = scope.device()
+      //var control = scope.control()
+      scope.windowClose = function() {
+        $window.close()
+      }
+
+      scope.saveScreenShot = function() {
+        scope.control.screenshot().then(function(result) {
+          location.href = result.body.href + '?download'
+        })
+      }
+
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/device-context-menu/device-context-menu-spec.js b/crowdstf/res/app/components/stf/device-context-menu/device-context-menu-spec.js
new file mode 100644
index 0000000..0c59b73
--- /dev/null
+++ b/crowdstf/res/app/components/stf/device-context-menu/device-context-menu-spec.js
@@ -0,0 +1,23 @@
+describe('deviceContextMenu', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div device-context-menu name="name">hi</div>')(scope)
+     expect(element.text()).toBe('hello, world')
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/device-context-menu/device-context-menu.css b/crowdstf/res/app/components/stf/device-context-menu/device-context-menu.css
new file mode 100644
index 0000000..39aaa3a
--- /dev/null
+++ b/crowdstf/res/app/components/stf/device-context-menu/device-context-menu.css
@@ -0,0 +1,3 @@
+.stf-device-context-menu {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/components/stf/device-context-menu/device-context-menu.jade b/crowdstf/res/app/components/stf/device-context-menu/device-context-menu.jade
new file mode 100644
index 0000000..b9b8b32
--- /dev/null
+++ b/crowdstf/res/app/components/stf/device-context-menu/device-context-menu.jade
@@ -0,0 +1,31 @@
+.dropdown.context-menu(id='context-menu-{{ $index }}')
+  ul.dropdown-menu(role='menu')
+    li
+      a.pointer(role='menuitem', ng-click='control.back(); $event.preventDefault()')
+        i.fa.fa-mail-reply.fa-fw
+        span(translate) Back
+    li
+      a.pointer(role='menuitem', ng-click='control.home(); $event.preventDefault()')
+        i.fa.fa-home.fa-fw
+        span(translate) Home
+    li.divider
+    li
+      a.pointer(role='menuitem', ng-click='rotateRight(); $event.preventDefault()')
+        i.fa.fa-rotate-left.fa-fw
+        span(translate) Rotate Left
+    li
+      a.pointer(role='menuitem', ng-click='rotateLeft(); $event.preventDefault()')
+        i.fa.fa-rotate-right.fa-fw
+        span(translate) Rotate Right
+    li.divider
+    li
+      a.pointer(role='menuitem', ng-click='saveScreenShot(); $event.preventDefault()')
+        i.fa.fa-camera.fa-fw
+        span(translate) Save ScreenShot
+    li.divider
+    li
+      a.pointer(role='menuitem', ng-click='$root.standalone ? windowClose() : kickDevice(device); $event.preventDefault()')
+        i.fa.fa-sign-out.fa-fw
+        span(translate) Stop Using
+
+.stf-device-context-menu(ng-transclude, context-menu, data-target='context-menu-{{ $index }}').fill-height
diff --git a/crowdstf/res/app/components/stf/device-context-menu/index.js b/crowdstf/res/app/components/stf/device-context-menu/index.js
new file mode 100644
index 0000000..4deebb1
--- /dev/null
+++ b/crowdstf/res/app/components/stf/device-context-menu/index.js
@@ -0,0 +1,6 @@
+require('./device-context-menu.css')
+
+module.exports = angular.module('stf.device-context-menu', [
+  require('ng-context-menu').name
+])
+  .directive('deviceContextMenu', require('./device-context-menu-directive'))
diff --git a/crowdstf/res/app/components/stf/device/device-info-filter/index.js b/crowdstf/res/app/components/stf/device/device-info-filter/index.js
new file mode 100644
index 0000000..198ee0c
--- /dev/null
+++ b/crowdstf/res/app/components/stf/device/device-info-filter/index.js
@@ -0,0 +1,120 @@
+module.exports = angular.module('stf.device-status', [])
+  .filter('statusNameAction', function(gettext) {
+    return function(text) {
+      return {
+        absent: gettext('Disconnected'),
+        present: gettext('Connected'),
+        offline: gettext('Offline'),
+        unauthorized: gettext('Unauthorized'),
+        preparing: gettext('Preparing'),
+        ready: gettext('Ready'),
+        using: gettext('Stop Using'),
+        busy: gettext('Busy'),
+        available: gettext('Use')
+      }[text] || gettext('Unknown')
+    }
+  })
+  .filter('statusNamePassive', function(gettext) {
+    return function(text) {
+      return {
+        absent: gettext('Disconnected'),
+        present: gettext('Connected'),
+        offline: gettext('Offline'),
+        unauthorized: gettext('Unauthorized'),
+        preparing: gettext('Preparing'),
+        ready: gettext('Ready'),
+        using: gettext('Using'),
+        busy: gettext('Busy'),
+        available: gettext('Available')
+      }[text] || gettext('Unknown')
+    }
+  })
+ .filter('likelyLeaveReason', function(gettext) {
+    return function(text) {
+      return {
+        ungroup_request: gettext('You (or someone else) kicked the device.'),
+        owner_change: gettext('Someone stole your device.'),
+        automatic_timeout: gettext('Device was kicked by automatic timeout.	'),
+        device_absent: gettext('Device is not present anymore for some reason.'),
+        status_change: gettext('Device is present but offline.')
+      }[text] || gettext('Unknown reason.')
+    }
+  })
+  .filter('batteryHealth', function(gettext) {
+    return function(text) {
+      return {
+        cold: gettext('Cold'),
+        good: gettext('Good'),
+        dead: gettext('Dead'),
+        over_voltage: gettext('Over Voltage'),
+        overheat: gettext('Overheat'),
+        unspecified_failure: gettext('Unspecified Failure')
+      }[text] || gettext('-')
+    }
+  })
+  .filter('batterySource', function(gettext) {
+    return function(text) {
+      return {
+        ac: gettext('AC'),
+        usb: gettext('USB'),
+        wireless: gettext('Wireless')
+      }[text] || gettext('-')
+    }
+  })
+  .filter('batteryStatus', function(gettext) {
+    return function(text) {
+      return {
+        charging: gettext('Charging'),
+        discharging: gettext('Discharging'),
+        full: gettext('Full'),
+        not_charging: gettext('Not Charging')
+      }[text] || gettext('-')
+    }
+  })
+  .filter('displayDensity', function() {
+    return function(text) {
+      return {
+        '0.5': 'LDPI', // (120 dpi)
+        '1': 'MDPI', // (160 dpi)
+        '1.5': 'HDPI', // (240 dpi)
+        '2': 'XHDPI', // (320 dpi)
+        '3': 'XXHDPI', // (480 dpi)
+        '4': 'XXXHDPI' // (640 dpi)
+      }[text] || text
+    }
+  })
+  .filter('networkType', function(gettext) {
+    return function(text) {
+      return {
+        bluetooth: gettext('Bluetooth'),
+        dummy: gettext('Dummy'),
+        ethernet: gettext('Ethernet'),
+        mobile: gettext('Mobile'),
+        mobile_dun: gettext('Mobile DUN'),
+        mobile_hipri: gettext('Mobile High Priority'),
+        mobile_mms: gettext('Mobile MMS'),
+        mobile_supl: gettext('Mobile SUPL'),
+        mobile_wifi: gettext('WiFi'),
+        wimax: gettext('WiMAX')
+      }[text] || text
+    }
+  })
+  .filter('networkSubType', function(gettext) {
+    return function(text) {
+      return {
+        mobile_wifi: gettext('WiFi')
+      }[text] || text
+    }
+  })
+  .filter('humanizedBool', function(gettext) {
+    return function(text) {
+      switch (text) {
+        case true:
+          return gettext('Yes')
+        case false:
+          return gettext('No')
+        default:
+          return gettext('-')
+      }
+    }
+  })
diff --git a/crowdstf/res/app/components/stf/device/device-service.js b/crowdstf/res/app/components/stf/device/device-service.js
new file mode 100644
index 0000000..95aeb63
--- /dev/null
+++ b/crowdstf/res/app/components/stf/device/device-service.js
@@ -0,0 +1,222 @@
+var oboe = require('oboe')
+var _ = require('lodash')
+var EventEmitter = require('eventemitter3')
+
+module.exports = function DeviceServiceFactory($http, socket, EnhanceDeviceService) {
+  var deviceService = {}
+
+  function Tracker($scope, options) {
+    var devices = []
+    var devicesBySerial = Object.create(null)
+    var scopedSocket = socket.scoped($scope)
+    var digestTimer, lastDigest
+
+    $scope.$on('$destroy', function() {
+      clearTimeout(digestTimer)
+    })
+
+    function digest() {
+      // Not great. Consider something else
+      if (!$scope.$$phase) {
+        $scope.$digest()
+      }
+
+      lastDigest = Date.now()
+      digestTimer = null
+    }
+
+    function notify(event) {
+      if (!options.digest) {
+        return
+      }
+
+      if (event.important) {
+        // Handle important updates immediately.
+        //digest()
+        window.requestAnimationFrame(digest)
+      }
+      else {
+        if (!digestTimer) {
+          var delta = Date.now() - lastDigest
+          if (delta > 1000) {
+            // It's been a while since the last update, so let's just update
+            // right now even though it's low priority.
+            digest()
+          }
+          else {
+            // It hasn't been long since the last update. Let's wait for a
+            // while so that the UI doesn't get stressed out.
+            digestTimer = setTimeout(digest, delta)
+          }
+        }
+      }
+    }
+
+    function sync(data) {
+      // usable IF device is physically present AND device is online AND
+      // preparations are ready AND the device has no owner or we are the
+      // owner
+      data.usable = data.present && data.status === 3 && data.ready &&
+        (!data.owner || data.using)
+
+      // Make sure we don't mistakenly think we still have the device
+      if (!data.usable || !data.owner) {
+        data.using = false
+      }
+
+      EnhanceDeviceService.enhance(data)
+    }
+
+    function get(data) {
+      return devices[devicesBySerial[data.serial]]
+    }
+
+    var insert = function insert(data) {
+      devicesBySerial[data.serial] = devices.push(data) - 1
+      sync(data)
+      this.emit('add', data)
+    }.bind(this)
+
+    var modify = function modify(data, newData) {
+      _.merge(data, newData, function(a, b) {
+        // New Arrays overwrite old Arrays
+        if (_.isArray(b)) {
+          return b
+        }
+      })
+      sync(data)
+      this.emit('change', data)
+    }.bind(this)
+
+    var remove = function remove(data) {
+      var index = devicesBySerial[data.serial]
+      if (index >= 0) {
+        devices.splice(index, 1)
+        delete devicesBySerial[data.serial]
+        this.emit('remove', data)
+      }
+    }.bind(this)
+
+    function fetch(data) {
+      deviceService.load(data.serial)
+        .then(function(device) {
+          return changeListener({
+            important: true
+          , data: device
+          })
+        })
+        .catch(function() {})
+    }
+
+    function addListener(event) {
+      var device = get(event.data)
+      if (device) {
+        modify(device, event.data)
+        notify(event)
+      }
+      else {
+        if (options.filter(event.data)) {
+          insert(event.data)
+          notify(event)
+        }
+      }
+    }
+
+    function changeListener(event) {
+      var device = get(event.data)
+      if (device) {
+        modify(device, event.data)
+        if (!options.filter(device)) {
+          remove(device)
+        }
+        notify(event)
+      }
+      else {
+        if (options.filter(event.data)) {
+          insert(event.data)
+          // We've only got partial data
+          fetch(event.data)
+          notify(event)
+        }
+      }
+    }
+
+    scopedSocket.on('device.add', addListener)
+    scopedSocket.on('device.remove', changeListener)
+    scopedSocket.on('device.change', changeListener)
+
+    this.add = function(device) {
+      addListener({
+        important: true
+      , data: device
+      })
+    }
+
+    this.devices = devices
+  }
+
+  Tracker.prototype = new EventEmitter()
+
+  deviceService.trackAll = function($scope) {
+    var tracker = new Tracker($scope, {
+      filter: function() {
+        return true
+      }
+    , digest: false
+    })
+
+    oboe('/app/api/v1/devices')
+      .node('devices[*]', function(device) {
+        tracker.add(device)
+      })
+
+    return tracker
+  }
+
+  deviceService.trackGroup = function($scope) {
+    var tracker = new Tracker($scope, {
+      filter: function(device) {
+        return device.using
+      }
+    , digest: true
+    })
+
+    oboe('/app/api/v1/group')
+      .node('devices[*]', function(device) {
+        tracker.add(device)
+      })
+
+    return tracker
+  }
+
+  deviceService.load = function(serial) {
+    return $http.get('/app/api/v1/devices/' + serial)
+      .then(function(response) {
+        return response.data.device
+      })
+  }
+
+  deviceService.get = function(serial, $scope) {
+    var tracker = new Tracker($scope, {
+      filter: function(device) {
+        return device.serial === serial
+      }
+    , digest: true
+    })
+
+    return deviceService.load(serial)
+      .then(function(device) {
+        tracker.add(device)
+        return device
+      })
+  }
+
+  deviceService.updateNote = function(serial, note) {
+    socket.emit('device.note', {
+      serial: serial,
+      note: note
+    })
+  }
+
+  return deviceService
+}
diff --git a/crowdstf/res/app/components/stf/device/enhance-device/enhance-device-service.js b/crowdstf/res/app/components/stf/device/enhance-device/enhance-device-service.js
new file mode 100644
index 0000000..39afae9
--- /dev/null
+++ b/crowdstf/res/app/components/stf/device/enhance-device/enhance-device-service.js
@@ -0,0 +1,93 @@
+module.exports = function EnhanceDeviceServiceFactory($filter, AppState) {
+  var service = {}
+
+  function setState(data) {
+    // For convenience, formulate an aggregate state property that covers
+    // every possible state.
+    data.state = 'absent'
+    if (data.present) {
+      data.state = 'present'
+      switch (data.status) {
+        case 1:
+          data.state = 'offline'
+          break
+        case 2:
+          data.state = 'unauthorized'
+          break
+        case 3:
+          data.state = 'preparing'
+          if (data.ready) {
+            data.state = 'ready'
+            if (data.using) {
+              data.state = 'using'
+            }
+            else {
+              if (data.owner) {
+                data.state = 'busy'
+              }
+              else {
+                data.state = 'available'
+              }
+            }
+          }
+          break
+      }
+    }
+  }
+
+  function enhanceDevice(device) {
+    device.enhancedName = device.name || device.model || device.serial || 'Unknown'
+    device.enhancedModel = device.model || 'Unknown'
+    device.enhancedImage120 = '/static/app/devices/icon/x120/' + (device.image || '_default.jpg')
+    device.enhancedImage24 = '/static/app/devices/icon/x24/' + (device.image || '_default.jpg')
+    device.enhancedStateAction = $filter('statusNameAction')(device.state)
+    device.enhancedStatePassive = $filter('statusNamePassive')(device.state)
+  }
+
+  function enhanceDeviceDetails(device) {
+    if (device.battery) {
+      device.enhancedBatteryPercentage = (device.battery.level / device.battery.scale * 100) + '%'
+      device.enhancedBatteryHealth = $filter('batteryHealth')(device.battery.health)
+      device.enhancedBatterySource = $filter('batterySource')(device.battery.source)
+      device.enhancedBatteryStatus = $filter('batteryStatus')(device.battery.status)
+      device.enhancedBatteryTemp = device.battery.temp + '°C'
+    }
+
+    if (device.owner) {
+      device.enhancedUserProfileUrl = enhanceUserProfileUrl(device.owner.email)
+      device.enhancedUserName = device.owner.name || 'No name'
+    }
+  }
+
+  function enhanceUserProfileUrl(email) {
+    var url
+    var userProfileUrl = (function() {
+      if (AppState && AppState.config && AppState.config.userProfileUrl) {
+        return AppState.config.userProfileUrl
+      }
+      return null
+    })()
+
+    if (userProfileUrl) {
+      // Using RFC 6570 URI Template specification
+      if (userProfileUrl && email) {
+        url = userProfileUrl.indexOf('{user}') !== -1 ?
+          userProfileUrl.replace('{user}', email) :
+          userProfileUrl + email
+      }
+    } else if (email.indexOf('@') !== -1) {
+      url = 'mailto:' + email
+    } else {
+      url = '/!#/user/' + email
+    }
+    return url
+  }
+
+  service.enhance = function(device) {
+    setState(device)
+    enhanceDevice(device)
+    enhanceDeviceDetails(device)
+  }
+
+  return service
+}
diff --git a/crowdstf/res/app/components/stf/device/enhance-device/index.js b/crowdstf/res/app/components/stf/device/enhance-device/index.js
new file mode 100644
index 0000000..d260e98
--- /dev/null
+++ b/crowdstf/res/app/components/stf/device/enhance-device/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf/device/enhance-device', [
+  require('stf/app-state').name
+])
+  .factory('EnhanceDeviceService', require('./enhance-device-service'))
diff --git a/crowdstf/res/app/components/stf/device/index.js b/crowdstf/res/app/components/stf/device/index.js
new file mode 100644
index 0000000..01abb6b
--- /dev/null
+++ b/crowdstf/res/app/components/stf/device/index.js
@@ -0,0 +1,6 @@
+module.exports = angular.module('stf/device', [
+  require('./device-info-filter').name,
+  require('./enhance-device').name
+])
+  .factory('DeviceService', require('./device-service'))
+  .factory('StateClassesService', require('./state-classes-service'))
diff --git a/crowdstf/res/app/components/stf/device/state-classes-service.js b/crowdstf/res/app/components/stf/device/state-classes-service.js
new file mode 100644
index 0000000..dadf072
--- /dev/null
+++ b/crowdstf/res/app/components/stf/device/state-classes-service.js
@@ -0,0 +1,40 @@
+module.exports = function StateClassesService() {
+  var service = {}
+
+  service.stateButton = function(state) {
+    var stateClasses = {
+      using: 'state-using btn-primary',
+      busy: 'state-busy btn-warning',
+      available: 'state-available btn-primary-outline',
+      ready: 'state-ready btn-primary-outline',
+      present: 'state-present btn-primary-outline',
+      preparing: 'state-preparing btn-primary-outline btn-success-outline',
+      unauthorized: 'state-unauthorized btn-danger-outline',
+      offline: 'state-offline btn-warning-outline'
+    }[state]
+    if (typeof stateClasses === 'undefined') {
+      stateClasses = 'btn-default-outline'
+    }
+    return stateClasses
+  }
+
+  service.stateColor = function(state) {
+    var stateClasses = {
+      using: 'state-using',
+      busy: 'state-busy',
+      available: 'state-available',
+      ready: 'state-ready',
+      present: 'state-present',
+      preparing: 'state-preparing',
+      unauthorized: 'state-unauthorized',
+      offline: 'state-offline'
+    }[state]
+    if (typeof stateClasses === 'undefined') {
+      stateClasses = ''
+    }
+    return stateClasses
+  }
+
+  return service
+}
+
diff --git a/crowdstf/res/app/components/stf/filter-string/filter-string-service.js b/crowdstf/res/app/components/stf/filter-string/filter-string-service.js
new file mode 100644
index 0000000..0b4875c
--- /dev/null
+++ b/crowdstf/res/app/components/stf/filter-string/filter-string-service.js
@@ -0,0 +1,53 @@
+var _ = require('lodash')
+
+module.exports = function FilterStringServiceFactory() {
+  var service = {}
+
+  /**
+   * Filters integer
+   *
+   * @param {string} searchValue Value to search
+   * @param {string} str Value to compare
+   * @returns {boolean} true if matched
+   */
+  service.filterInteger = function(searchValue, str) {
+    var matched = true
+    matched = service.filterString(searchValue + '', str + '')
+    return matched
+  }
+
+  /**
+   * Filters string
+   *
+   * @param {string} searchValue Value to search
+   * @param {string} str Value to compare
+   * @returns {boolean} true if matched
+   */
+  service.filterString = function(searchValue, str) {
+    var matched = true
+    var searchLowerCase = searchValue.toLowerCase()
+    var searchContent = searchValue.slice(1)
+    var searchContentLowerCase = searchLowerCase.slice(1)
+    switch (searchValue.charAt(0)) {
+      case '/':
+        var lastSlash = searchContent.lastIndexOf('/')
+        if (lastSlash !== -1) {
+          var pattern = searchContent.substring(0, lastSlash)
+          var flags = searchContent.substring(lastSlash + 1)
+          var regex = new RegExp(pattern, flags)
+          matched = !_.isNull(str.match(regex))
+        } else {
+          matched = true // Regex is not complete, don't filter yet
+        }
+        break
+      case '!':
+        matched = str.toLowerCase().indexOf(searchContentLowerCase) === -1
+        break
+      default:
+        matched = str.toLowerCase().indexOf(searchLowerCase) !== -1
+    }
+    return matched
+  }
+
+  return service
+}
diff --git a/crowdstf/res/app/components/stf/filter-string/filter-string-spec.js b/crowdstf/res/app/components/stf/filter-string/filter-string-spec.js
new file mode 100644
index 0000000..b0848f9
--- /dev/null
+++ b/crowdstf/res/app/components/stf/filter-string/filter-string-spec.js
@@ -0,0 +1,11 @@
+describe('FilterStringService', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  it('should ...', inject(function() {
+
+	//expect(FilterStringService.doSomething()).toEqual('something')
+
+  }))
+
+})
diff --git a/crowdstf/res/app/components/stf/filter-string/index.js b/crowdstf/res/app/components/stf/filter-string/index.js
new file mode 100644
index 0000000..4ffca91
--- /dev/null
+++ b/crowdstf/res/app/components/stf/filter-string/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.filter-string', [
+
+])
+  .factory('FilterStringService', require('./filter-string-service'))
diff --git a/crowdstf/res/app/components/stf/image-onload/image-onload-animate-directive.js b/crowdstf/res/app/components/stf/image-onload/image-onload-animate-directive.js
new file mode 100644
index 0000000..3a36549
--- /dev/null
+++ b/crowdstf/res/app/components/stf/image-onload/image-onload-animate-directive.js
@@ -0,0 +1,16 @@
+module.exports = function imageOnloadAnimateDirective($parse, $animate) {
+  return {
+    restrict: 'A',
+    link: function(scope, element) {
+      $animate.addClass(element, 'ng-image-not-loaded')
+      element.bind('load', function() {
+        $animate.removeClass(element, 'ng-image-not-loaded')
+
+        //if(!scope.$$phase) {
+        //  scope.$digest()
+        //}
+//        console.log('image is loaded')
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/image-onload/image-onload-directive.js b/crowdstf/res/app/components/stf/image-onload/image-onload-directive.js
new file mode 100644
index 0000000..5744a95
--- /dev/null
+++ b/crowdstf/res/app/components/stf/image-onload/image-onload-directive.js
@@ -0,0 +1,11 @@
+module.exports = function imageOnloadDirective() {
+  return {
+    restrict: 'A',
+    link: function(scope, element, attrs) {
+      element.bind('load', function() {
+        scope.$eval(attrs.imageOnload)
+//        console.log('image is loaded')
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/image-onload/image-onload-spec.js b/crowdstf/res/app/components/stf/image-onload/image-onload-spec.js
new file mode 100644
index 0000000..f644938
--- /dev/null
+++ b/crowdstf/res/app/components/stf/image-onload/image-onload-spec.js
@@ -0,0 +1,23 @@
+describe('imageOnload', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div image-onload name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/image-onload/index.js b/crowdstf/res/app/components/stf/image-onload/index.js
new file mode 100644
index 0000000..d55c878
--- /dev/null
+++ b/crowdstf/res/app/components/stf/image-onload/index.js
@@ -0,0 +1,5 @@
+module.exports = angular.module('stf.image-onload', [
+
+])
+  .directive('imageOnload', require('./image-onload-directive'))
+  .directive('imageOnloadAnimate', require('./image-onload-animate-directive'))
diff --git a/crowdstf/res/app/components/stf/install/index.js b/crowdstf/res/app/components/stf/install/index.js
new file mode 100644
index 0000000..792f442
--- /dev/null
+++ b/crowdstf/res/app/components/stf/install/index.js
@@ -0,0 +1,5 @@
+module.exports = angular.module('stf.install-service', [
+  require('gettext').name
+])
+  .filter('installError', require('./install-error-filter'))
+  .factory('InstallService', require('./install-service'))
diff --git a/crowdstf/res/app/components/stf/install/install-error-filter.js b/crowdstf/res/app/components/stf/install/install-error-filter.js
new file mode 100644
index 0000000..63955fc
--- /dev/null
+++ b/crowdstf/res/app/components/stf/install/install-error-filter.js
@@ -0,0 +1,95 @@
+module.exports = function installErrorFilter(gettext) {
+  /* eslint max-len:0 */
+  return function(text) {
+    switch (text) {
+      // Our error codes.
+      case 'INSTALL_SUCCEEDED':
+        return gettext('Installation succeeded.')
+      case 'INSTALL_ERROR_UNKNOWN':
+        return gettext('Installation failed due to an unknown error.')
+      case 'INSTALL_ERROR_TIMEOUT':
+        return gettext('Installation timed out.')
+      case 'INSTALL_CANCELED_BY_USER': // Found on Xiaomi devices
+        return gettext('Installation canceled by user.')
+      // Android PackageManager error codes from [1].
+      // [1] https://github.com/android/platform_frameworks_base/blob/
+      //     master/core/java/android/content/pm/PackageManager.java
+      case 'INSTALL_FAILED_ALREADY_EXISTS':
+        return gettext('The package is already installed.')
+      case 'INSTALL_FAILED_INVALID_APK':
+        return gettext('The package archive file is invalid.')
+      case 'INSTALL_FAILED_INVALID_URI':
+        return gettext('The URI passed in is invalid.')
+      case 'INSTALL_FAILED_INSUFFICIENT_STORAGE':
+        return gettext("The package manager service found that the device didn't have enough storage space to install the app.")
+      case 'INSTALL_FAILED_DUPLICATE_PACKAGE':
+        return gettext('A package is already installed with the same name.')
+      case 'INSTALL_FAILED_NO_SHARED_USER':
+        return gettext('The requested shared user does not exist.')
+      case 'INSTALL_FAILED_UPDATE_INCOMPATIBLE':
+        return gettext("A previously installed package of the same name has a different signature than the new package (and the old package's data was not removed).")
+      case 'INSTALL_FAILED_MISSING_SHARED_LIBRARY':
+        return gettext('The new package uses a shared library that is not available.')
+      case 'INSTALL_FAILED_REPLACE_COULDNT_DELETE':
+        return gettext('The existing package could not be deleted.')
+      case 'INSTALL_FAILED_DEXOPT':
+        return gettext('The new package failed while optimizing and validating its dex files, either because there was not enough storage or the validation failed.')
+      case 'INSTALL_FAILED_OLDER_SDK':
+        return gettext('The new package failed because the current SDK version is older than that required by the package.')
+      case 'INSTALL_FAILED_CONFLICTING_PROVIDER':
+        return gettext('The new package failed because it contains a content provider with thesame authority as a provider already installed in the system.')
+      case 'INSTALL_FAILED_NEWER_SDK':
+        return gettext('The new package failed because the current SDK version is newer than that required by the package.')
+      case 'INSTALL_FAILED_TEST_ONLY':
+        return gettext('The new package failed because it has specified that it is a test-only package and the caller has not supplied the INSTALL_ALLOW_TEST flag.')
+      case 'INSTALL_FAILED_CPU_ABI_INCOMPATIBLE':
+        return gettext("The package being installed contains native code, but none that is compatible with the device's CPU_ABI.")
+      case 'INSTALL_FAILED_MISSING_FEATURE':
+        return gettext('The new package uses a feature that is not available.')
+      case 'INSTALL_FAILED_CONTAINER_ERROR':
+        return gettext("A secure container mount point couldn't be accessed on external media.")
+      case 'INSTALL_FAILED_INVALID_INSTALL_LOCATION':
+        return gettext("The new package couldn't be installed in the specified install location.")
+      case 'INSTALL_FAILED_MEDIA_UNAVAILABLE':
+        return gettext("The new package couldn't be installed in the specified install location because the media is not available.")
+      case 'INSTALL_FAILED_VERIFICATION_TIMEOUT':
+        return gettext("The new package couldn't be installed because the verification timed out.")
+      case 'INSTALL_FAILED_VERIFICATION_FAILURE':
+        return gettext("The new package couldn't be installed because the verification did not succeed.")
+      case 'INSTALL_FAILED_PACKAGE_CHANGED':
+        return gettext('The package changed from what the calling program expected.')
+      case 'INSTALL_FAILED_UID_CHANGED':
+        return gettext('The new package is assigned a different UID than it previously held.')
+      case 'INSTALL_FAILED_VERSION_DOWNGRADE':
+        return gettext('The new package has an older version code than the currently installed package.')
+      case 'INSTALL_PARSE_FAILED_NOT_APK':
+        return gettext("The parser was given a path that is not a file, or does not end with the expected '.apk' extension.")
+      case 'INSTALL_PARSE_FAILED_BAD_MANIFEST':
+        return gettext('The parser was unable to retrieve the AndroidManifest.xml file.')
+      case 'INSTALL_PARSE_FAILED_UNEXPECTED_EXCEPTION':
+        return gettext('The parser encountered an unexpected exception.')
+      case 'INSTALL_PARSE_FAILED_NO_CERTIFICATES':
+        return gettext('The parser did not find any certificates in the .apk.')
+      case 'INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES':
+        return gettext('The parser found inconsistent certificates on the files in the .apk.')
+      case 'INSTALL_PARSE_FAILED_CERTIFICATE_ENCODING':
+        return gettext('The parser encountered a CertificateEncodingException in one of the files in the .apk.')
+      case 'INSTALL_PARSE_FAILED_BAD_PACKAGE_NAME':
+        return gettext('The parser encountered a bad or missing package name in the manifest.')
+      case 'INSTALL_PARSE_FAILED_BAD_SHARED_USER_ID':
+        return gettext('The parser encountered a bad shared user id name in the manifest.')
+      case 'INSTALL_PARSE_FAILED_MANIFEST_MALFORMED':
+        return gettext('The parser encountered some structural problem in the manifest.')
+      case 'INSTALL_PARSE_FAILED_MANIFEST_EMPTY':
+        return gettext('The parser did not find any actionable tags (instrumentation or application) in the manifest.')
+      case 'INSTALL_FAILED_INTERNAL_ERROR':
+        return gettext('The system failed to install the package because of system issues.')
+      case 'INSTALL_FAILED_USER_RESTRICTED':
+        return gettext('The system failed to install the package because the user is restricted from installing apps.')
+      case 'INSTALL_FAILED_NO_MATCHING_ABIS':
+        return gettext('The system failed to install the package because its packaged native code did not match any of the ABIs supported by the system.')
+      default:
+        return gettext(text)
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/install/install-service.js b/crowdstf/res/app/components/stf/install/install-service.js
new file mode 100644
index 0000000..cd9902f
--- /dev/null
+++ b/crowdstf/res/app/components/stf/install/install-service.js
@@ -0,0 +1,133 @@
+var EventEmitter = require('eventemitter3')
+var Promise = require('bluebird')
+Promise.longStackTraces()
+
+module.exports = function InstallService(
+  $rootScope
+, $http
+, $filter
+, StorageService
+) {
+  var installService = Object.create(null)
+
+  function Installation(state) {
+    this.progress = 0
+    this.state = state
+    this.settled = false
+    this.success = false
+    this.error = null
+    this.href = null
+    this.manifest = null
+    this.launch = true
+  }
+
+  Installation.prototype = Object.create(EventEmitter.prototype)
+  Installation.prototype.constructor = Installation
+
+  Installation.prototype.apply = function($scope) {
+    function changeListener() {
+      $scope.safeApply()
+    }
+
+    this.on('change', changeListener)
+
+    $scope.$on('$destroy', function() {
+      this.removeListener('change', changeListener)
+    }.bind(this))
+
+    return this
+  }
+
+  Installation.prototype.update = function(progress, state) {
+    this.progress = Math.floor(progress)
+    this.state = state
+    this.emit('change')
+  }
+
+  Installation.prototype.okay = function(state) {
+    this.settled = true
+    this.progress = 100
+    this.success = true
+    this.state = state
+    this.emit('change')
+  }
+
+  Installation.prototype.fail = function(err) {
+    this.settled = true
+    this.progress = 100
+    this.success = false
+    this.error = err
+    this.emit('change')
+  }
+
+  installService.installUrl = function(control, url) {
+    var installation = new Installation('downloading')
+    $rootScope.$broadcast('installation', installation)
+    return control.uploadUrl(url)
+      .progressed(function(uploadResult) {
+        installation.update(uploadResult.progress / 2, uploadResult.lastData)
+      })
+      .then(function(uploadResult) {
+        installation.update(uploadResult.progress / 2, uploadResult.lastData)
+        installation.manifest = uploadResult.body
+        return control.install({
+            href: installation.href
+          , manifest: installation.manifest
+          , launch: installation.launch
+          })
+          .progressed(function(result) {
+            installation.update(50 + result.progress / 2, result.lastData)
+          })
+      })
+      .then(function() {
+        installation.okay('installed')
+      })
+      .catch(function(err) {
+        installation.fail(err.code || err.message)
+      })
+  }
+
+  installService.installFile = function(control, $files) {
+    var installation = new Installation('uploading')
+    $rootScope.$broadcast('installation', installation)
+    return StorageService.storeFile('apk', $files, {
+        filter: function(file) {
+          return /\.apk$/i.test(file.name)
+        }
+      })
+      .progressed(function(e) {
+        if (e.lengthComputable) {
+          installation.update(e.loaded / e.total * 100 / 2, 'uploading')
+        }
+      })
+      .then(function(res) {
+        installation.update(100 / 2, 'processing')
+        installation.href = res.data.resources.file.href
+        return $http.get(installation.href + '/manifest')
+          .then(function(res) {
+            if (res.data.success) {
+              installation.manifest = res.data.manifest
+              return control.install({
+                  href: installation.href
+                , manifest: installation.manifest
+                , launch: installation.launch
+                })
+                .progressed(function(result) {
+                  installation.update(50 + result.progress / 2, result.lastData)
+                })
+            }
+            else {
+              throw new Error('Unable to retrieve manifest')
+            }
+          })
+      })
+      .then(function() {
+        installation.okay('installed')
+      })
+      .catch(function(err) {
+        installation.fail(err.code || err.message)
+      })
+  }
+
+  return installService
+}
diff --git a/crowdstf/res/app/components/stf/install/install-spec.js b/crowdstf/res/app/components/stf/install/install-spec.js
new file mode 100644
index 0000000..704432e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/install/install-spec.js
@@ -0,0 +1,13 @@
+describe('install', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+	it('should ...', inject(function() {
+
+    //var filter = $filter('installError')
+
+		//expect(filter('input')).toEqual('output')
+
+	}))
+
+})
diff --git a/crowdstf/res/app/components/stf/keycodes/android/index.json b/crowdstf/res/app/components/stf/keycodes/android/index.json
new file mode 100644
index 0000000..26011c0
--- /dev/null
+++ b/crowdstf/res/app/components/stf/keycodes/android/index.json
@@ -0,0 +1,222 @@
+{
+  "KEYCODE_0": 7,
+  "KEYCODE_1": 8,
+  "KEYCODE_2": 9,
+  "KEYCODE_3": 10,
+  "KEYCODE_3D_MODE": 206,
+  "KEYCODE_4": 11,
+  "KEYCODE_5": 12,
+  "KEYCODE_6": 13,
+  "KEYCODE_7": 14,
+  "KEYCODE_8": 15,
+  "KEYCODE_9": 16,
+  "KEYCODE_A": 29,
+  "KEYCODE_ALT_LEFT": 57,
+  "KEYCODE_ALT_RIGHT": 58,
+  "KEYCODE_APOSTROPHE": 75,
+  "KEYCODE_APP_SWITCH": 187,
+  "KEYCODE_ASSIST": 219,
+  "KEYCODE_AT": 77,
+  "KEYCODE_AVR_INPUT": 182,
+  "KEYCODE_AVR_POWER": 181,
+  "KEYCODE_B": 30,
+  "KEYCODE_BACK": 4,
+  "KEYCODE_BACKSLASH": 73,
+  "KEYCODE_BOOKMARK": 174,
+  "KEYCODE_BREAK": 121,
+  "KEYCODE_BUTTON_1": 188,
+  "KEYCODE_BUTTON_10": 197,
+  "KEYCODE_BUTTON_11": 198,
+  "KEYCODE_BUTTON_12": 199,
+  "KEYCODE_BUTTON_13": 200,
+  "KEYCODE_BUTTON_14": 201,
+  "KEYCODE_BUTTON_15": 202,
+  "KEYCODE_BUTTON_16": 203,
+  "KEYCODE_BUTTON_2": 189,
+  "KEYCODE_BUTTON_3": 190,
+  "KEYCODE_BUTTON_4": 191,
+  "KEYCODE_BUTTON_5": 192,
+  "KEYCODE_BUTTON_6": 193,
+  "KEYCODE_BUTTON_7": 194,
+  "KEYCODE_BUTTON_8": 195,
+  "KEYCODE_BUTTON_9": 196,
+  "KEYCODE_BUTTON_A": 96,
+  "KEYCODE_BUTTON_B": 97,
+  "KEYCODE_BUTTON_C": 98,
+  "KEYCODE_BUTTON_L1": 102,
+  "KEYCODE_BUTTON_L2": 104,
+  "KEYCODE_BUTTON_MODE": 110,
+  "KEYCODE_BUTTON_R1": 103,
+  "KEYCODE_BUTTON_R2": 105,
+  "KEYCODE_BUTTON_SELECT": 109,
+  "KEYCODE_BUTTON_START": 108,
+  "KEYCODE_BUTTON_THUMBL": 106,
+  "KEYCODE_BUTTON_THUMBR": 107,
+  "KEYCODE_BUTTON_X": 99,
+  "KEYCODE_BUTTON_Y": 100,
+  "KEYCODE_BUTTON_Z": 101,
+  "KEYCODE_C": 31,
+  "KEYCODE_CALCULATOR": 210,
+  "KEYCODE_CALENDAR": 208,
+  "KEYCODE_CALL": 5,
+  "KEYCODE_CAMERA": 27,
+  "KEYCODE_CAPS_LOCK": 115,
+  "KEYCODE_CAPTIONS": 175,
+  "KEYCODE_CHANNEL_DOWN": 167,
+  "KEYCODE_CHANNEL_UP": 166,
+  "KEYCODE_CLEAR": 28,
+  "KEYCODE_COMMA": 55,
+  "KEYCODE_CONTACTS": 207,
+  "KEYCODE_CTRL_LEFT": 113,
+  "KEYCODE_CTRL_RIGHT": 114,
+  "KEYCODE_D": 32,
+  "KEYCODE_DEL": 67,
+  "KEYCODE_DPAD_CENTER": 23,
+  "KEYCODE_DPAD_DOWN": 20,
+  "KEYCODE_DPAD_LEFT": 21,
+  "KEYCODE_DPAD_RIGHT": 22,
+  "KEYCODE_DPAD_UP": 19,
+  "KEYCODE_DVR": 173,
+  "KEYCODE_E": 33,
+  "KEYCODE_EISU": 212,
+  "KEYCODE_ENDCALL": 6,
+  "KEYCODE_ENTER": 66,
+  "KEYCODE_ENVELOPE": 65,
+  "KEYCODE_EQUALS": 70,
+  "KEYCODE_ESCAPE": 111,
+  "KEYCODE_EXPLORER": 64,
+  "KEYCODE_F": 34,
+  "KEYCODE_F1": 131,
+  "KEYCODE_F10": 140,
+  "KEYCODE_F11": 141,
+  "KEYCODE_F12": 142,
+  "KEYCODE_F2": 132,
+  "KEYCODE_F3": 133,
+  "KEYCODE_F4": 134,
+  "KEYCODE_F5": 135,
+  "KEYCODE_F6": 136,
+  "KEYCODE_F7": 137,
+  "KEYCODE_F8": 138,
+  "KEYCODE_F9": 139,
+  "KEYCODE_FOCUS": 80,
+  "KEYCODE_FORWARD": 125,
+  "KEYCODE_FORWARD_DEL": 112,
+  "KEYCODE_FUNCTION": 119,
+  "KEYCODE_G": 35,
+  "KEYCODE_GRAVE": 68,
+  "KEYCODE_GUIDE": 172,
+  "KEYCODE_H": 36,
+  "KEYCODE_HEADSETHOOK": 79,
+  "KEYCODE_HENKAN": 214,
+  "KEYCODE_HOME": 3,
+  "KEYCODE_I": 37,
+  "KEYCODE_INFO": 165,
+  "KEYCODE_INSERT": 124,
+  "KEYCODE_J": 38,
+  "KEYCODE_K": 39,
+  "KEYCODE_KANA": 218,
+  "KEYCODE_KATAKANA_HIRAGANA": 215,
+  "KEYCODE_L": 40,
+  "KEYCODE_LANGUAGE_SWITCH": 204,
+  "KEYCODE_LEFT_BRACKET": 71,
+  "KEYCODE_M": 41,
+  "KEYCODE_MANNER_MODE": 205,
+  "KEYCODE_MEDIA_CLOSE": 128,
+  "KEYCODE_MEDIA_EJECT": 129,
+  "KEYCODE_MEDIA_FAST_FORWARD": 90,
+  "KEYCODE_MEDIA_NEXT": 87,
+  "KEYCODE_MEDIA_PAUSE": 127,
+  "KEYCODE_MEDIA_PLAY": 126,
+  "KEYCODE_MEDIA_PLAY_PAUSE": 85,
+  "KEYCODE_MEDIA_PREVIOUS": 88,
+  "KEYCODE_MEDIA_RECORD": 130,
+  "KEYCODE_MEDIA_REWIND": 89,
+  "KEYCODE_MEDIA_STOP": 86,
+  "KEYCODE_MENU": 82,
+  "KEYCODE_META_LEFT": 117,
+  "KEYCODE_META_RIGHT": 118,
+  "KEYCODE_MINUS": 69,
+  "KEYCODE_MOVE_END": 123,
+  "KEYCODE_MOVE_HOME": 122,
+  "KEYCODE_MUHENKAN": 213,
+  "KEYCODE_MUSIC": 209,
+  "KEYCODE_MUTE": 91,
+  "KEYCODE_N": 42,
+  "KEYCODE_NOTIFICATION": 83,
+  "KEYCODE_NUM": 78,
+  "KEYCODE_NUMPAD_0": 144,
+  "KEYCODE_NUMPAD_1": 145,
+  "KEYCODE_NUMPAD_2": 146,
+  "KEYCODE_NUMPAD_3": 147,
+  "KEYCODE_NUMPAD_4": 148,
+  "KEYCODE_NUMPAD_5": 149,
+  "KEYCODE_NUMPAD_6": 150,
+  "KEYCODE_NUMPAD_7": 151,
+  "KEYCODE_NUMPAD_8": 152,
+  "KEYCODE_NUMPAD_9": 153,
+  "KEYCODE_NUMPAD_ADD": 157,
+  "KEYCODE_NUMPAD_COMMA": 159,
+  "KEYCODE_NUMPAD_DIVIDE": 154,
+  "KEYCODE_NUMPAD_DOT": 158,
+  "KEYCODE_NUMPAD_ENTER": 160,
+  "KEYCODE_NUMPAD_EQUALS": 161,
+  "KEYCODE_NUMPAD_LEFT_PAREN": 162,
+  "KEYCODE_NUMPAD_MULTIPLY": 155,
+  "KEYCODE_NUMPAD_RIGHT_PAREN": 163,
+  "KEYCODE_NUMPAD_SUBTRACT": 156,
+  "KEYCODE_NUM_LOCK": 143,
+  "KEYCODE_O": 43,
+  "KEYCODE_P": 44,
+  "KEYCODE_PAGE_DOWN": 93,
+  "KEYCODE_PAGE_UP": 92,
+  "KEYCODE_PERIOD": 56,
+  "KEYCODE_PICTSYMBOLS": 94,
+  "KEYCODE_PLUS": 81,
+  "KEYCODE_POUND": 18,
+  "KEYCODE_POWER": 26,
+  "KEYCODE_PROG_BLUE": 186,
+  "KEYCODE_PROG_GREEN": 184,
+  "KEYCODE_PROG_RED": 183,
+  "KEYCODE_PROG_YELLOW": 185,
+  "KEYCODE_Q": 45,
+  "KEYCODE_R": 46,
+  "KEYCODE_RIGHT_BRACKET": 72,
+  "KEYCODE_RO": 217,
+  "KEYCODE_S": 47,
+  "KEYCODE_SCROLL_LOCK": 116,
+  "KEYCODE_SEARCH": 84,
+  "KEYCODE_SEMICOLON": 74,
+  "KEYCODE_SETTINGS": 176,
+  "KEYCODE_SHIFT_LEFT": 59,
+  "KEYCODE_SHIFT_RIGHT": 60,
+  "KEYCODE_SLASH": 76,
+  "KEYCODE_SOFT_LEFT": 1,
+  "KEYCODE_SOFT_RIGHT": 2,
+  "KEYCODE_SPACE": 62,
+  "KEYCODE_STAR": 17,
+  "KEYCODE_STB_INPUT": 180,
+  "KEYCODE_STB_POWER": 179,
+  "KEYCODE_SWITCH_CHARSET": 95,
+  "KEYCODE_SYM": 63,
+  "KEYCODE_SYSRQ": 120,
+  "KEYCODE_T": 48,
+  "KEYCODE_TAB": 61,
+  "KEYCODE_TV": 170,
+  "KEYCODE_TV_INPUT": 178,
+  "KEYCODE_TV_POWER": 177,
+  "KEYCODE_U": 49,
+  "KEYCODE_UNKNOWN": 0,
+  "KEYCODE_V": 50,
+  "KEYCODE_VOLUME_DOWN": 25,
+  "KEYCODE_VOLUME_MUTE": 164,
+  "KEYCODE_VOLUME_UP": 24,
+  "KEYCODE_W": 51,
+  "KEYCODE_WINDOW": 171,
+  "KEYCODE_X": 52,
+  "KEYCODE_Y": 53,
+  "KEYCODE_YEN": 216,
+  "KEYCODE_Z": 54,
+  "KEYCODE_ZENKAKU_HANKAKU": 211,
+  "KEYCODE_ZOOM_IN": 168,
+  "KEYCODE_ZOOM_OUT": 169
+}
diff --git a/crowdstf/res/app/components/stf/keycodes/index.js b/crowdstf/res/app/components/stf/keycodes/index.js
new file mode 100644
index 0000000..44c2c23
--- /dev/null
+++ b/crowdstf/res/app/components/stf/keycodes/index.js
@@ -0,0 +1,16 @@
+module.exports = angular.module('stf.keycodes', [
+
+])
+  .factory('KeycodesMapped', function() {
+    return require('./mapped/index.json')
+  })
+
+// Not used for now:
+//
+//  .factory('KeycodesAndroid', function () {
+//    return require('./android/index.json')
+//  })
+//  .factory('KeycodesJS', function () {
+//    return require('./android/index.json')
+//  })
+//  .factory('KeycodesService', require('./keycodes-service'))
diff --git a/crowdstf/res/app/components/stf/keycodes/js/index.json b/crowdstf/res/app/components/stf/keycodes/js/index.json
new file mode 100644
index 0000000..e08b497
--- /dev/null
+++ b/crowdstf/res/app/components/stf/keycodes/js/index.json
@@ -0,0 +1,103 @@
+{
+  "0": 48,
+  "1": 49,
+  "2": 50,
+  "3": 51,
+  "4": 52,
+  "5": 53,
+  "6": 54,
+  "7": 55,
+  "8": 56,
+  "9": 57,
+  "A": 65,
+  "ADD": 107,
+  "ALT": 18,
+  "ALT_RIGHT": 17,
+  "B": 66,
+  "BACKSLASH": 220,
+  "BACKSPACE": 8,
+  "C": 67,
+  "CAPS_LOCK": 20,
+  "CLOSE_BRACKET": 221,
+  "COMMA": 188,
+  "CTRL": 17,
+  "CTRL_RIGHT": 18,
+  "D": 68,
+  "DASH": 189,
+  "DECIMAL_POINT": 110,
+  "DELETE": 46,
+  "DIVIDE": 111,
+  "DOWN": 40,
+  "E": 69,
+  "END": 35,
+  "ENTER": 13,
+  "EQUAL_SIGN": 187,
+  "ESCAPE": 27,
+  "F": 70,
+  "F1": 112,
+  "F10": 121,
+  "F11": 122,
+  "F12": 123,
+  "F2": 113,
+  "F3": 114,
+  "F4": 115,
+  "F5": 116,
+  "F6": 117,
+  "F7": 118,
+  "F8": 119,
+  "F9": 120,
+  "G": 71,
+  "GRAVE_ACCENT": 192,
+  "H": 72,
+  "HOME": 36,
+  "I": 73,
+  "INSERT": 45,
+  "J": 74,
+  "K": 75,
+  "L": 76,
+  "LEFT": 37,
+  "LEFT_WINDOW": 91,
+  "M": 77,
+  "MULTIPLY": 106,
+  "N": 78,
+  "NUMPAD_0": 96,
+  "NUMPAD_1": 97,
+  "NUMPAD_2": 98,
+  "NUMPAD_3": 99,
+  "NUMPAD_4": 100,
+  "NUMPAD_5": 101,
+  "NUMPAD_6": 102,
+  "NUMPAD_7": 103,
+  "NUMPAD_8": 104,
+  "NUMPAD_9": 105,
+  "NUM_LOCK": 144,
+  "O": 79,
+  "OPEN_BRACKET": 219,
+  "P": 80,
+  "PAGE_DOWN": 34,
+  "PAGE_UP": 33,
+  "PAUSE_BREAK": 19,
+  "PERIOD": 190,
+  "Q": 81,
+  "R": 82,
+  "RIGHT": 39,
+  "RIGHT_WINDOW": 92,
+  "S": 83,
+  "SCROLL_LOCK": 145,
+  "SELECT_KEY": 93,
+  "SEMICOLON": 186,
+  "SHIFT": 16,
+  "SINGLE_QUOTE": 222,
+  "SLASH": 191,
+  "SPACE": 32,
+  "SUBTRACT": 109,
+  "T": 84,
+  "TAB": 9,
+  "U": 85,
+  "UP": 38,
+  "V": 86,
+  "W": 87,
+  "X": 88,
+  "Y": 89,
+  "Z": 90
+}
diff --git a/crowdstf/res/app/components/stf/keycodes/keycodes-service.js b/crowdstf/res/app/components/stf/keycodes/keycodes-service.js
new file mode 100644
index 0000000..e8af718
--- /dev/null
+++ b/crowdstf/res/app/components/stf/keycodes/keycodes-service.js
@@ -0,0 +1,83 @@
+module.exports = function KeycodesServiceFactory(KeycodesAndroid, KeycodesJS) {
+  var service = {}
+
+  var a = KeycodesAndroid
+  var j = KeycodesJS
+  var androidMap = [
+    [j.ENTER, a.KEYCODE_ENTER],
+    [j.SPACE, a.KEYCODE_SPACE],
+    [j.DELETE, a.KEYCODE_DEL],
+    [j.ESCAPE, a.KEYCODE_ESCAPE],
+    [j.BACKSPACE, a.KEYCODE_DEL],
+    [j.TAB, a.KEYCODE_TAB],
+    [j.SHIFT, a.KEYCODE_SHIFT_LEFT],
+    [j.CAPS_LOCK, a.KEYCODE_CAPS_LOCK],
+    [j.SLASH, a.KEYCODE_SLASH],
+    [j.BACKSLASH, a.KEYCODE_BACKSLASH],
+    [j.COMMA, a.KEYCODE_COMMA],
+    [j.PERIOD, a.KEYCODE_PERIOD],
+    [j.SEMICOLON, a.KEYCODE_SEMICOLON],
+    [j.PAGE_UP, a.KEYCODE_PAGE_UP],
+    [j.PAGE_DOWN, a.KEYCODE_PAGE_DOWN],
+    //  [j.LEFT_WINDOW, a.KEYCODE_RO],
+    //  [j.SELECT_KEY, a.KEYCODE_KANA],
+    [j.HOME, a.KEYCODE_MOVE_HOME],
+    [j.END, a.KEYCODE_MOVE_END],
+    [j.UP, a.KEYCODE_DPAD_UP],
+    [j.DOWN, a.KEYCODE_DPAD_DOWN],
+    [j.LEFT, a.KEYCODE_DPAD_LEFT],
+    [j.RIGHT, a.KEYCODE_DPAD_RIGHT],
+    [j.F3, a.KEYCODE_POWER],
+    [j.F7, a.KEYCODE_MEDIA_PREVIOUS],
+    [j.F8, a.KEYCODE_MEDIA_PLAY_PAUSE],
+    [j.F9, a.KEYCODE_MEDIA_NEXT],
+    [j.F10, a.KEYCODE_VOLUME_MUTE],
+    [j.F11, a.KEYCODE_VOLUME_DOWN],
+    [j.F12, a.KEYCODE_VOLUME_UP],
+    [j.NUMPAD_0, a.KEYCODE_NUMPAD_0],
+    [j.NUMPAD_1, a.KEYCODE_NUMPAD_1],
+    [j.NUMPAD_2, a.KEYCODE_NUMPAD_2],
+    [j.NUMPAD_3, a.KEYCODE_NUMPAD_3],
+    [j.NUMPAD_4, a.KEYCODE_NUMPAD_4],
+    [j.NUMPAD_5, a.KEYCODE_NUMPAD_5],
+    [j.NUMPAD_6, a.KEYCODE_NUMPAD_6],
+    [j.NUMPAD_7, a.KEYCODE_NUMPAD_7],
+    [j.NUMPAD_8, a.KEYCODE_NUMPAD_8],
+    [j.NUMPAD_9, a.KEYCODE_NUMPAD_9],
+    [j.MULTIPLY, a.KEYCODE_NUMPAD_MULTIPLY],
+    [j.ADD, a.KEYCODE_NUMPAD_ADD],
+    [j.SUBTRACT, a.KEYCODE_NUMPAD_SUBTRACT],
+    [j.DECIMAL_POINT, a.KEYCODE_NUMPAD_DOT],
+    [j.DIVIDE, a.KEYCODE_NUMPAD_DIVIDE],
+    [j.EQUAL_SIGN, a.KEYCODE_EQUALS],
+    [j.DASH, a.KEYCODE_MINUS],
+    [j.GRAVE_ACCENT, a.KEYCODE_GRAVE],
+    [j.OPEN_BRACKET, a.KEYCODE_LEFT_BRACKET],
+    [j.CLOSE_BRACKET, a.KEYCODE_RIGHT_BRACKET],
+    [j.SINGLE_QUOTE, a.KEYCODE_APOSTROPHE]
+  ]
+
+  service.mapToDevice = function(keyCode) {
+    return service.mapToAndroid(keyCode)
+  }
+
+  service.mapToAndroid = function(key) {
+    // All special keys
+    for (var i = 0; i < androidMap.length; ++i) {
+      if (androidMap[i][0] === key) {
+        return androidMap[i][1]
+      }
+    }
+    // Range of numbers and letters
+    if (key >= j['0'] && key <= j['9']) {
+      return key - 41 // 0-9 range
+    }
+    else if (key >= j.A && key <= j.Z) {
+      return key - 36 // a-z range
+    }
+    // Key not mapped
+    return -1
+  }
+
+  return service
+}
diff --git a/crowdstf/res/app/components/stf/keycodes/keycodes-spec.js b/crowdstf/res/app/components/stf/keycodes/keycodes-spec.js
new file mode 100644
index 0000000..d76fe4c
--- /dev/null
+++ b/crowdstf/res/app/components/stf/keycodes/keycodes-spec.js
@@ -0,0 +1,11 @@
+//describe('KeycodesService', function() {
+//
+//  beforeEach(angular.mock.module(require('./').name));
+//
+//  it('should ...', inject(function(KeycodesService) {
+//
+//	//expect(KeycodesService.doSomething()).toEqual('something');
+//
+//  }));
+//
+//})
diff --git a/crowdstf/res/app/components/stf/keycodes/mapped/index.json b/crowdstf/res/app/components/stf/keycodes/mapped/index.json
new file mode 100644
index 0000000..20c288c
--- /dev/null
+++ b/crowdstf/res/app/components/stf/keycodes/mapped/index.json
@@ -0,0 +1,31 @@
+{
+  "8": "del",
+  "9": "tab",
+  "13": "enter",
+  "20": "caps_lock",
+  "27": "escape",
+  "33": "page_up",
+  "34": "page_down",
+  "35": "move_end",
+  "36": "move_home",
+  "37": "dpad_left",
+  "38": "dpad_up",
+  "39": "dpad_right",
+  "40": "dpad_down",
+  "45": "insert",
+  "46": "forward_del",
+  "93": "menu",
+  "112": "f1",
+  "113": "f2",
+  "114": "f3",
+  "115": "f4",
+  "116": "f5",
+  "117": "f6",
+  "118": "f7",
+  "119": "f8",
+  "120": "f9",
+  "121": "f10",
+  "122": "f11",
+  "123": "f12",
+  "144": "num_lock"
+}
diff --git a/crowdstf/res/app/components/stf/keys/add-adb-key/adb-keys-service.js b/crowdstf/res/app/components/stf/keys/add-adb-key/adb-keys-service.js
new file mode 100644
index 0000000..8d93b69
--- /dev/null
+++ b/crowdstf/res/app/components/stf/keys/add-adb-key/adb-keys-service.js
@@ -0,0 +1,12 @@
+module.exports = function AdbKeysServiceFactory() {
+  var service = {}
+
+  service.commentFromKey = function(key) {
+    if (key.match(/.+= (.+)/)) {
+      return key.replace(/.+= (.+)/g, '$1')
+    }
+    return ''
+  }
+
+  return service
+}
diff --git a/crowdstf/res/app/components/stf/keys/add-adb-key/add-adb-key-directive.js b/crowdstf/res/app/components/stf/keys/add-adb-key/add-adb-key-directive.js
new file mode 100644
index 0000000..8f96879
--- /dev/null
+++ b/crowdstf/res/app/components/stf/keys/add-adb-key/add-adb-key-directive.js
@@ -0,0 +1,42 @@
+module.exports = function addAdbKeyDirective(AdbKeysService) {
+  return {
+    restrict: 'EA',
+    replace: true,
+    scope: {
+      showAdd: '=',
+      showClipboard: '='
+    },
+    template: require('./add-adb-key.jade'),
+    controller: function($scope, UserService) {
+      $scope.addForm = {
+        title: ''
+      , key: ''
+      }
+
+      $scope.addKey = function() {
+        UserService.addAdbKey({
+          title: $scope.addForm.title
+        , key: $scope.addForm.key
+        })
+        $scope.closeAddKey()
+      }
+
+      $scope.closeAddKey = function() {
+        $scope.addForm.title = ''
+        $scope.addForm.key = ''
+        // TODO: cannot access to the form by name inside a directive?
+        //$scope.adbkeyform.$setPristine()
+        $scope.showAdd = false
+      }
+    },
+    link: function(scope) {
+      scope.$watch('addForm.key', function(newValue) {
+        if (newValue && !scope.addForm.title) {
+          // By default sets the title to the ADB key comment because
+          // usually it happens to be username@hostname.
+          scope.addForm.title = AdbKeysService.commentFromKey(newValue)
+        }
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/keys/add-adb-key/add-adb-key-spec.js b/crowdstf/res/app/components/stf/keys/add-adb-key/add-adb-key-spec.js
new file mode 100644
index 0000000..03957f8
--- /dev/null
+++ b/crowdstf/res/app/components/stf/keys/add-adb-key/add-adb-key-spec.js
@@ -0,0 +1,23 @@
+describe('addAdbKey', function() {
+
+  beforeEach(angular.mock.module(require('./index').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div add-adb-key name="name">hi</div>')(scope)
+     expect(element.text()).toBe('hello, world')
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/keys/add-adb-key/add-adb-key.css b/crowdstf/res/app/components/stf/keys/add-adb-key/add-adb-key.css
new file mode 100644
index 0000000..711586e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/keys/add-adb-key/add-adb-key.css
@@ -0,0 +1,3 @@
+.stf-add-adb-key {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/components/stf/keys/add-adb-key/add-adb-key.jade b/crowdstf/res/app/components/stf/keys/add-adb-key/add-adb-key.jade
new file mode 100644
index 0000000..efbc9b5
--- /dev/null
+++ b/crowdstf/res/app/components/stf/keys/add-adb-key/add-adb-key.jade
@@ -0,0 +1,39 @@
+.panel.panel-default.stf-add-adb-key(ng-show='showAdd')
+  .panel-heading
+    h3.panel-title(translate) Add ADB Key
+  .panel-body
+    form.form-horizontal(name='adbkeyform', ng-submit='addKey(key)')
+
+      div(ng-show='showClipboard')
+
+        .alert.alert-info.selectable
+          strong(translate) Tip:
+          span &nbsp;
+          span(translate) Run this command to copy the key to your clipboard
+          textarea(readonly, rows='1', text-focus-select, ng-copy='focusAddKey = true'
+          ).form-control.remote-debug-textarea pbcopy < ~/.android/adbkey.pub
+
+      br
+
+      .form-group
+        label.control-label(for='adb-device-key')
+          i.fa.fa-key.fa-fw
+          span(translate) Key
+
+        textarea(id='adb-device-key', rows='4', name='deviceKey', ng-model='addForm.key', ng-required='true',
+        autocorrect='off', autocapitalize='off', spellcheck='false',
+        focus-element='focusAddKey', ng-paste='focusAddTitle = true').form-control
+
+      .form-group
+        label.control-label(for='adb-device-title')
+          i.fa.fa-laptop.fa-fw
+          span(translate)  Device
+
+        input(id='adb-device-title', type='text', name='deviceTitle', ng-model='addForm.title', ng-required='true',
+        text-focus-select, focus-element='focusAddTitle').form-control
+
+      button.btn.btn-primary-outline.btn-sm.pull-right(type='submit')
+        i.fa.fa-plus.fa-fw
+        span(translate) Add Key
+
+    error-message(message='{{error}}')
diff --git a/crowdstf/res/app/components/stf/keys/add-adb-key/index.js b/crowdstf/res/app/components/stf/keys/add-adb-key/index.js
new file mode 100644
index 0000000..2865372
--- /dev/null
+++ b/crowdstf/res/app/components/stf/keys/add-adb-key/index.js
@@ -0,0 +1,9 @@
+require('./add-adb-key.css')
+
+module.exports = angular.module('stf.add-adb-key', [
+  require('gettext').name,
+  require('stf/common-ui').name
+])
+  .directive('addAdbKey', require('./add-adb-key-directive'))
+  .factory('AdbKeysService', require('./adb-keys-service'))
+
diff --git a/crowdstf/res/app/components/stf/keys/index.js b/crowdstf/res/app/components/stf/keys/index.js
new file mode 100644
index 0000000..54d4f48
--- /dev/null
+++ b/crowdstf/res/app/components/stf/keys/index.js
@@ -0,0 +1,3 @@
+module.exports = angular.module('stf.keys', [
+  require('./add-adb-key').name
+])
diff --git a/crowdstf/res/app/components/stf/landscape/index.js b/crowdstf/res/app/components/stf/landscape/index.js
new file mode 100644
index 0000000..6420d66
--- /dev/null
+++ b/crowdstf/res/app/components/stf/landscape/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.landscape', [
+  require('stf/browser-info').name
+])
+  .directive('landscape', require('./landscape-directive'))
diff --git a/crowdstf/res/app/components/stf/landscape/landscape-directive.js b/crowdstf/res/app/components/stf/landscape/landscape-directive.js
new file mode 100644
index 0000000..2ecf9de
--- /dev/null
+++ b/crowdstf/res/app/components/stf/landscape/landscape-directive.js
@@ -0,0 +1,50 @@
+module.exports =
+  function landscapeDirective(BrowserInfo, $document, $window) {
+    return {
+      restrict: 'A',
+      link: function(scope) {
+        var body = angular.element($document[0].body)
+
+        if (typeof $window.orientation !== 'undefined') {
+          if ($window.orientation !== 0) {
+            rotateGuest(false)
+          }
+        }
+
+        function rotateGuest(portrait) {
+          if (portrait) {
+            body.addClass('guest-portrait')
+            body.removeClass('guest-landscape')
+
+            scope.$broadcast('guest-portrait')
+          } else {
+            body.addClass('guest-landscape')
+            body.removeClass('guest-portrait')
+
+            scope.$broadcast('guest-landscape')
+
+            $window.scrollTo(0, 0)
+          }
+        }
+
+        function guestDisplayRotated() {
+          var isPortrait = (window.innerHeight > window.innerWidth)
+          rotateGuest(isPortrait)
+        }
+
+        if (BrowserInfo.deviceorientation) {
+          window.addEventListener('orientationchange', guestDisplayRotated,
+            true)
+        }
+
+        function off() {
+          if (BrowserInfo.deviceorientation) {
+            window.removeEventListener('orientationchange',
+              guestDisplayRotated)
+          }
+        }
+
+        scope.$on('$destroy', off)
+      }
+    }
+  }
diff --git a/crowdstf/res/app/components/stf/landscape/landscape-spec.js b/crowdstf/res/app/components/stf/landscape/landscape-spec.js
new file mode 100644
index 0000000..cf98bfe
--- /dev/null
+++ b/crowdstf/res/app/components/stf/landscape/landscape-spec.js
@@ -0,0 +1,23 @@
+describe('landscape', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div landscape name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/language/index.js b/crowdstf/res/app/components/stf/language/index.js
new file mode 100644
index 0000000..a039fbb
--- /dev/null
+++ b/crowdstf/res/app/components/stf/language/index.js
@@ -0,0 +1,7 @@
+module.exports = angular.module('stf-language', [
+  require('stf/settings').name,
+  require('gettext').name,
+  require('stf/app-state').name
+])
+  .factory('LanguageService', require('./language-service'))
+  .provider('language', require('./language-provider'))
diff --git a/crowdstf/res/app/components/stf/language/language-provider.js b/crowdstf/res/app/components/stf/language/language-provider.js
new file mode 100644
index 0000000..543ac1b
--- /dev/null
+++ b/crowdstf/res/app/components/stf/language/language-provider.js
@@ -0,0 +1,65 @@
+//var supportedLanguages = require('./../../../../common/lang/langs.json')
+
+module.exports = function LanguageProvider(AppStateProvider) {
+  var provider = {
+    selectedLanguage: 'ja' // default
+  }
+
+  var a = AppStateProvider.$get()
+  if (a && a.user && a.user.settings && a.user.settings &&
+    a.user.settings.selectedLanguage) {
+    provider.selectedLanguage = a.user.settings.selectedLanguage
+  }
+
+  return {
+    $get: function() {
+      return provider
+    }
+  }
+}
+
+//module.exports = function LanguageProvider() {
+//  var LanguageService = {}
+//
+//  function detectLanguage() {
+//    return (navigator.language || navigator.userLanguage || 'en-US')
+//      .substring(0, 2)
+//  }
+//
+//  function isSupported(lang) {
+//    return !!supportedLanguages[lang]
+//  }
+//
+//  function onlySupported(lang, defaultValue) {
+//    return isSupported(lang) ? lang : defaultValue
+//  }
+//
+//  LanguageService.settingKey = 'selectedLanguage'
+//  LanguageService.supportedLanguages = supportedLanguages
+//  LanguageService.defaultLanguage = 'en'
+//  LanguageService.detectedLanguage =
+//    onlySupported(detectLanguage(), LanguageService.defaultLanguage)
+//
+//  return {
+//    set: function (constants) {
+//      angular.extend(LanguageService, constants)
+//    },
+//    $get: function (SettingsService, gettextCatalog) {
+//      SettingsService.sync(
+//        LanguageService, {
+//          target: LanguageService.settingKey,
+//          source: LanguageService.settingKey,
+//          defaultValue: LanguageService.detectedLanguage
+//        }, updateLanguage
+//      )
+//
+//      function updateLanguage() {
+//        gettextCatalog.setCurrentLanguage(LanguageService.selectedLanguage)
+//      }
+//
+//      LanguageService.updateLanguage = updateLanguage
+//
+//      return LanguageService
+//    }
+//  }
+//}
diff --git a/crowdstf/res/app/components/stf/language/language-service.js b/crowdstf/res/app/components/stf/language/language-service.js
new file mode 100644
index 0000000..096a5ce
--- /dev/null
+++ b/crowdstf/res/app/components/stf/language/language-service.js
@@ -0,0 +1,43 @@
+var supportedLanguages = require('./../../../../common/lang/langs.json')
+
+module.exports =
+  function LanguageServiceFactory(SettingsService, gettextCatalog) {
+    // TODO: make this LanguageProvider so it can be used on config
+
+    var LanguageService = {}
+
+    function detectLanguage() {
+      return (navigator.language || navigator.userLanguage || 'en-US')
+        .substring(0, 2)
+    }
+
+    function isSupported(lang) {
+      return !!supportedLanguages[lang]
+    }
+
+    function onlySupported(lang, defaultValue) {
+      return isSupported(lang) ? lang : defaultValue
+    }
+
+    LanguageService.settingKey = 'selectedLanguage'
+    LanguageService.supportedLanguages = supportedLanguages
+    LanguageService.defaultLanguage = 'en'
+    LanguageService.detectedLanguage =
+      onlySupported(detectLanguage(), LanguageService.defaultLanguage)
+
+    SettingsService.sync(
+      LanguageService, {
+        target: LanguageService.settingKey,
+        source: LanguageService.settingKey,
+        defaultValue: LanguageService.detectedLanguage
+      }, updateLanguage
+    )
+
+    function updateLanguage() {
+      gettextCatalog.setCurrentLanguage(LanguageService.selectedLanguage)
+    }
+
+    LanguageService.updateLanguage = updateLanguage
+
+    return LanguageService
+  }
diff --git a/crowdstf/res/app/components/stf/logcat-table/index.js b/crowdstf/res/app/components/stf/logcat-table/index.js
new file mode 100644
index 0000000..191244f
--- /dev/null
+++ b/crowdstf/res/app/components/stf/logcat-table/index.js
@@ -0,0 +1,6 @@
+require('./logcat-table.css')
+
+module.exports = angular.module('stf.logcat-table', [
+
+])
+  .directive('logcatTable', require('./logcat-table-directive'))
diff --git a/crowdstf/res/app/components/stf/logcat-table/logcat-table-directive.js b/crowdstf/res/app/components/stf/logcat-table/logcat-table-directive.js
new file mode 100644
index 0000000..1675197
--- /dev/null
+++ b/crowdstf/res/app/components/stf/logcat-table/logcat-table-directive.js
@@ -0,0 +1,113 @@
+var _ = require('lodash')
+
+module.exports =
+  function logcatTableDirective($rootScope, $timeout, LogcatService) {
+    return {
+      restrict: 'E',
+      replace: true,
+      template: require('./logcat-table.jade'),
+      link: function(scope, element) {
+        var autoScroll = true
+        var autoScrollDependingOnScrollPosition = true
+        var scrollPosition = 0
+        var scrollHeight = 0
+        var parent = element[0]
+        var body = element.find('tbody')[0]
+        var maxEntriesBuffer = 3000
+        var numberOfEntries = 0
+
+        function incrementNumberEntry() {
+          numberOfEntries++
+          if (numberOfEntries > maxEntriesBuffer) {
+            scope.clearTable()
+          }
+        }
+
+        LogcatService.addEntryListener = function(entry) {
+          incrementNumberEntry()
+          addRow(body, entry)
+        }
+
+        LogcatService.addFilteredEntriesListener = function(entries) {
+          clearTable()
+          //var fragment = document.createDocumentFragment()
+          _.each(entries, function(entry) {
+            // TODO: This is not adding all the entries after first scope creation
+            incrementNumberEntry()
+            addRow(body, entry, true)
+          })
+        }
+
+        function shouldAutoScroll() {
+          if (autoScrollDependingOnScrollPosition) {
+            return scrollPosition === scrollHeight
+          } else {
+            return true
+          }
+        }
+
+        function scrollListener(event) {
+          scrollPosition = event.target.scrollTop + event.target.clientHeight
+          scrollHeight = event.target.scrollHeight
+        }
+
+        var throttledScrollListener = _.throttle(scrollListener, 100)
+        parent.addEventListener('scroll', throttledScrollListener, false)
+
+        function scrollToBottom() {
+          parent.scrollTop = parent.scrollHeight + 20
+          $timeout(function() {
+            parent.scrollTop = parent.scrollHeight
+          }, 10)
+        }
+
+        function addRow(rowParent, data, batchRequest) {
+          var newRow = rowParent.insertRow(-1)
+
+          newRow.classList.add('log-' + data.priorityLabel)
+
+          //newRow.insertCell(-1)
+          //  .appendChild(document.createTextNode(LogcatService.numberOfEntries))
+          //newRow.insertCell(-1)
+          //  .appendChild(document.createTextNode(data.deviceLabel))
+          newRow.insertCell(-1)
+            .appendChild(document.createTextNode(data.priorityLabel))
+          newRow.insertCell(-1)
+            .appendChild(document.createTextNode(data.dateLabel))
+          if ($rootScope.platform === 'native') {
+            newRow.insertCell(-1)
+              .appendChild(document.createTextNode(data.pid))
+            newRow.insertCell(-1)
+              .appendChild(document.createTextNode(data.tid))
+            //newRow.insertCell(-1)
+            //  .appendChild(document.createTextNode(data.app))
+            newRow.insertCell(-1)
+              .appendChild(document.createTextNode(data.tag))
+          }
+          newRow.insertCell(-1)
+            .appendChild(document.createTextNode(data.message))
+
+          if (autoScroll && shouldAutoScroll() && !batchRequest) {
+            _.throttle(scrollToBottom, 30)()
+          }
+        }
+
+        function clearTable() {
+          var oldBody = body
+          var newBody = document.createElement('tbody')
+          oldBody.parentNode.replaceChild(newBody, oldBody)
+          body = newBody
+        }
+
+        scope.clearTable = function() {
+          LogcatService.clear()
+          numberOfEntries = 0
+          clearTable()
+        }
+
+        scope.$on('$destroy', function() {
+          parent.removeEventListener('scroll', throttledScrollListener)
+        })
+      }
+    }
+  }
diff --git a/crowdstf/res/app/components/stf/logcat-table/logcat-table-spec.js b/crowdstf/res/app/components/stf/logcat-table/logcat-table-spec.js
new file mode 100644
index 0000000..8a12dd6
--- /dev/null
+++ b/crowdstf/res/app/components/stf/logcat-table/logcat-table-spec.js
@@ -0,0 +1,23 @@
+describe('logcatTable', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div logcat-table name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/logcat-table/logcat-table.css b/crowdstf/res/app/components/stf/logcat-table/logcat-table.css
new file mode 100644
index 0000000..8c98e59
--- /dev/null
+++ b/crowdstf/res/app/components/stf/logcat-table/logcat-table.css
@@ -0,0 +1,77 @@
+.stf-logcat-table {
+  background: #fff;
+  position: absolute;
+  top: 69px;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  overflow-y: auto;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+  /*z-index: -10;*/
+}
+
+.stf-logcat-table table {
+  table-layout: fixed !important;
+}
+
+.stf-logcat-table tbody tr {
+  border-bottom: 1px solid rgb(240, 240, 240);
+}
+
+.stf-logcat-table tbody td {
+  padding-left: 5px;
+  padding-right: 5px;
+}
+
+.stf-logcat-table .console-message-text {
+  font-size: 11px !important;
+  font-family: Menlo, monospace;
+  white-space: pre-wrap;
+}
+
+.stf-logcat-table .console-message-text td {
+  /*color: rgb(48, 57, 66);*/
+}
+
+/*.stf-logcat-table tr td:first-child {*/
+  /*background: #f3f3f3;*/
+  /*color: #800000;*/
+  /*text-align: right;*/
+/*}*/
+
+/**
+  Logging colors
+*/
+
+.stf-logcat-table .log-Unknown {
+  color: bisque;
+}
+
+.stf-logcat-table .log-Default,
+.stf-logcat-table .log-Silent {
+  color: darkolivegreen;
+}
+
+.stf-logcat-table .log-Verbose {
+  color: blueviolet;
+}
+
+.stf-logcat-table .log-Debug {
+  color: #020c7d;
+}
+
+.stf-logcat-table .log-Info {
+  color: #177d1c;
+}
+
+.stf-logcat-table .log-Warn {
+  color: coral;
+}
+
+.stf-logcat-table .log-Error {
+  color: red;
+}
+
+.stf-logcat-table .log-Fatal {
+  color: darkcyan;
+}
diff --git a/crowdstf/res/app/components/stf/logcat-table/logcat-table.jade b/crowdstf/res/app/components/stf/logcat-table/logcat-table.jade
new file mode 100644
index 0000000..25a14df
--- /dev/null
+++ b/crowdstf/res/app/components/stf/logcat-table/logcat-table.jade
@@ -0,0 +1,3 @@
+.stf-logcat-table.force-gpu
+  table.console-message-text.tableX.table-condensed.selectable
+    tbody
diff --git a/crowdstf/res/app/components/stf/logcat/index.js b/crowdstf/res/app/components/stf/logcat/index.js
new file mode 100644
index 0000000..8bea261
--- /dev/null
+++ b/crowdstf/res/app/components/stf/logcat/index.js
@@ -0,0 +1,5 @@
+module.exports = angular.module('stf.logcat', [
+  require('stf/filter-string').name,
+  require('stf/socket').name
+])
+  .factory('LogcatService', require('./logcat-service'))
diff --git a/crowdstf/res/app/components/stf/logcat/logcat-service.js b/crowdstf/res/app/components/stf/logcat/logcat-service.js
new file mode 100644
index 0000000..ff36ae6
--- /dev/null
+++ b/crowdstf/res/app/components/stf/logcat/logcat-service.js
@@ -0,0 +1,149 @@
+var _ = require('lodash')
+var _s = require('underscore.string')
+
+module.exports = function LogcatServiceFactory(socket, FilterStringService) {
+  var service = {}
+  service.started = false
+  service.numberOfEntries = 0
+
+  service.serverFilters = [
+    {
+      tag: '',
+      priority: 2
+    }
+  ]
+
+  service.filters = {
+    numberOfEntries: 0,
+    entries: [
+    ],
+    levelNumbers: []
+  }
+
+  var _filters = {}
+
+  function defineFilterProperties(properties) {
+    _.forEach(properties, function(prop) {
+      Object.defineProperty(service.filters, prop, {
+        get: function() {
+          return _filters[prop]
+        },
+        set: function(value) {
+          _filters[prop] = value || null
+          service.serverFilters[0][prop] = value || undefined
+          service.filters.filterLines()
+        }
+      })
+    })
+  }
+
+  defineFilterProperties([
+    'levelNumber',
+    'message',
+    'pid',
+    'tid',
+    'dateLabel',
+    'date',
+    'tag',
+    'priority'
+  ])
+
+  service.entries = [
+  ]
+
+  service.logLevels = [
+    'UNKNOWN',
+    'DEFAULT',
+    'VERBOSE',
+    'DEBUG',
+    'INFO',
+    'WARN',
+    'ERROR',
+    'FATAL',
+    'SILENT'
+  ]
+
+  var logLevelsLowerCase = _.map(service.logLevels, function(level) {
+    return level.toLowerCase()
+  })
+
+  var logLevelsCapitalized = _.map(logLevelsLowerCase, function(level) {
+    return _s.capitalize(level)
+  })
+
+  for (var i = 2; i < 8; ++i) {
+    service.filters.levelNumbers.push({number: i, name: logLevelsCapitalized[i]})
+  }
+
+  function enhanceEntry(data) {
+    var date = new Date(data.date * 1000)
+    data.dateLabel =
+      _s.pad(date.getHours(), 2, '0') + ':' +
+      _s.pad(date.getMinutes(), 2, '0') + ':' +
+      _s.pad(date.getSeconds(), 2, '0') + '.' +
+      _s.pad(date.getMilliseconds(), 3, '0')
+
+    data.deviceLabel = 'Android'
+
+    data.priorityLabel = logLevelsCapitalized[data.priority]
+
+    return data
+  }
+
+  socket.on('logcat.entry', function(rawData) {
+    service.numberOfEntries++
+    service.entries.push(enhanceEntry(rawData))
+
+    if (typeof (service.addEntryListener) === 'function') {
+      if (filterLine(rawData)) {
+        service.addEntryListener(rawData)
+      }
+    }
+  })
+
+  service.clear = function() {
+    service.numberOfEntries = 0
+    service.entries = []
+  }
+
+  service.filters.filterLines = function() {
+    service.filters.entries = _.filter(service.entries, filterLine)
+
+    if (typeof (service.addFilteredEntriesListener) === 'function') {
+      service.addFilteredEntriesListener(service.filters.entries)
+    }
+  }
+
+  function filterLine(line) {
+    var enabled = true
+    var filters = service.filters
+
+    var matched = true
+    if (enabled) {
+      if (!_.isEmpty(filters.priority)) {
+        matched &= line.priority >= filters.priority.number
+      }
+      if (!_.isEmpty(filters.date)) {
+        matched &= FilterStringService.filterString(filters.date, line.dateLabel)
+      }
+      if (!_.isEmpty(filters.pid)) {
+        matched &= FilterStringService.filterInteger(filters.pid, line.pid)
+      }
+      if (!_.isEmpty(filters.tid)) {
+        matched &= FilterStringService.filterInteger(filters.tid, line.tid)
+      }
+      if (!_.isEmpty(filters.tag)) {
+        matched &= FilterStringService.filterString(filters.tag, line.tag)
+      }
+      if (!_.isEmpty(filters.message)) {
+        matched &= FilterStringService.filterString(filters.message, line.message)
+      }
+    } else {
+      matched = true
+    }
+    return matched
+  }
+
+
+  return service
+}
diff --git a/crowdstf/res/app/components/stf/logcat/logcat-spec.js b/crowdstf/res/app/components/stf/logcat/logcat-spec.js
new file mode 100644
index 0000000..7d38694
--- /dev/null
+++ b/crowdstf/res/app/components/stf/logcat/logcat-spec.js
@@ -0,0 +1,11 @@
+describe('LogcatService', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  it('should ...', inject(function() {
+
+	//expect(LogcatService.doSomething()).toEqual('something')
+
+  }))
+
+})
diff --git a/crowdstf/res/app/components/stf/native-url/index.js b/crowdstf/res/app/components/stf/native-url/index.js
new file mode 100644
index 0000000..5e33520
--- /dev/null
+++ b/crowdstf/res/app/components/stf/native-url/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.native-url', [
+
+])
+  .factory('NativeUrlService', require('./native-url-service'))
diff --git a/crowdstf/res/app/components/stf/native-url/native-url-service.js b/crowdstf/res/app/components/stf/native-url/native-url-service.js
new file mode 100644
index 0000000..2842419
--- /dev/null
+++ b/crowdstf/res/app/components/stf/native-url/native-url-service.js
@@ -0,0 +1,89 @@
+module.exports = function NativeUrlServiceFactory($window, $timeout) {
+  var service = {}
+
+  // Ways of opening native URLs:
+  // - window.open
+  // - window.location.href
+
+  // Ways of detecting failure:
+  // - new window timeout
+  // - window onblur timeout
+  // - javascript single thread time elapsed
+
+  // Browser Behaviours:
+  // - Chrome Mac: 25ms timeout OK
+  // - Firefox Mac: 250ms timeout OK, 1000ms timeout better
+  // - Safari Mac: no working fallback, may need 2 html pages
+  // navigator.userAgent.match()
+  // TODO: Find which method works well in every browser
+
+  var fallbackMethod = 'USE_ON_BLUR'
+
+  var wasBlured = false
+  var windowOpened
+
+  // TODO: use a central on-blur event
+  var cachedWindowOnBlur = $window.onblur
+
+  service.open = function(options) {
+
+    switch (fallbackMethod) {
+      case 'USE_NEW_WINDOW':
+        // Doesn't work well on Chrome
+        windowOpened = $window.open(options.nativeUrl)
+
+        $timeout(function() {
+          if (windowOpened) {
+            windowOpened.close()
+          }
+        }, 500)
+
+        $window.location.href = options.webUrl
+        break
+      case 'USE_ON_BLUR':
+        // Doesn't work on Safari
+
+        $window.onblur = function() {
+          wasBlured = true
+        }
+
+        $window.location.href = options.nativeUrl
+
+        $timeout(function() {
+          if (wasBlured) {
+            wasBlured = false
+          } else {
+            $window.open(options.webUrl, '_blank')
+          }
+
+          $window.onblur = cachedWindowOnBlur
+        }, 250)
+
+        break
+      case 'USE_TIME_ELAPSED':
+        // Doesn't work on Chrome
+
+        var start, end, elapsed
+        start = new Date().getTime()
+
+        // console.log(' window.performance.webkitNow()', window.performance.now())
+
+        // This depends on the fact that JS is single-thread
+        document.location = options.nativeUrl
+
+        end = new Date().getTime()
+
+        elapsed = (end - start)
+
+        if (elapsed < 1) {
+          document.location = options.webUrl
+        }
+
+        break
+      default:
+    }
+
+  }
+
+  return service
+}
diff --git a/crowdstf/res/app/components/stf/native-url/native-url-spec.js b/crowdstf/res/app/components/stf/native-url/native-url-spec.js
new file mode 100644
index 0000000..fd1db94
--- /dev/null
+++ b/crowdstf/res/app/components/stf/native-url/native-url-spec.js
@@ -0,0 +1,11 @@
+describe('NativeUrlService', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  it('should ...', inject(function() {
+
+	//expect(NativeUrlService.doSomething()).toEqual('something')
+
+  }))
+
+})
diff --git a/crowdstf/res/app/components/stf/nav-menu/index.js b/crowdstf/res/app/components/stf/nav-menu/index.js
new file mode 100644
index 0000000..09f7229
--- /dev/null
+++ b/crowdstf/res/app/components/stf/nav-menu/index.js
@@ -0,0 +1,2 @@
+module.exports = angular.module('stf.nav-menu', [])
+  .directive('navMenu', require('./nav-menu-directive'))
diff --git a/crowdstf/res/app/components/stf/nav-menu/nav-menu-directive.js b/crowdstf/res/app/components/stf/nav-menu/nav-menu-directive.js
new file mode 100644
index 0000000..f488436
--- /dev/null
+++ b/crowdstf/res/app/components/stf/nav-menu/nav-menu-directive.js
@@ -0,0 +1,58 @@
+/* Based on https://ryankaskel.com/blog/2013/05/27/
+ a-different-approach-to-angularjs-navigation-menus */
+
+module.exports = function($location) {
+  return {
+    restrict: 'EA',
+    scope: {},
+    link: function(scope, element, attrs) {
+      var links = element.find('a')
+      var onClass = attrs.navMenu || 'current'
+      var urlMap = []
+      var routePattern, link, url
+
+      if (!$location.$$html5) {
+        routePattern = /\/#[^/]*/
+      }
+
+      for (var i = 0; i < links.length; i++) {
+        link = angular.element(links[i])
+        url = link.attr('ng-href')
+
+        // Remove angular route expressions
+        url = url.replace(/\/{{.*}}/g, '')
+
+        if ($location.$$html5) {
+          urlMap.push({url: url, link: link})
+        } else {
+          urlMap.push({url: url.replace(routePattern, ''), link: link})
+        }
+      }
+
+      function activateLink() {
+
+        var location = $location.path()
+        var pathLink = ''
+
+        for (var i = 0; i < urlMap.length; ++i) {
+          if (location.search(urlMap[i].url) !== -1) {
+            pathLink = urlMap[i].link
+          }
+        }
+
+        // Remove all active links
+        for (var j = 0; j < links.length; j++) {
+          link = angular.element(links[j])
+          link.removeClass(onClass)
+        }
+
+        if (pathLink) {
+          pathLink.addClass(onClass)
+        }
+      }
+
+      activateLink()
+      scope.$on('$routeChangeStart', activateLink)
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/nav-menu/nav-menu-spec.js b/crowdstf/res/app/components/stf/nav-menu/nav-menu-spec.js
new file mode 100644
index 0000000..0450c09
--- /dev/null
+++ b/crowdstf/res/app/components/stf/nav-menu/nav-menu-spec.js
@@ -0,0 +1,23 @@
+describe('navMenu', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your
+     directive, send that through compile() then compare the results.
+
+     var element = compile('<div nav-menu name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/page-visibility/index.js b/crowdstf/res/app/components/stf/page-visibility/index.js
new file mode 100644
index 0000000..5c2ac72
--- /dev/null
+++ b/crowdstf/res/app/components/stf/page-visibility/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.page-visibility', [
+
+])
+  .factory('PageVisibilityService', require('./page-visibility-service'))
diff --git a/crowdstf/res/app/components/stf/page-visibility/page-visibility-service.js b/crowdstf/res/app/components/stf/page-visibility/page-visibility-service.js
new file mode 100644
index 0000000..867ed29
--- /dev/null
+++ b/crowdstf/res/app/components/stf/page-visibility/page-visibility-service.js
@@ -0,0 +1,18 @@
+module.exports = function PageVisibilityServiceFactory($rootScope) {
+  var service = {
+    hidden: false
+  }
+
+  function visibilityChangeListener() {
+    service.hidden = document.hidden
+    $rootScope.$broadcast('visibilitychange', service.hidden)
+  }
+
+  document.addEventListener(
+    'visibilitychange'
+  , visibilityChangeListener
+  , false
+  )
+
+  return service
+}
diff --git a/crowdstf/res/app/components/stf/port-forwarding/index.js b/crowdstf/res/app/components/stf/port-forwarding/index.js
new file mode 100644
index 0000000..b03e677
--- /dev/null
+++ b/crowdstf/res/app/components/stf/port-forwarding/index.js
@@ -0,0 +1,3 @@
+module.exports = angular.module('stf.port-forwarding-service', [
+])
+  .factory('PortForwardingService', require('./port-forwarding-service'))
diff --git a/crowdstf/res/app/components/stf/port-forwarding/port-forwarding-service.js b/crowdstf/res/app/components/stf/port-forwarding/port-forwarding-service.js
new file mode 100644
index 0000000..b7c70d1
--- /dev/null
+++ b/crowdstf/res/app/components/stf/port-forwarding/port-forwarding-service.js
@@ -0,0 +1,6 @@
+module.exports = function() {
+  var service = {}
+
+
+  return service
+}
diff --git a/crowdstf/res/app/components/stf/port-forwarding/port-forwarding-spec.js b/crowdstf/res/app/components/stf/port-forwarding/port-forwarding-spec.js
new file mode 100644
index 0000000..2574bde
--- /dev/null
+++ b/crowdstf/res/app/components/stf/port-forwarding/port-forwarding-spec.js
@@ -0,0 +1,10 @@
+describe('PortForwardingService', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  it('should ...', inject(function() {
+	//expect(PortForwardingService.doSomething()).toEqual('something')
+
+  }))
+
+})
diff --git a/crowdstf/res/app/components/stf/scoped-hotkeys/index.js b/crowdstf/res/app/components/stf/scoped-hotkeys/index.js
new file mode 100644
index 0000000..71e24d2
--- /dev/null
+++ b/crowdstf/res/app/components/stf/scoped-hotkeys/index.js
@@ -0,0 +1,5 @@
+require('angular-hotkeys')
+module.exports = angular.module('stf.scoped-hotkeys', [
+  'cfp.hotkeys'
+])
+  .factory('ScopedHotkeysService', require('./scoped-hotkeys-service'))
diff --git a/crowdstf/res/app/components/stf/scoped-hotkeys/scoped-hotkeys-service.js b/crowdstf/res/app/components/stf/scoped-hotkeys/scoped-hotkeys-service.js
new file mode 100644
index 0000000..54de85c
--- /dev/null
+++ b/crowdstf/res/app/components/stf/scoped-hotkeys/scoped-hotkeys-service.js
@@ -0,0 +1,28 @@
+module.exports = function ScopedHotkeysServiceFactory(hotkeys, $filter) {
+  return function(scope, hotkeySet) {
+
+    function hotkeyAdd(combo, desc, callback, preventDefault) {
+      hotkeys.add({
+        combo: combo,
+        description: $filter('translate')(desc),
+        allowIn: ['textarea', 'input'],
+        callback: function(event) {
+          if (preventDefault || typeof preventDefault === 'undefined') {
+            event.preventDefault()
+          }
+          callback()
+        }
+      })
+    }
+
+    angular.forEach(hotkeySet, function(value) {
+      hotkeyAdd(value[0], value[1], value[2], value[3])
+    })
+
+    scope.$on('$destroy', function() {
+      angular.forEach(hotkeySet, function(value) {
+        hotkeys.del(value[0])
+      })
+    })
+  }
+}
diff --git a/crowdstf/res/app/components/stf/scoped-hotkeys/scoped-hotkeys-spec.js b/crowdstf/res/app/components/stf/scoped-hotkeys/scoped-hotkeys-spec.js
new file mode 100644
index 0000000..deef34f
--- /dev/null
+++ b/crowdstf/res/app/components/stf/scoped-hotkeys/scoped-hotkeys-spec.js
@@ -0,0 +1,11 @@
+describe('ScopedHotkeysService', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  it('should ...', inject(function() {
+
+	//expect(ScopedHotkeysService.doSomething()).toEqual('something')
+
+  }))
+
+})
diff --git a/crowdstf/res/app/components/stf/screen/fast-image-render/canvas-render.js b/crowdstf/res/app/components/stf/screen/fast-image-render/canvas-render.js
new file mode 100644
index 0000000..e32960b
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/fast-image-render/canvas-render.js
@@ -0,0 +1,43 @@
+function CanvasRender(canvasElement, options) {
+  this.options = options
+  this.context = canvasElement.getContext('2d')
+
+  var devicePixelRatio = window.devicePixelRatio || 1
+
+  var backingStoreRatio = this.context.webkitBackingStorePixelRatio ||
+  this.context.mozBackingStorePixelRatio ||
+  this.context.msBackingStorePixelRatio ||
+  this.context.oBackingStorePixelRatio ||
+  this.context.backingStorePixelRatio || 1
+
+  this.frontBackRatio = devicePixelRatio / backingStoreRatio
+
+  if (options.autoScaleForRetina && devicePixelRatio !== backingStoreRatio) {
+    var oldWidth = canvasElement.width
+    var oldHeight = canvasElement.height
+
+    canvasElement.width = oldWidth * this.frontBackRatio
+    canvasElement.height = oldHeight * this.frontBackRatio
+
+    canvasElement.style.width = oldWidth + 'px'
+    canvasElement.style.height = oldHeight + 'px'
+
+    this.context.scale(this.frontBackRatio, this.frontBackRatio)
+  }
+}
+
+CanvasRender.prototype.draw = function(image) {
+  this.context.drawImage(image, 0, 0)
+}
+
+CanvasRender.prototype.clear = function() {
+  this.context.clearRect(0, 0, this.context.canvas.width,
+    this.context.canvas.height)
+}
+
+// Check for Non CommonJS world
+if (typeof module !== 'undefined') {
+  module.exports = {
+    CanvasRender: CanvasRender
+  }
+}
diff --git a/crowdstf/res/app/components/stf/screen/fast-image-render/index.js b/crowdstf/res/app/components/stf/screen/fast-image-render/index.js
new file mode 100644
index 0000000..263c2c5
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/fast-image-render/index.js
@@ -0,0 +1,191 @@
+// See http://jsperf.com/fastest-canvas-drawing/2
+// See http://jsperf.com/canvas-drawimage-vs-putimagedata/3
+// See http://jsperf.com/canvas-drawimage-vs-webgl-drawarrays
+
+function FastImageRender(canvasElement, options) {
+  var that = this
+  this.options = options || {}
+  this.canvasElement = canvasElement
+  this.timeoutId = null
+
+  if (that.options.raf) {
+    that.animLoop = function() {
+      that.raf = window.requireAnimationFrame(that.animLoop)
+
+      // separate render from drawing
+      // render
+    }
+  }
+
+  // Loader
+  this.loader = new Image()
+  this.loader.onload = function() {
+    if (that.options.timeout) {
+      clearTimeout(that.timeoutId)
+    }
+    if (typeof (that.onLoad) === 'function') {
+      that.onLoad(this)
+    }
+  }
+  this.loader.onerror = function() {
+    if (that.options.timeout) {
+      clearTimeout(that.timeoutId)
+    }
+    if (typeof (that.onError) === 'function') {
+      that.onError(this)
+    }
+  }
+
+  if (this.options.render === 'webgl') {
+    var WebGLRender = require('./webgl-render').WebGLRender
+    this.render = new WebGLRender(canvasElement, options)
+  } else {
+    var CanvasRender = require('./canvas-render').CanvasRender
+    this.render = new CanvasRender(canvasElement, options)
+  }
+
+
+}
+
+FastImageRender.prototype.destroy = function() {
+
+  window.cancelAnimationFrame(this.raf)
+
+  // delete onLoad & onError
+}
+
+FastImageRender.prototype.load = function(url, type) {
+  var that = this
+
+  if (that.options.timeout) {
+    that.timeoutId = setTimeout(function() {
+      if (typeof (that.onError) === 'function') {
+        that.onError('timeout')
+      }
+    }, that.options.timeout)
+  }
+
+  if (this.options.textureLoader) {
+    if (!this.textureLoader) {
+      this.textureLoader = new window.TextureUtil.TextureLoader(this.render.ctx)
+    }
+    var texture = null
+    if (type) {
+      texture = this.render.ctx.createTexture()
+      this.textureLoader.loadEx(url, texture, true, function() {
+        if (typeof (that.onLoad) === 'function') {
+          that.onLoad(texture)
+        }
+      }, type)
+    } else {
+      this.textureLoader.load(url, function(texture) {
+        if (typeof (that.onLoad) === 'function') {
+          that.onLoad(texture)
+        }
+      })
+    }
+
+  } else {
+
+    this.loader.src = url
+  }
+}
+
+FastImageRender.prototype.draw = function(image) {
+  this.render.draw(image)
+}
+
+FastImageRender.prototype.clear = function() {
+  this.render.clear()
+}
+
+Object.defineProperty(FastImageRender.prototype, 'canvasWidth', {
+  get: function() {
+    return this.canvasElement.width
+  },
+  set: function(width) {
+    if (width) {
+      if (width !== this.canvasElement.width) {
+        this.canvasElement.width = width
+      }
+    }
+  }
+})
+
+Object.defineProperty(FastImageRender.prototype, 'canvasHeight', {
+  get: function() {
+    return this.canvasElement.height
+  },
+  set: function(height) {
+    if (height) {
+      if (height !== this.canvasElement.height) {
+        this.canvasElement.height = height
+      }
+    }
+  }
+})
+
+Object.defineProperty(FastImageRender.prototype, 'displayWidth', {
+  get: function() {
+    return this.canvasElement.width
+  },
+  set: function(width) {
+    if (width) {
+      if (width !== this.canvasElement.width) {
+        this.canvasElement.width = width
+      }
+    }
+  }
+})
+
+Object.defineProperty(FastImageRender.prototype, 'displayHeight', {
+  get: function() {
+    return this.canvasElement.height
+  },
+  set: function(height) {
+    if (height) {
+      if (height !== this.canvasElement.height) {
+        this.canvasElement.height = height
+      }
+    }
+  }
+})
+
+Object.defineProperty(FastImageRender.prototype, 'canvasStyleWidth', {
+  get: function() {
+    return parseInt(this.canvasElement.style.width, 10)
+  },
+  set: function(width) {
+    if (width) {
+      var styleWidth = width + 'px'
+      if (styleWidth !== this.canvasElement.style.width) {
+        this.canvasElement.style.width = styleWidth
+      }
+    }
+  }
+})
+
+Object.defineProperty(FastImageRender.prototype, 'canvasStyleHeight', {
+  get: function() {
+    return parseInt(this.canvasElement.style.height, 10)
+  },
+  set: function(height) {
+    if (height) {
+      var styleHeight = height + 'px'
+      if (styleHeight !== this.canvasElement.style.height) {
+        this.canvasElement.style.height = height
+      }
+    }
+  }
+})
+
+
+// -------------------------------------------------------------------------------------------------
+
+
+// Check for Non CommonJS world
+if (typeof module !== 'undefined') {
+  module.exports = {
+    FastImageRender: FastImageRender
+  }
+}
diff --git a/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.dds b/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.dds
new file mode 100644
index 0000000..f85ef61
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.dds
Binary files differ
diff --git a/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.dds.gz b/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.dds.gz
new file mode 100644
index 0000000..69f878e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.dds.gz
Binary files differ
diff --git a/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.jpg b/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.jpg
new file mode 100644
index 0000000..dfbad70
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.jpg
Binary files differ
diff --git a/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.png b/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.png
new file mode 100644
index 0000000..e211921
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.png
Binary files differ
diff --git a/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.webp b/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.webp
new file mode 100644
index 0000000..ed5ec83
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/screen.webp
Binary files differ
diff --git a/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/texture24.crn b/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/texture24.crn
new file mode 100644
index 0000000..5fe97a2
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/fast-image-render/test/images/texture24.crn
Binary files differ
diff --git a/crowdstf/res/app/components/stf/screen/fast-image-render/test/index.html b/crowdstf/res/app/components/stf/screen/fast-image-render/test/index.html
new file mode 100644
index 0000000..3479923
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/fast-image-render/test/index.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <title>Fast Image Render Test</title>
+</head>
+<body>
+
+<h2>Fast Image Render Test</h2>
+
+<dl>
+    <dt>Frame number</dt>
+    <dd id="frame-number"></dd>
+</dl>
+
+<dl>
+    <dt>Total Time</dt>
+    <dd id="total-time"></dd>
+</dl>
+
+<canvas width="643px" height="1149px"></canvas>
+<!--<canvas width="642px" height="1146px"></canvas>-->
+
+<script src="http://cdnjs.cloudflare.com/ajax/libs/pixi.js/1.5.1/pixi.dev.js"></script>
+<script src="../webgl-texture-utils/build/texture-util.js"></script>
+<script src="../index.js"></script>
+<script src="performance_test.js"></script>
+</body>
+</html>
diff --git a/crowdstf/res/app/components/stf/screen/fast-image-render/test/performance_test.js b/crowdstf/res/app/components/stf/screen/fast-image-render/test/performance_test.js
new file mode 100644
index 0000000..dd45755
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/fast-image-render/test/performance_test.js
@@ -0,0 +1,55 @@
+/* eslint no-console: 0 */
+
+var canvasElement = document.querySelector('canvas')
+var frameNumberElement = document.querySelector('#frame-number')
+var totalTimeElement = document.querySelector('#total-time')
+
+var frame = {
+  total: 100,
+  current: 0
+}
+
+function FastImageRender() {
+
+}
+
+var imageRender = new FastImageRender(
+  canvasElement
+, {
+    render: 'canvas'
+  , textureLoader: false
+  }
+)
+
+function loadNext() {
+  console.time('load')
+//  var width = 300
+//  var height = 300
+  //  loader.src = 'http://placehold.it/' + width + 'x' + height + '?' +
+  //    Date.now()
+  //  loader.src = 'http://lorempixel.com/' + width + '/' + height +
+  //    '/abstract/Frame-' + frames.current + '/?' + Date.now()
+  imageRender.load('images/screen.jpg?' + Date.now())
+//  imageRender.load('images/screen.jpg')
+}
+
+var startTime = new Date().getTime()
+
+loadNext()
+
+imageRender.onLoad = function(image) {
+  console.timeEnd('load')
+  console.time('draw')
+  imageRender.draw(image)
+  console.timeEnd('draw')
+
+  frameNumberElement.innerHTML = frame.current
+
+  if (frame.current++ < frame.total) {
+    loadNext()
+  } else {
+    var endTime = new Date().getTime()
+    var totalTime = endTime - startTime
+    totalTimeElement.innerHTML = totalTime / 1000 + ' seconds'
+  }
+}
diff --git a/crowdstf/res/app/components/stf/screen/fast-image-render/test/results.md b/crowdstf/res/app/components/stf/screen/fast-image-render/test/results.md
new file mode 100644
index 0000000..5d41045
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/fast-image-render/test/results.md
@@ -0,0 +1,103 @@
+# Benchmark results (internal data)
+
+-----
+
+### Versions
+- Canary 35
+- Safari 7
+- Firefox 27
+
+
+### Time to load and draw 5000 frames
+
+Hardware | Browser | Render | Time | ms
+-------- | ------- | ------ | ---- | --
+iMac | Canary | pixi-canvas | 54s | 
+iMac | Canary | pixi-webgl | 58s | 
+iMac | Canary | canvas | 54s | 5.8ms
+iMac | Firefox | pixi-canvas | 75s | 8.3ms
+iMac | Firefox | pixi-webgl | 82s | 11.3ms
+iMac | Firefox | canvas | 76s | 8.1ms
+MacBook | Canary | pixi-canvas | 68s
+MacBook | Canary | pixi-webgl | 83s
+MacBook | Canary | canvas | 76s
+
+
+### Time to just draw 5000 frames
+
+Hardware | Browser | Format | Render | Time | ms
+-------- | ------- | ------ | ------ | ---- | --
+iMac | Canary | DDS DXT1 | webgl | 55s | 
+
+
+### Time to render 1 frame
+Hardware | Browser | Format | Size | Function | ms
+-------- | ------- | ------ | ---- | -------- | --
+iMac | Canary | DDS DXT1 | 2.1MB | load | 20-100ms
+iMac | Canary | DDS DXT1 | 2.1MB | draw | **0.04ms**
+iMac | Canary | DDS GZIP | 271KB | load | 
+iMac | Canary | DDS GZIP | 271KB | draw | 
+iMac | Canary | WEBP     |  70KB | load | 9ms
+iMac | Canary | WEBP     |  70KB | draw | 24ms
+iMac | Canary | DDS CRN  | 70KB  | load | 30ms
+iMac | Canary | DDS CRN  | 70KB  | draw | **0.04ms**
+iMac | Canary | JPEG     | 94KB  | load | 25ms
+iMac | Canary | JPEG     | 94KB  | draw | 6ms
+iMac | Canary | PNG      | 590KB | load | 6ms
+iMac | Canary | PNG      | 590KB | draw | 30ms
+
+
+# About ST3C DXT1 DDS CRN
+
+### ST3C (S3 Texture Compression) 
+Group of related lossy texture compression algorithms, supported by most GPUs in Mac/PCs 
+
+### DXT1 (Block Compression 1)
+Smallest ST3C algorithm, 1-bit Alpha, compression ratio 6:1
+
+### DDS (DirectDraw Surface)
+Container file format for storing ST3C textures
+
+### CRN (DXTn Real-time Transcoding)
+Container file format for transcoding crunched ST3C textures
+
+
+
+# DDS vs JPEG
+
+- Drawing a DXT1 texture is over 100 times faster than JPEG
+- The DXT1 texture is transferred directly to the GPU, unlike JPEG
+- The DXT1 uses 6 times less GPU memory
+- File size is big, but when gzipped it's smaller than PNG
+
+# CRN vs JPEG
+- It transcodes to DXT1 so has all DXT1 benefits
+- File size is very small, even smaller than JPEG (!)
+- Requires to transcode on client side, so there is a CPU penalty
+- However transcoding CRN->DXT1 is faster than decoding and tranfering JPEG->GPU
+- JPEG->GPU texture uploading blocks the main thread and is slow
+- Transcoding a CRN can be done async, and even offloaded to a WebWorker
+- Currently the Crunch library is compiled to JS with Emscripten, so enabling asm.js would make the transcoding even faster
+- Compressing CRN vs libjpeg-turbo benchmarks still need to be done
+
+# Links
+
+
+ECT1 texture format works on all Android devices
+http://developer.android.com/tools/help/etc1tool.html
+
+WebGL also
+http://blog.tojicode.com/2011/12/compressed-textures-in-webgl.html
+
+Crunch
+https://code.google.com/p/crunch/
+
+Fast compression
+http://www.fastcompression.com/
+
+DXT1 crunch WebGL
+http://www-cs-students.stanford.edu/~eparker/files/crunch/decode_test.html
+
+WebGL Texture Utils
+https://github.com/gunta/webgl-texture-utils#webgl-texture-utils
+
diff --git a/crowdstf/res/app/components/stf/screen/fast-image-render/webgl-render.js b/crowdstf/res/app/components/stf/screen/fast-image-render/webgl-render.js
new file mode 100644
index 0000000..0aceb79
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/fast-image-render/webgl-render.js
@@ -0,0 +1,369 @@
+/*
+ Based on http://www-cs-students.stanford.edu/~eparker/files/crunch/renderer.js
+ */
+
+/*global Float32Array */
+
+/**
+ * Constructs a renderer object.
+ * @param {WebGLRenderingContext} gl The GL context.
+ * @constructor
+ */
+var Renderer = function(gl) {
+  /**
+   * The GL context.
+   * @type {WebGLRenderingContext}
+   * @private
+   */
+  this.gl_ = gl
+
+  /**
+   * The WebGLProgram.
+   * @type {WebGLProgram}
+   * @private
+   */
+  this.program_ = gl.createProgram()
+
+  /**
+   * @type {WebGLShader}
+   * @private
+   */
+  this.vertexShader_ = this.compileShader_(
+    Renderer.vertexShaderSource_, gl.VERTEX_SHADER)
+
+  /**
+   * @type {WebGLShader}
+   * @private
+   */
+  this.fragmentShader_ = this.compileShader_(
+    Renderer.fragmentShaderSource_, gl.FRAGMENT_SHADER)
+
+  /**
+   * Cached uniform locations.
+   * @type {Object.<string, WebGLUniformLocation>}
+   * @private
+   */
+  this.uniformLocations_ = {}
+
+  /**
+   * Cached attribute locations.
+   * @type {Object.<string, WebGLActiveInfo>}
+   * @private
+   */
+  this.attribLocations_ = {}
+
+  /**
+   * A vertex buffer containing a single quad with xy coordinates from [-1,-1]
+   * to [1,1] and uv coordinates from [0,0] to [1,1].
+   * @private
+   */
+  this.quadVertexBuffer_ = gl.createBuffer()
+  gl.bindBuffer(gl.ARRAY_BUFFER, this.quadVertexBuffer_)
+  var vertices = new Float32Array(
+    [-1.0, -1.0, 0.0, 1.0,
+      +1.0, -1.0, 1.0, 1.0,
+      -1.0, +1.0, 0.0, 0.0,
+      1.0, +1.0, 1.0, 0.0])
+  gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW)
+
+
+  // Init shaders
+  gl.attachShader(this.program_, this.vertexShader_)
+  gl.attachShader(this.program_, this.fragmentShader_)
+  gl.bindAttribLocation(this.program_, 0, 'vert')
+  gl.linkProgram(this.program_)
+  gl.useProgram(this.program_)
+  gl.enableVertexAttribArray(0)
+
+  gl.enable(gl.DEPTH_TEST)
+  gl.disable(gl.CULL_FACE)
+
+  var count = gl.getProgramParameter(this.program_, gl.ACTIVE_UNIFORMS)
+  for (var i = 0; i < /** @type {number} */(count); i++) {
+    var infoU = gl.getActiveUniform(this.program_, i)
+    this.uniformLocations_[infoU.name] =
+      gl.getUniformLocation(this.program_, infoU.name)
+  }
+
+  count = gl.getProgramParameter(this.program_, gl.ACTIVE_ATTRIBUTES)
+  for (var j = 0; j < /** @type {number} */(count); j++) {
+    var infoA = gl.getActiveAttrib(this.program_, j)
+    this.attribLocations_[infoA.name] =
+      gl.getAttribLocation(this.program_, infoA.name)
+  }
+}
+
+
+Renderer.prototype.finishInit = function() {
+  this.draw()
+}
+
+
+Renderer.prototype.createDxtTexture =
+  function(dxtData, width, height, format) {
+    var gl = this.gl_
+    var tex = gl.createTexture()
+    gl.bindTexture(gl.TEXTURE_2D, tex)
+    gl.compressedTexImage2D(
+      gl.TEXTURE_2D,
+      0,
+      format,
+      width,
+      height,
+      0,
+      dxtData)
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
+    //gl.generateMipmap(gl.TEXTURE_2D)
+    gl.bindTexture(gl.TEXTURE_2D, null)
+    return tex
+  }
+
+
+Renderer.prototype.createRgb565Texture = function(rgb565Data, width, height) {
+  var gl = this.gl_
+  var tex = gl.createTexture()
+  gl.bindTexture(gl.TEXTURE_2D, tex)
+  gl.texImage2D(
+    gl.TEXTURE_2D,
+    0,
+    gl.RGB,
+    width,
+    height,
+    0,
+    gl.RGB,
+    gl.UNSIGNED_SHORT_5_6_5,
+    rgb565Data)
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
+  //gl.generateMipmap(gl.TEXTURE_2D)
+  gl.bindTexture(gl.TEXTURE_2D, null)
+  return tex
+}
+
+
+Renderer.prototype.drawTexture = function(texture, width, height) {
+  var gl = this.gl_
+  // draw scene
+  gl.clearColor(0, 0, 0, 1)
+  gl.clearDepth(1.0)
+  gl.viewport(0, 0, width, height)
+  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT)
+
+  gl.activeTexture(gl.TEXTURE0)
+  gl.bindTexture(gl.TEXTURE_2D, texture)
+  gl.uniform1i(this.uniformLocations_.texSampler, 0)
+
+  gl.enableVertexAttribArray(this.attribLocations_.vert)
+  gl.bindBuffer(gl.ARRAY_BUFFER, this.quadVertexBuffer_)
+  gl.vertexAttribPointer(this.attribLocations_.vert, 4, gl.FLOAT,
+    false, 0, 0)
+  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
+}
+
+
+/**
+ * Compiles a GLSL shader and returns a WebGLShader.
+ * @param {string} shaderSource The shader source code string.
+ * @param {number} type Either VERTEX_SHADER or FRAGMENT_SHADER.
+ * @return {WebGLShader} The new WebGLShader.
+ * @private
+ */
+Renderer.prototype.compileShader_ = function(shaderSource, type) {
+  var gl = this.gl_
+  var shader = gl.createShader(type)
+  gl.shaderSource(shader, shaderSource)
+  gl.compileShader(shader)
+  return shader
+}
+
+
+/**
+ * @type {string}
+ * @private
+ */
+Renderer.vertexShaderSource_ = [
+  'attribute vec4 vert;',
+  'varying vec2 v_texCoord;',
+  'void main() {',
+  '  gl_Position = vec4(vert.xy, 0.0, 1.0);',
+  '  v_texCoord = vert.zw;',
+  '}'
+].join('\n')
+
+
+/**
+ * @type {string}
+ * @private
+ */
+Renderer.fragmentShaderSource_ = [
+  'precision highp float;',
+  'uniform sampler2D texSampler;',
+  'varying vec2 v_texCoord;',
+  'void main() {',
+  '  gl_FragColor = texture2D(texSampler, v_texCoord);',
+  '}'
+].join('\n')
+
+// -------------------------------------------------------------------------------------------------
+
+
+function WebGLRender(canvasElement) {
+  this.options = {
+//    alpha: this.transparent,
+//    antialias: !!antialias,
+//    premultipliedAlpha: !!transparent,
+//    stencil: true
+  }
+
+  try {
+    this.ctx = canvasElement.getContext('experimental-webgl', this.options)
+  } catch (e) {
+    try {
+      this.ctx = canvasElement.getContext('webgl', this.options)
+    } catch (e2) {
+      // fail, not able to get a context
+      throw new Error('This browser does not support webGL. Try using the' +
+      'canvas renderer' + this)
+    }
+  }
+
+  if (!this.ctx.getExtension('WEBKIT_WEBGL_compressed_texture_s3tc')) {
+    this.dxtSupported = false
+  }
+
+  this.renderer = new Renderer(this.ctx)
+
+
+  this.contextLost = false
+
+//  gl.useProgram(this.shaderManager.defaultShader.program)
+
+  //this.ctx.disable(this.ctx.DEPTH_TEST)
+  //this.ctx.disable(this.ctx.CULL_FACE)
+
+  //this.setup()
+}
+
+WebGLRender.prototype.setup = function() {
+  // create shaders
+  var vertexShaderSrc =
+    'attribute vec2 aVertex;' +
+    'attribute vec2 aUV;' +
+    'varying vec2 vTex;' +
+    'void main(void) {' +
+    '  gl_Position = vec4(aVertex, 0.0, 1.0);' +
+    '  vTex = aUV;' +
+    '}'
+
+  var fragmentShaderSrc =
+    'precision highp float;' +
+    'varying vec2 vTex;' +
+    'uniform sampler2D sampler0;' +
+    'void main(void){' +
+    '  gl_FragColor = texture2D(sampler0, vTex);' +
+    '}'
+
+  var vertShaderObj = this.ctx.createShader(this.ctx.VERTEX_SHADER)
+  var fragShaderObj = this.ctx.createShader(this.ctx.FRAGMENT_SHADER)
+  this.ctx.shaderSource(vertShaderObj, vertexShaderSrc)
+  this.ctx.shaderSource(fragShaderObj, fragmentShaderSrc)
+  this.ctx.compileShader(vertShaderObj)
+  this.ctx.compileShader(fragShaderObj)
+
+  var progObj = this.ctx.createProgram()
+  this.ctx.attachShader(progObj, vertShaderObj)
+  this.ctx.attachShader(progObj, fragShaderObj)
+
+  this.ctx.linkProgram(progObj)
+  this.ctx.useProgram(progObj)
+
+  var width = this.displayWidth
+  var height = this.displayHeight
+
+  this.ctx.viewport(0, 0, width, height)
+
+  this.vertexBuff = this.ctx.createBuffer()
+  this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this.vertexBuff)
+  this.ctx.bufferData(
+    this.ctx.ARRAY_BUFFER, new Float32Array([
+      -1 / 8, 1 / 6, -1 / 8, -1 / 6, 1 / 8, -1 / 6, 1 / 8, 1 / 6
+    ]), this.ctx.STATIC_DRAW
+  )
+
+  this.texBuff = this.ctx.createBuffer()
+  this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this.texBuff)
+  this.ctx.bufferData(
+    this.ctx.ARRAY_BUFFER,
+    new Float32Array([0, 1, 0, 0, 1, 0, 1, 1]),
+    this.ctx.STATIC_DRAW)
+
+  this.vloc = this.ctx.getAttribLocation(progObj, 'aVertex')
+  this.tloc = this.ctx.getAttribLocation(progObj, 'aUV')
+
+}
+
+WebGLRender.prototype.draw = function(image) {
+//  this.renderer.drawTexture(image, image.width, image.height)
+  this.renderer.drawTexture(image, 643, 1149)
+}
+
+
+WebGLRender.prototype.drawOld = function(image) {
+  var tex = this.ctx.createTexture()
+  this.ctx.bindTexture(this.ctx.TEXTURE_2D, tex)
+  this.ctx.texParameteri(
+    this.ctx.TEXTURE_2D, this.ctx.TEXTURE_MIN_FILTER, this.ctx.NEAREST
+  )
+  this.ctx.texParameteri(
+    this.ctx.TEXTURE_2D, this.ctx.TEXTURE_MAG_FILTER, this.ctx.NEAREST
+  )
+  /*
+   this.ctx.texParameteri(
+   this.ctx.TEXTURE_2D
+   , this.ctx.TEXTURE_MIN_FILTER
+   , this.ctx.LINEAR
+   )
+
+   this.ctx.texParameteri(
+   this.ctx.TEXTURE_2D
+   , this.ctx.TEXTURE_WRAP_S
+   , this.ctx.CLAMP_TO_EDGE
+   )
+   this.ctx.texParameteri(
+   this.ctx.TEXTURE_2D
+   , this.ctx.TEXTURE_WRAP_T
+   , this.ctx.CLAMP_TO_EDGE
+   )
+   */
+  this.ctx.generateMipmap(this.ctx.TEXTURE_2D)
+  this.ctx.texImage2D(
+    this.ctx.TEXTURE_2D, 0, this.ctx.RGBA, this.ctx.RGBA,
+    this.ctx.UNSIGNED_BYTE, image
+  )
+
+  this.ctx.enableVertexAttribArray(this.vloc)
+  this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this.vertexBuff)
+  this.ctx.vertexAttribPointer(this.vloc, 2, this.ctx.FLOAT, false, 0, 0)
+
+  this.ctx.enableVertexAttribArray(this.tloc)
+  this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this.texBuff)
+  this.ctx.bindTexture(this.ctx.TEXTURE_2D, tex)
+  this.ctx.vertexAttribPointer(this.tloc, 2, this.ctx.FLOAT, false, 0, 0)
+}
+
+WebGLRender.prototype.clear = function() {
+
+}
+
+
+// Check for Non CommonJS world
+if (typeof module !== 'undefined') {
+  module.exports = {
+    WebGLRender: WebGLRender
+  }
+}
diff --git a/crowdstf/res/app/components/stf/screen/imagepool.js b/crowdstf/res/app/components/stf/screen/imagepool.js
new file mode 100644
index 0000000..793ca5d
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/imagepool.js
@@ -0,0 +1,23 @@
+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]
+  }
+}
+
+module.exports = ImagePool
diff --git a/crowdstf/res/app/components/stf/screen/index.js b/crowdstf/res/app/components/stf/screen/index.js
new file mode 100644
index 0000000..49c69c8
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/index.js
@@ -0,0 +1,11 @@
+require('./screen.css')
+
+module.exports = angular.module('stf/screen', [
+  require('stf/screen/scaling').name
+, require('stf/util/vendor').name
+, require('stf/page-visibility').name
+, require('stf/browser-info').name
+, require('stf/common-ui/nothing-to-show').name
+])
+  .directive('deviceScreen', require('./screen-directive'))
+  .controller('DeviceScreenCtrl', require('./screen-controller'))
diff --git a/crowdstf/res/app/components/stf/screen/rotator-test.js b/crowdstf/res/app/components/stf/screen/rotator-test.js
new file mode 100644
index 0000000..1801301
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/rotator-test.js
@@ -0,0 +1,64 @@
+/* eslint no-console: 0 */
+var rotator = require('./rotator')
+
+var tests = [
+  [0, 0, +0]
+, [0, 90, -90]
+, [0, 180, -180]
+, [0, 270, +90]
+, [90, 0, +90]
+, [90, 90, +0]
+, [90, 180, -90]
+, [90, 270, +180]
+, [180, 0, +180]
+, [180, 90, +90]
+, [180, 180, +0]
+, [180, 270, -90]
+, [270, 0, -90]
+, [270, 90, -180]
+, [270, 180, +90]
+, [270, 270, +0]
+, [360, 0, +0]
+, [360, 90, -90]
+, [360, 180, -180]
+, [360, 270, +90]
+, [-90, 0, -90]
+, [-90, 90, -180]
+, [-90, 180, +90]
+, [-90, 270, 0]
+, [-180, 0, +180]
+, [-180, 90, +90]
+, [-180, 180, +0]
+, [-180, 270, -90]
+, [-270, 0, +90]
+, [-270, 90, 0]
+, [-270, 180, -90]
+, [-270, 270, +180]
+, [720, 0, +0]
+, [720, 90, -90]
+, [720, 180, -180]
+, [720, 270, +90]
+, [450, 0, +90]
+, [450, 90, +0]
+, [450, 180, -90]
+, [450, 270, +180]
+, [540, 0, +180]
+, [540, 90, +90]
+, [540, 180, +0]
+, [540, 270, -90]
+, [630, 0, -90]
+, [630, 90, -180]
+, [630, 180, +90]
+, [630, 270, +0]
+]
+
+tests.forEach(function(values) {
+  var msg = values[0] + ' -> ' + values[1] + ' should be ' + values[2]
+
+  if (rotator(values[0], values[1]) === values[2]) {
+    console.log('pass: ' + msg)
+  }
+  else {
+    console.error('FAIL: ' + msg)
+  }
+})
diff --git a/crowdstf/res/app/components/stf/screen/rotator.js b/crowdstf/res/app/components/stf/screen/rotator.js
new file mode 100644
index 0000000..a84a7a4
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/rotator.js
@@ -0,0 +1,33 @@
+var mapping = {
+  0: {
+    0: 0
+  , 90: -90
+  , 180: -180
+  , 270: 90
+  }
+, 90: {
+    0: 90
+  , 90: 0
+  , 180: -90
+  , 270: 180
+  }
+, 180: {
+    0: 180
+  , 90: 90
+  , 180: 0
+  , 270: -90
+  }
+, 270: {
+    0: -90
+  , 90: -180
+  , 180: 90
+  , 270: 0
+  }
+}
+
+module.exports = function rotator(oldRotation, newRotation) {
+  var r1 = oldRotation < 0 ? 360 + oldRotation % 360 : oldRotation % 360
+  var r2 = newRotation < 0 ? 360 + newRotation % 360 : newRotation % 360
+
+  return mapping[r1][r2]
+}
diff --git a/crowdstf/res/app/components/stf/screen/scaling/index.js b/crowdstf/res/app/components/stf/screen/scaling/index.js
new file mode 100644
index 0000000..327fd7f
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/scaling/index.js
@@ -0,0 +1,2 @@
+module.exports = angular.module('stf/scaling', [])
+  .factory('ScalingService', require('./scaling-service'))
diff --git a/crowdstf/res/app/components/stf/screen/scaling/scaling-service.js b/crowdstf/res/app/components/stf/screen/scaling/scaling-service.js
new file mode 100644
index 0000000..de804c6
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/scaling/scaling-service.js
@@ -0,0 +1,211 @@
+module.exports = function ScalingServiceFactory() {
+  var scalingService = {
+  }
+
+  scalingService.coordinator = function(realWidth, realHeight) {
+    var realRatio = realWidth / realHeight
+
+    /**
+     * 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   |
+     * |--------------|------|---------|---------|---------|
+     */
+    return {
+      coords: function(boundingW, boundingH, relX, relY, rotation) {
+        var w, h, x, y, ratio, scaledValue
+
+        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
+        }
+
+        ratio = w / h
+
+        if (realRatio > ratio) {
+          // covers the area horizontally
+          scaledValue = w / realRatio
+
+          // adjust y to start from the scaled top edge
+          y -= (h - scaledValue) / 2
+
+          // not touching the screen, but we want to trigger certain events
+          // (like touchup) anyway, so let's do it on the edges.
+          if (y < 0) {
+            y = 0
+          }
+          else if (y > scaledValue) {
+            y = scaledValue
+          }
+
+          // make sure x is within bounds too
+          if (x < 0) {
+            x = 0
+          }
+          else if (x > w) {
+            x = w
+          }
+
+          h = scaledValue
+        }
+        else {
+          // covers the area vertically
+          scaledValue = h * realRatio
+
+          // adjust x to start from the scaled left edge
+          x -= (w - scaledValue) / 2
+
+          // not touching the screen, but we want to trigger certain events
+          // (like touchup) anyway, so let's do it on the edges.
+          if (x < 0) {
+            x = 0
+          }
+          else if (x > scaledValue) {
+            x = scaledValue
+          }
+
+          // make sure y is within bounds too
+          if (y < 0) {
+            y = 0
+          }
+          else if (y > h) {
+            y = h
+          }
+
+          w = scaledValue
+        }
+
+        return {
+          xP: x / w
+        , yP: y / h
+        }
+      }
+    , size: function(sizeWidth, sizeHeight) {
+        var width = sizeWidth
+        var height = sizeHeight
+        var ratio = width / height
+
+        if (realRatio > ratio) {
+          // covers the area horizontally
+
+          if (width >= realWidth) {
+            // don't go over max size
+            width = realWidth
+            height = realHeight
+          }
+          else {
+            height = Math.floor(width / realRatio)
+          }
+        }
+        else {
+          // covers the area vertically
+
+          if (height >= realHeight) {
+            // don't go over max size
+            height = realHeight
+            width = realWidth
+          }
+          else {
+            width = Math.floor(height * realRatio)
+          }
+        }
+
+        return {
+          width: width
+        , height: height
+        }
+      }
+    , projectedSize: function(boundingW, boundingH, rotation) {
+        var w, h
+
+        switch (rotation) {
+        case 0:
+        case 180:
+          w = boundingW
+          h = boundingH
+          break
+        case 90:
+        case 270:
+          w = boundingH
+          h = boundingW
+          break
+        }
+
+        var ratio = w / h
+
+        if (realRatio > ratio) {
+          // covers the area horizontally
+          h = Math.floor(w / realRatio)
+        }
+        else {
+          w = Math.floor(h * realRatio)
+        }
+
+        return {
+          width: w
+        , height: h
+        }
+      }
+    }
+  }
+
+  return scalingService
+}
diff --git a/crowdstf/res/app/components/stf/screen/screen-controller.js b/crowdstf/res/app/components/stf/screen/screen-controller.js
new file mode 100644
index 0000000..8078c27
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/screen-controller.js
@@ -0,0 +1,13 @@
+module.exports = function DeviceScreenCtrl(
+  $scope
+, $rootScope
+, ScalingService
+, InstallService
+) {
+  $scope.displayError = false
+  $scope.ScalingService = ScalingService
+
+  $scope.installFile = function($files) {
+    return InstallService.installFile($scope.control, $files)
+  }
+}
diff --git a/crowdstf/res/app/components/stf/screen/screen-directive.js b/crowdstf/res/app/components/stf/screen/screen-directive.js
new file mode 100644
index 0000000..ec4e56d
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/screen-directive.js
@@ -0,0 +1,938 @@
+var _ = require('lodash')
+var rotator = require('./rotator')
+var ImagePool = require('./imagepool')
+
+module.exports = function DeviceScreenDirective(
+  $document
+, ScalingService
+, VendorUtil
+, PageVisibilityService
+, $timeout
+, $window
+) {
+  return {
+    restrict: 'E'
+  , template: require('./screen.jade')
+  , scope: {
+      control: '&'
+    , device: '&'
+    }
+  , link: function(scope, element) {
+      var URL = window.URL || window.webkitURL
+      var BLANK_IMG =
+        'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
+      var cssTransform = VendorUtil.style(['transform', 'webkitTransform'])
+
+      var device = scope.device()
+      var control = scope.control()
+
+      var input = element.find('input')
+
+      var screen = scope.screen = {
+        rotation: 0
+      , bounds: {
+          x: 0
+        , y: 0
+        , w: 0
+        , h: 0
+        }
+      }
+
+      var scaler = ScalingService.coordinator(
+        device.display.width
+      , device.display.height
+      )
+
+      /**
+       * SCREEN HANDLING
+       *
+       * This section should deal with updating the screen ONLY.
+       */
+      ;(function() {
+        function stop() {
+          try {
+            ws.onerror = ws.onclose = ws.onmessage = ws.onopen = null
+            ws.close()
+            ws = null
+          }
+          catch (err) { /* noop */ }
+        }
+
+        var ws = new WebSocket(device.display.url)
+        ws.binaryType = 'blob'
+
+        ws.onerror = function errorListener() {
+          // @todo Handle
+        }
+
+        ws.onclose = function closeListener() {
+          // @todo Maybe handle
+        }
+
+        ws.onopen = function openListener() {
+          checkEnabled()
+        }
+
+        var canvas = element.find('canvas')[0]
+        var g = canvas.getContext('2d')
+        var positioner = element.find('div')[0]
+
+        function vendorBackingStorePixelRatio(g) {
+          return g.webkitBackingStorePixelRatio ||
+            g.mozBackingStorePixelRatio ||
+            g.msBackingStorePixelRatio ||
+            g.oBackingStorePixelRatio ||
+            g.backingStorePixelRatio || 1
+        }
+
+        var devicePixelRatio = window.devicePixelRatio || 1
+        var backingStoreRatio = vendorBackingStorePixelRatio(g)
+        var frontBackRatio = devicePixelRatio / backingStoreRatio
+
+        var options = {
+          autoScaleForRetina: true
+        , density: Math.max(1, Math.min(1.5, devicePixelRatio || 1))
+        , minscale: 0.36
+        }
+
+        var adjustedBoundSize
+        var cachedEnabled = false
+
+        function updateBounds() {
+          function adjustBoundedSize(w, h) {
+            var sw = w * options.density
+            var sh = h * options.density
+            var f
+
+            if (sw < (f = device.display.width * options.minscale)) {
+              sw *= f / sw
+              sh *= f / sh
+            }
+
+            if (sh < (f = device.display.height * options.minscale)) {
+              sw *= f / sw
+              sh *= f / sh
+            }
+
+            return {
+              w: Math.ceil(sw)
+            , h: Math.ceil(sh)
+            }
+          }
+
+          // FIXME: element is an object HTMLUnknownElement in IE9
+          var w = screen.bounds.w = element[0].offsetWidth
+          var h = screen.bounds.h = element[0].offsetHeight
+
+          // Developer error, let's try to reduce debug time
+          if (!w || !h) {
+            throw new Error(
+              'Unable to read bounds; container must have dimensions'
+            )
+          }
+
+          var newAdjustedBoundSize = (function() {
+            switch (screen.rotation) {
+            case 90:
+            case 270:
+              return adjustBoundedSize(h, w)
+            case 0:
+            case 180:
+              /* falls through */
+            default:
+              return adjustBoundedSize(w, h)
+            }
+          })()
+
+          if (!adjustedBoundSize ||
+            newAdjustedBoundSize.w !== adjustedBoundSize.w ||
+            newAdjustedBoundSize.h !== adjustedBoundSize.h) {
+            adjustedBoundSize = newAdjustedBoundSize
+            onScreenInterestAreaChanged()
+          }
+        }
+
+        function shouldUpdateScreen() {
+          return (
+            // NO if the user has disabled the screen.
+            scope.$parent.showScreen &&
+            // NO if we're not even using the device anymore.
+            device.using &&
+            // NO if the page is not visible (e.g. background tab).
+            !PageVisibilityService.hidden &&
+            // NO if we don't have a connection yet.
+            ws.readyState === WebSocket.OPEN
+            // YES otherwise
+          )
+        }
+
+        function checkEnabled() {
+          var newEnabled = shouldUpdateScreen()
+
+          if (newEnabled === cachedEnabled) {
+            updateBounds()
+          }
+          else if (newEnabled) {
+            updateBounds()
+            onScreenInterestGained()
+          }
+          else {
+            g.clearRect(0, 0, canvas.width, canvas.height)
+            onScreenInterestLost()
+          }
+
+          cachedEnabled = newEnabled
+        }
+
+        function onScreenInterestGained() {
+          if (ws.readyState === WebSocket.OPEN) {
+            ws.send('size ' + adjustedBoundSize.w + 'x' + adjustedBoundSize.h)
+            ws.send('on')
+          }
+        }
+
+        function onScreenInterestAreaChanged() {
+          if (ws.readyState === WebSocket.OPEN) {
+            ws.send('size ' + adjustedBoundSize.w + 'x' + adjustedBoundSize.h)
+          }
+        }
+
+        function onScreenInterestLost() {
+          if (ws.readyState === WebSocket.OPEN) {
+            ws.send('off')
+          }
+        }
+
+        ws.onmessage = (function() {
+          var cachedScreen = {
+            rotation: 0
+          , bounds: {
+              x: 0
+            , y: 0
+            , w: 0
+            , h: 0
+            }
+          }
+
+          var cachedImageWidth = 0
+          var cachedImageHeight = 0
+          var cssRotation = 0
+          var alwaysUpright = false
+          var imagePool = new ImagePool(10)
+
+          function applyQuirks(banner) {
+            element[0].classList.toggle(
+              'quirk-always-upright', alwaysUpright = banner.quirks.alwaysUpright)
+          }
+
+          function hasImageAreaChanged(img) {
+            return cachedScreen.bounds.w !== screen.bounds.w ||
+              cachedScreen.bounds.h !== screen.bounds.h ||
+              cachedImageWidth !== img.width ||
+              cachedImageHeight !== img.height ||
+              cachedScreen.rotation !== screen.rotation
+          }
+
+          function isRotated() {
+            return screen.rotation === 90 || screen.rotation === 270
+          }
+
+          function updateImageArea(img) {
+            if (!hasImageAreaChanged(img)) {
+              return
+            }
+
+            cachedImageWidth = img.width
+            cachedImageHeight = img.height
+
+            if (options.autoScaleForRetina) {
+              canvas.width = cachedImageWidth * frontBackRatio
+              canvas.height = cachedImageHeight * frontBackRatio
+              g.scale(frontBackRatio, frontBackRatio)
+            }
+            else {
+              canvas.width = cachedImageWidth
+              canvas.height = cachedImageHeight
+            }
+
+            cssRotation += rotator(cachedScreen.rotation, screen.rotation)
+
+            canvas.style[cssTransform] = 'rotate(' + cssRotation + 'deg)'
+
+            cachedScreen.bounds.h = screen.bounds.h
+            cachedScreen.bounds.w = screen.bounds.w
+            cachedScreen.rotation = screen.rotation
+
+            canvasAspect = canvas.width / canvas.height
+
+            if (isRotated() && !alwaysUpright) {
+              canvasAspect = img.height / img.width
+              element[0].classList.add('rotated')
+            }
+            else {
+              canvasAspect = img.width / img.height
+              element[0].classList.remove('rotated')
+            }
+
+            if (alwaysUpright) {
+              // If the screen image is always in upright position (but we
+              // still want the rotation animation), we need to cancel out
+              // the rotation by using another rotation.
+              positioner.style[cssTransform] = 'rotate(' + -cssRotation + 'deg)'
+            }
+
+            maybeFlipLetterbox()
+          }
+
+          return function messageListener(message) {
+            screen.rotation = device.display.rotation
+
+            if (message.data instanceof Blob) {
+              if (shouldUpdateScreen()) {
+                if (scope.displayError) {
+                  scope.$apply(function() {
+                    scope.displayError = false
+                  })
+                }
+
+                var blob = new Blob([message.data], {
+                  type: 'image/jpeg'
+                })
+
+                var img = imagePool.next()
+
+                img.onload = function() {
+                  updateImageArea(this)
+
+                  g.drawImage(img, 0, 0, img.width, img.height)
+
+                  // Try to forcefully clean everything to get rid of memory
+                  // leaks. Note that 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 (/^start /.test(message.data)) {
+              applyQuirks(JSON.parse(message.data.substr('start '.length)))
+            }
+            else if (message.data === 'secure_on') {
+              scope.$apply(function() {
+                scope.displayError = 'secure'
+              })
+            }
+          }
+        })()
+
+        // NOTE: instead of fa-pane-resize, a fa-child-pane-resize could be better
+        scope.$on('fa-pane-resize', _.debounce(updateBounds, 1000))
+        scope.$watch('device.using', checkEnabled)
+        scope.$on('visibilitychange', checkEnabled)
+        scope.$watch('$parent.showScreen', checkEnabled)
+
+        scope.retryLoadingScreen = function() {
+          if (scope.displayError === 'secure') {
+            control.home()
+          }
+        }
+
+        scope.$on('guest-portrait', function() {
+          control.rotate(0)
+        })
+
+        scope.$on('guest-landscape', function() {
+          control.rotate(90)
+        })
+
+        var canvasAspect = 1
+        var parentAspect = 1
+
+        function resizeListener() {
+          parentAspect = element[0].offsetWidth / element[0].offsetHeight
+          maybeFlipLetterbox()
+        }
+
+        function maybeFlipLetterbox() {
+          element[0].classList.toggle(
+            'letterboxed', parentAspect < canvasAspect)
+        }
+
+        $window.addEventListener('beforeunload', stop, false)
+        $window.addEventListener('resize', resizeListener, false)
+        scope.$on('fa-pane-resize', resizeListener)
+
+        resizeListener()
+
+        scope.$on('$destroy', function() {
+          stop()
+          $window.removeEventListener('beforeunload', stop, false)
+          $window.removeEventListener('resize', resizeListener, false)
+        })
+      })()
+
+      /**
+       * KEYBOARD HANDLING
+       *
+       * This should be moved elsewhere, but due to shared dependencies and
+       * elements it's currently here. So basically due to laziness.
+       *
+       * For now, try to keep the whole section as a separate unit as much
+       * as possible.
+       */
+      ;(function() {
+        function isChangeCharsetKey(e) {
+          // Add any special key here for changing charset
+          //console.log('e', e)
+
+          // Chrome/Safari/Opera
+          if (
+            // Mac | Kinesis keyboard | Karabiner | Latin key, Kana key
+          e.keyCode === 0 && e.keyIdentifier === 'U+0010' ||
+
+            // Mac | MacBook Pro keyboard | Latin key, Kana key
+          e.keyCode === 0 && e.keyIdentifier === 'U+0020' ||
+
+            // Win | Lenovo X230 keyboard | Alt+Latin key
+          e.keyCode === 246 && e.keyIdentifier === 'U+00F6' ||
+
+            // Win | Lenovo X230 keyboard | Convert key
+          e.keyCode === 28 && e.keyIdentifier === 'U+001C'
+          ) {
+            return true
+          }
+
+          // Firefox
+          switch (e.key) {
+            case 'Convert': // Windows | Convert key
+            case 'Alphanumeric': // Mac | Latin key
+            case 'RomanCharacters': // Windows/Mac | Latin key
+            case 'KanjiMode': // Windows/Mac | Kana key
+              return true
+          }
+
+          return false
+        }
+
+        function handleSpecialKeys(e) {
+          if (isChangeCharsetKey(e)) {
+            e.preventDefault()
+            control.keyPress('switch_charset')
+            return true
+          }
+
+          return false
+        }
+
+        function keydownListener(e) {
+          // Prevent tab from switching focus to the next element, we only want
+          // that to happen on the device side.
+          if (e.keyCode === 9) {
+            e.preventDefault()
+          }
+          control.keyDown(e.keyCode)
+        }
+
+        function keyupListener(e) {
+          if (!handleSpecialKeys(e)) {
+            control.keyUp(e.keyCode)
+          }
+        }
+
+        function pasteListener(e) {
+          // Prevent value change or the input event sees it. This way we get
+          // the real value instead of any "\n" -> " " conversions we might see
+          // in the input value.
+          e.preventDefault()
+          control.paste(e.clipboardData.getData('text/plain'))
+        }
+
+        function copyListener(e) {
+          e.preventDefault()
+          // This is asynchronous and by the time it returns we will no longer
+          // have access to setData(). In other words it doesn't work. Currently
+          // what happens is that on the first copy, it will attempt to fetch
+          // the clipboard contents. Only on the second copy will it actually
+          // copy that to the clipboard.
+          control.getClipboardContent()
+          if (control.clipboardContent) {
+            e.clipboardData.setData('text/plain', control.clipboardContent)
+          }
+        }
+
+        function inputListener() {
+          // Why use the input event if we don't let it handle pasting? The
+          // reason is that on latest Safari (Version 8.0 (10600.1.25)), if
+          // you use the "Romaji" Kotoeri input method, we'll never get any
+          // keypress events. It also causes us to lose the very first keypress
+          // on the page. Currently I'm not sure if we can fix that one.
+          control.type(this.value)
+          this.value = ''
+        }
+
+        input.bind('keydown', keydownListener)
+        input.bind('keyup', keyupListener)
+        input.bind('input', inputListener)
+        input.bind('paste', pasteListener)
+        input.bind('copy', copyListener)
+      })()
+
+      /**
+       * TOUCH HANDLING
+       *
+       * This should be moved elsewhere, but due to shared dependencies and
+       * elements it's currently here. So basically due to laziness.
+       *
+       * For now, try to keep the whole section as a separate unit as much
+       * as possible.
+       */
+      ;(function() {
+        var slots = []
+        var slotted = Object.create(null)
+        var fingers = []
+        var seq = -1
+        var cycle = 100
+        var fakePinch = false
+        var lastPossiblyBuggyMouseUpEvent = 0
+
+        function nextSeq() {
+          return ++seq >= cycle ? (seq = 0) : seq
+        }
+
+        function createSlots() {
+          // The reverse order is important because slots and fingers are in
+          // opposite sort order. Anyway don't change anything here unless
+          // you understand what it does and why.
+          for (var i = 9; i >= 0; --i) {
+            var finger = createFinger(i)
+            element.append(finger)
+            slots.push(i)
+            fingers.unshift(finger)
+          }
+        }
+
+        function activateFinger(index, x, y, pressure) {
+          var scale = 0.5 + pressure
+          fingers[index].classList.add('active')
+          fingers[index].style[cssTransform] =
+            'translate3d(' + x + 'px,' + y + 'px,0) ' +
+            'scale(' + scale + ',' + scale + ')'
+        }
+
+        function deactivateFinger(index) {
+          fingers[index].classList.remove('active')
+        }
+
+        function deactivateFingers() {
+          for (var i = 0, l = fingers.length; i < l; ++i) {
+            fingers[i].classList.remove('active')
+          }
+        }
+
+        function createFinger(index) {
+          var el = document.createElement('span')
+          el.className = 'finger finger-' + index
+          return el
+        }
+
+        function calculateBounds() {
+          var el = element[0]
+
+          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 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()
+          startMousing()
+
+          var x = e.pageX - screen.bounds.x
+          var y = e.pageY - screen.bounds.y
+          var pressure = 0.5
+          var scaled = scaler.coords(
+                screen.bounds.w
+              , screen.bounds.h
+              , x
+              , y
+              , screen.rotation
+              )
+
+          control.touchDown(nextSeq(), 0, scaled.xP, scaled.yP, pressure)
+
+          if (fakePinch) {
+            control.touchDown(nextSeq(), 1, 1 - scaled.xP, 1 - scaled.yP,
+              pressure)
+          }
+
+          control.touchCommit(nextSeq())
+
+          activateFinger(0, x, y, pressure)
+
+          if (fakePinch) {
+            activateFinger(1, -e.pageX + screen.bounds.x + screen.bounds.w,
+              -e.pageY + screen.bounds.y + screen.bounds.h, pressure)
+          }
+
+          element.bind('mousemove', mouseMoveListener)
+          $document.bind('mouseup', mouseUpListener)
+          $document.bind('mouseleave', mouseUpListener)
+
+          if (lastPossiblyBuggyMouseUpEvent &&
+              lastPossiblyBuggyMouseUpEvent.timeStamp > e.timeStamp) {
+            // We got mouseup before mousedown. See mouseUpBugWorkaroundListener
+            // for details.
+            mouseUpListener(lastPossiblyBuggyMouseUpEvent)
+          }
+          else {
+            lastPossiblyBuggyMouseUpEvent = null
+          }
+        }
+
+        function mouseMoveListener(event) {
+          var e = event
+          if (e.originalEvent) {
+            e = e.originalEvent
+          }
+
+          // Skip secondary click
+          if (e.which === 3) {
+            return
+          }
+          e.preventDefault()
+
+          var addGhostFinger = !fakePinch && e.altKey
+          var deleteGhostFinger = fakePinch && !e.altKey
+
+          fakePinch = e.altKey
+
+          var x = e.pageX - screen.bounds.x
+          var y = e.pageY - screen.bounds.y
+          var pressure = 0.5
+          var scaled = scaler.coords(
+                screen.bounds.w
+              , screen.bounds.h
+              , x
+              , y
+              , screen.rotation
+              )
+
+          control.touchMove(nextSeq(), 0, scaled.xP, scaled.yP, pressure)
+
+          if (addGhostFinger) {
+            control.touchDown(nextSeq(), 1, 1 - scaled.xP, 1 - scaled.yP, pressure)
+          }
+          else if (deleteGhostFinger) {
+            control.touchUp(nextSeq(), 1)
+          }
+          else if (fakePinch) {
+            control.touchMove(nextSeq(), 1, 1 - scaled.xP, 1 - scaled.yP, pressure)
+          }
+
+          control.touchCommit(nextSeq())
+
+          activateFinger(0, x, y, pressure)
+
+          if (deleteGhostFinger) {
+            deactivateFinger(1)
+          }
+          else if (fakePinch) {
+            activateFinger(1, -e.pageX + screen.bounds.x + screen.bounds.w,
+              -e.pageY + screen.bounds.y + screen.bounds.h, pressure)
+          }
+        }
+
+        function mouseUpListener(event) {
+          var e = event
+          if (e.originalEvent) {
+            e = e.originalEvent
+          }
+
+          // Skip secondary click
+          if (e.which === 3) {
+            return
+          }
+          e.preventDefault()
+
+          control.touchUp(nextSeq(), 0)
+
+          if (fakePinch) {
+            control.touchUp(nextSeq(), 1)
+          }
+
+          control.touchCommit(nextSeq())
+
+          deactivateFinger(0)
+
+          if (fakePinch) {
+            deactivateFinger(1)
+          }
+
+          stopMousing()
+        }
+
+        /**
+         * Do NOT remove under any circumstances. Currently, in the latest
+         * Safari (Version 8.0 (10600.1.25)), if an input field is focused
+         * while we do a tap click on an MBP trackpad ("Tap to click" in
+         * Settings), it sometimes causes the mouseup event to trigger before
+         * the mousedown event (but event.timeStamp will be correct). It
+         * doesn't happen in any other browser. The following minimal test
+         * case triggers the same behavior (although less frequently). Keep
+         * tapping and you'll eventually see see two mouseups in a row with
+         * the same counter value followed by a mousedown with a new counter
+         * value. Also, when the bug happens, the cursor in the input field
+         * stops blinking. It may take up to 300 attempts to spot the bug on
+         * a MacBook Pro (Retina, 15-inch, Mid 2014).
+         *
+         *     <!doctype html>
+         *
+         *     <div id="touchable"
+         *       style="width: 100px; height: 100px; background: green"></div>
+         *     <input id="focusable" type="text" />
+         *
+         *     <script>
+         *     var touchable = document.getElementById('touchable')
+         *       , focusable = document.getElementById('focusable')
+         *       , counter = 0
+         *
+         *     function mousedownListener(e) {
+         *       counter += 1
+         *       console.log('mousedown', counter, e, e.timeStamp)
+         *       e.preventDefault()
+         *     }
+         *
+         *     function mouseupListener(e) {
+         *       e.preventDefault()
+         *       console.log('mouseup', counter, e, e.timeStamp)
+         *       focusable.focus()
+         *     }
+         *
+         *     touchable.addEventListener('mousedown', mousedownListener, false)
+         *     touchable.addEventListener('mouseup', mouseupListener, false)
+         *     </script>
+         *
+         * I believe that the bug is caused by some kind of a race condition
+         * in Safari. Using a textarea or a focused contenteditable does not
+         * get rid of the bug. The bug also happens if the text field is
+         * focused manually by the user (not with .focus()).
+         *
+         * It also doesn't help if you .blur() before .focus().
+         *
+         * So basically we'll just have to store the event on mouseup and check
+         * if we should do the browser's job in the mousedown handler.
+         */
+        function mouseUpBugWorkaroundListener(e) {
+          lastPossiblyBuggyMouseUpEvent = e
+        }
+
+        function startMousing() {
+          control.gestureStart(nextSeq())
+          input[0].focus()
+        }
+
+        function stopMousing() {
+          element.unbind('mousemove', mouseMoveListener)
+          $document.unbind('mouseup', mouseUpListener)
+          $document.unbind('mouseleave', mouseUpListener)
+          deactivateFingers()
+          control.gestureStop(nextSeq())
+        }
+
+        function touchStartListener(event) {
+          var e = event
+          e.preventDefault()
+
+          //Make it jQuery compatible also
+          if (e.originalEvent) {
+            e = e.originalEvent
+          }
+
+          calculateBounds()
+
+          if (e.touches.length === e.changedTouches.length) {
+            startTouching()
+          }
+
+          var currentTouches = Object.create(null)
+          var i, l
+
+          for (i = 0, l = e.touches.length; i < l; ++i) {
+            currentTouches[e.touches[i].identifier] = 1
+          }
+
+          function maybeLostTouchEnd(id) {
+            return !(id in currentTouches)
+          }
+
+          // We might have lost a touchend event due to various edge cases
+          // (literally) such as dragging from the bottom of the screen so that
+          // the control center appears. If so, let's ask for a reset.
+          if (Object.keys(slotted).some(maybeLostTouchEnd)) {
+            Object.keys(slotted).forEach(function(id) {
+              slots.push(slotted[id])
+              delete slotted[id]
+            })
+            slots.sort().reverse()
+            control.touchReset(nextSeq())
+            deactivateFingers()
+          }
+
+          if (!slots.length) {
+            // This should never happen but who knows...
+            throw new Error('Ran out of multitouch slots')
+          }
+
+          for (i = 0, l = e.changedTouches.length; i < l; ++i) {
+            var touch = e.changedTouches[i]
+            var slot = slots.pop()
+            var x = touch.pageX - screen.bounds.x
+            var y = touch.pageY - screen.bounds.y
+            var pressure = touch.force || 0.5
+            var scaled = scaler.coords(
+                  screen.bounds.w
+                , screen.bounds.h
+                , x
+                , y
+                , screen.rotation
+                )
+
+            slotted[touch.identifier] = slot
+            control.touchDown(nextSeq(), slot, scaled.xP, scaled.yP, pressure)
+            activateFinger(slot, x, y, pressure)
+          }
+
+          element.bind('touchmove', touchMoveListener)
+          $document.bind('touchend', touchEndListener)
+          $document.bind('touchleave', touchEndListener)
+
+          control.touchCommit(nextSeq())
+        }
+
+        function touchMoveListener(event) {
+          var e = event
+          e.preventDefault()
+
+          if (e.originalEvent) {
+            e = e.originalEvent
+          }
+
+          for (var i = 0, l = e.changedTouches.length; i < l; ++i) {
+            var touch = e.changedTouches[i]
+            var slot = slotted[touch.identifier]
+            var x = touch.pageX - screen.bounds.x
+            var y = touch.pageY - screen.bounds.y
+            var pressure = touch.force || 0.5
+            var scaled = scaler.coords(
+                  screen.bounds.w
+                , screen.bounds.h
+                , x
+                , y
+                , screen.rotation
+                )
+
+            control.touchMove(nextSeq(), slot, scaled.xP, scaled.yP, pressure)
+            activateFinger(slot, x, y, pressure)
+          }
+
+          control.touchCommit(nextSeq())
+        }
+
+        function touchEndListener(event) {
+          var e = event
+          if (e.originalEvent) {
+            e = e.originalEvent
+          }
+
+          var foundAny = false
+
+          for (var i = 0, l = e.changedTouches.length; i < l; ++i) {
+            var touch = e.changedTouches[i]
+            var slot = slotted[touch.identifier]
+            if (typeof slot === 'undefined') {
+              // We've already disposed of the contact. We may have gotten a
+              // touchend event for the same contact twice.
+              continue
+            }
+            delete slotted[touch.identifier]
+            slots.push(slot)
+            control.touchUp(nextSeq(), slot)
+            deactivateFinger(slot)
+            foundAny = true
+          }
+
+          if (foundAny) {
+            control.touchCommit(nextSeq())
+            if (!e.touches.length) {
+              stopTouching()
+            }
+          }
+        }
+
+        function startTouching() {
+          control.gestureStart(nextSeq())
+        }
+
+        function stopTouching() {
+          element.unbind('touchmove', touchMoveListener)
+          $document.unbind('touchend', touchEndListener)
+          $document.unbind('touchleave', touchEndListener)
+          deactivateFingers()
+          control.gestureStop(nextSeq())
+        }
+
+        element.on('touchstart', touchStartListener)
+        element.on('mousedown', mouseDownListener)
+        element.on('mouseup', mouseUpBugWorkaroundListener)
+
+        createSlots()
+      })()
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/screen/screen-keyboard/index.js b/crowdstf/res/app/components/stf/screen/screen-keyboard/index.js
new file mode 100644
index 0000000..a09c8e2
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/screen-keyboard/index.js
@@ -0,0 +1,6 @@
+require('./screen-keyboard.css')
+
+module.exports = angular.module('stf.screen-keyboard', [
+
+])
+  .directive('screenKeyboard', require('./screen-keyboard-directive'))
diff --git a/crowdstf/res/app/components/stf/screen/screen-keyboard/screen-keyboard-directive.js b/crowdstf/res/app/components/stf/screen/screen-keyboard/screen-keyboard-directive.js
new file mode 100644
index 0000000..62789b6
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/screen-keyboard/screen-keyboard-directive.js
@@ -0,0 +1,10 @@
+module.exports = function screenKeyboardDirective() {
+  return {
+    restrict: 'E',
+    template: require('./screen-keyboard.jade'),
+    link: function(scope, element) {
+      element.find('input')
+
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/screen/screen-keyboard/screen-keyboard-spec.js b/crowdstf/res/app/components/stf/screen/screen-keyboard/screen-keyboard-spec.js
new file mode 100644
index 0000000..506bab3
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/screen-keyboard/screen-keyboard-spec.js
@@ -0,0 +1,23 @@
+describe('screenKeyboard', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div screen-keyboard name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/screen/screen-keyboard/screen-keyboard.css b/crowdstf/res/app/components/stf/screen/screen-keyboard/screen-keyboard.css
new file mode 100644
index 0000000..b140732
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/screen-keyboard/screen-keyboard.css
@@ -0,0 +1,3 @@
+.stf-screen-keyboard {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/components/stf/screen/screen-keyboard/screen-keyboard.jade b/crowdstf/res/app/components/stf/screen/screen-keyboard/screen-keyboard.jade
new file mode 100644
index 0000000..d257217
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/screen-keyboard/screen-keyboard.jade
@@ -0,0 +1,2 @@
+input(type='password', tabindex='40', accesskey='C',
+  autocorrect='off', autocapitalize='off').stf-screen-keyboard
diff --git a/crowdstf/res/app/components/stf/screen/screen-touch/index.js b/crowdstf/res/app/components/stf/screen/screen-touch/index.js
new file mode 100644
index 0000000..adbdadc
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/screen-touch/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.screen-touch', [
+
+])
+  .directive('screenTouch', require('./screen-touch-directive'))
diff --git a/crowdstf/res/app/components/stf/screen/screen-touch/screen-touch-directive.js b/crowdstf/res/app/components/stf/screen/screen-touch/screen-touch-directive.js
new file mode 100644
index 0000000..83169d7
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/screen-touch/screen-touch-directive.js
@@ -0,0 +1,8 @@
+module.exports = function screenTouchDirective() {
+  return {
+    restrict: 'A',
+    link: function() {
+
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/screen/screen-touch/screen-touch-spec.js b/crowdstf/res/app/components/stf/screen/screen-touch/screen-touch-spec.js
new file mode 100644
index 0000000..a7536e7
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/screen-touch/screen-touch-spec.js
@@ -0,0 +1,23 @@
+describe('screenTouch', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div screen-touch name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/screen/screen.css b/crowdstf/res/app/components/stf/screen/screen.css
new file mode 100644
index 0000000..a7a4c5d
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/screen.css
@@ -0,0 +1,25 @@
+.screen-error {
+  width: 100%;
+  height: 100%;
+  background: rgba(0, 0, 0, 0.7);
+  z-index: 100;
+  position: absolute;
+  /*pointer-events: none; */
+}
+
+.screen-error .nothing-to-show {
+  color: #eee;
+}
+
+.screen-error .screen-error-message {
+  position: relative;
+  top: 15%;
+  transform: translateY(-15%);
+  text-align: center;
+}
+
+.screen-error .screen-error-alert {
+  padding: 15px;
+  margin-bottom: 20px;
+  color: #ccc;
+}
diff --git a/crowdstf/res/app/components/stf/screen/screen.jade b/crowdstf/res/app/components/stf/screen/screen.jade
new file mode 100644
index 0000000..8de1f94
--- /dev/null
+++ b/crowdstf/res/app/components/stf/screen/screen.jade
@@ -0,0 +1,16 @@
+div.positioner
+  canvas.screen(ng-show='device')
+  canvas.hacky-stretcher(width=1, height=1)
+div(ng-if='displayError').screen-error
+  .screen-error-message
+    nothing-to-show(message='{{"No device screen"|translate}}', icon='fa-eye-slash', ng-show='true')
+
+    .well
+      .screen-error-alert(ng-show='displayError === "secure"', translate) The current view is marked secure and cannot be viewed remotely.
+      .screen-error-alert(ng-show='displayError === "timeout"', translate) Retrieving the device screen has timed out.
+
+    .well
+      button(ng-click='retryLoadingScreen()', style='text-align: center;').btn.btn-primary.btn-block
+        i.fa.fa-refresh
+        span(translate) Retry
+input(type='password', tabindex='40', accesskey='C', autocorrect='off', autocapitalize='off', focus-element='$root.screenFocus')
diff --git a/crowdstf/res/app/components/stf/settings/index.js b/crowdstf/res/app/components/stf/settings/index.js
new file mode 100644
index 0000000..369750e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/settings/index.js
@@ -0,0 +1,5 @@
+module.exports = angular.module('stf/settings', [
+  require('stf/user').name,
+  require('stf/socket').name
+])
+  .factory('SettingsService', require('./settings-service'))
diff --git a/crowdstf/res/app/components/stf/settings/settings-service.js b/crowdstf/res/app/components/stf/settings/settings-service.js
new file mode 100644
index 0000000..c649fff
--- /dev/null
+++ b/crowdstf/res/app/components/stf/settings/settings-service.js
@@ -0,0 +1,115 @@
+var _ = require('lodash')
+
+module.exports = function SettingsServiceFactory(
+  $rootScope
+, UserService
+, socket
+) {
+  var SettingsService = {}
+
+  var settings = UserService.currentUser.settings || {}
+  var syncListeners = []
+
+  function createListener(object, options, monitor) {
+    var source = options.source || options.target
+    return function() {
+      var value = object[options.target] = (source in settings) ?
+        settings[source] :
+        options.defaultValue
+
+      if (monitor) {
+        monitor(value)
+      }
+    }
+  }
+
+  function applyDelta(delta) {
+    // TODO: This causes chaos
+    $rootScope.safeApply(function() {
+      _.merge(settings, delta, function(a, b) {
+        // New Arrays overwrite old Arrays
+        if (_.isArray(b)) {
+          return b
+        }
+      })
+
+      for (var i = 0, l = syncListeners.length; i < l; ++i) {
+        syncListeners[i]()
+      }
+    })
+  }
+
+  SettingsService.update = function(delta) {
+    socket.emit('user.settings.update', delta)
+    applyDelta(delta)
+  }
+
+  SettingsService.get = function(key) {
+    return settings[key]
+  }
+
+  SettingsService.set = function(key, value) {
+    var delta = Object.create(null)
+    delta[key] = value
+    SettingsService.update(delta)
+  }
+
+  SettingsService.reset = function() {
+    socket.emit('user.settings.reset')
+    settings = {}
+    applyDelta(null)
+  }
+
+  SettingsService.bind = function(scope, options) {
+    function value(possibleValue, defaultValue) {
+      return (typeof possibleValue !== 'undefined') ? possibleValue : defaultValue
+    }
+
+    var source = options.source || options.target
+    var defaultValue = value(options.defaultValue, scope[options.target])
+
+    scope.$watch(
+      options.target
+    , function(newValue, oldValue) {
+        // Skip initial value.
+        if (newValue !== oldValue) {
+          var delta = Object.create(null)
+          delta[source] = angular.copy(newValue)
+          SettingsService.update(delta)
+        }
+      }
+    , true
+    )
+
+    scope.$watch(
+      function() {
+        return settings[source]
+      }
+    , function(newValue, oldValue) {
+        // Skip initial value. The new value might not be different if
+        // settings were reset, for example. In that case we fall back
+        // to the default value.
+        if (newValue !== oldValue) {
+          scope[options.target] = value(newValue, defaultValue)
+        }
+      }
+    , true
+    )
+
+    scope[options.target] = value(settings[source], defaultValue)
+  }
+
+  SettingsService.sync = function(object, options, monitor) {
+    var listener = createListener(object, options, monitor)
+    listener() // Initialize
+    return syncListeners.push(listener) - 1
+  }
+
+  SettingsService.unsync = function(id) {
+    syncListeners.splice(id, 1)
+  }
+
+  socket.on('user.settings.update', applyDelta)
+
+  return SettingsService
+}
diff --git a/crowdstf/res/app/components/stf/socket/index.js b/crowdstf/res/app/components/stf/socket/index.js
new file mode 100644
index 0000000..205a50d
--- /dev/null
+++ b/crowdstf/res/app/components/stf/socket/index.js
@@ -0,0 +1,6 @@
+module.exports = angular.module('stf.socket', [
+  //TODO: Refactor version update out to its own Ctrl
+  require('stf/app-state').name,
+  require('stf/common-ui/modals/version-update').name
+])
+  .factory('socket', require('./socket-service'))
diff --git a/crowdstf/res/app/components/stf/socket/socket-service.js b/crowdstf/res/app/components/stf/socket/socket-service.js
new file mode 100644
index 0000000..e11d82b
--- /dev/null
+++ b/crowdstf/res/app/components/stf/socket/socket-service.js
@@ -0,0 +1,45 @@
+var io = require('socket.io')
+
+module.exports = function SocketFactory(
+  $rootScope
+, VersionUpdateService
+, AppState
+) {
+  var websocketUrl = AppState.config.websocketUrl || ''
+
+  var socket = io(websocketUrl, {
+    reconnection: false, transports: ['websocket']
+  })
+
+  socket.scoped = function($scope) {
+    var listeners = []
+
+    $scope.$on('$destroy', function() {
+      listeners.forEach(function(listener) {
+        socket.removeListener(listener.event, listener.handler)
+      })
+    })
+
+    return {
+      on: function(event, handler) {
+        listeners.push({
+          event: event, handler: handler
+        })
+        socket.on(event, handler)
+        return this
+      }
+    }
+  }
+
+  socket.on('outdated', function() {
+    VersionUpdateService.open()
+  })
+
+  socket.on('socket.ip', function(ip) {
+    $rootScope.$apply(function() {
+      socket.ip = ip
+    })
+  })
+
+  return socket
+}
diff --git a/crowdstf/res/app/components/stf/socket/socket-state/index.js b/crowdstf/res/app/components/stf/socket/socket-state/index.js
new file mode 100644
index 0000000..7722f72
--- /dev/null
+++ b/crowdstf/res/app/components/stf/socket/socket-state/index.js
@@ -0,0 +1,27 @@
+module.exports = angular.module('stf/socket/socket-state', [
+  require('stf/socket').name,
+  require('stf/common-ui/safe-apply').name,
+  require('stf/common-ui/notifications').name,
+  require('stf/common-ui/refresh-page').name,
+  require('stf/common-ui/modals/socket-disconnected').name
+])
+  .directive('socketState', require('./socket-state-directive'))
+  .config([
+    '$provide', function($provide) {
+      return $provide.decorator('$rootScope', [
+        '$delegate', function($delegate) {
+          $delegate.safeApply = function(fn) {
+            var phase = $delegate.$$phase
+            if (phase === '$apply' || phase === '$digest') {
+              if (fn && typeof fn === 'function') {
+                fn()
+              }
+            } else {
+              $delegate.$apply(fn)
+            }
+          }
+          return $delegate
+        }
+      ])
+    }
+  ])
diff --git a/crowdstf/res/app/components/stf/socket/socket-state/socket-state-directive.js b/crowdstf/res/app/components/stf/socket/socket-state/socket-state-directive.js
new file mode 100644
index 0000000..2e7930e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/socket/socket-state/socket-state-directive.js
@@ -0,0 +1,99 @@
+module.exports = function SocketStateDirectiveFactory(
+  socket
+, growl
+, gettext
+, $filter
+, SocketDisconnectedService
+, $window
+) {
+  return {
+    restrict: 'EA',
+    template: require('./socket-state.jade'),
+    link: function(scope) {
+      var hasFailedOnce = false
+
+      function setState(state) {
+        switch (state) {
+        case 'connect':
+          if (hasFailedOnce) {
+            growl.success('<h4>WebSocket</h4>' + $filter('translate')(
+              gettext('Connected successfully.')) +
+            '<refresh-page></refresh-page>', {ttl: 2000})
+          }
+          break
+        case 'disconnect':
+          SocketDisconnectedService.open(
+            gettext('Socket connection was lost'))
+          break
+        case 'connect_error':
+        case 'error':
+          SocketDisconnectedService.open(
+            gettext('Error'))
+          break
+        case 'reconnect_failed':
+          SocketDisconnectedService.open(
+            gettext('Error while reconnecting'))
+          break
+        case 'reconnect':
+          growl.success('<h4>WebSocket</h4>' + $filter('translate')(
+            gettext('Reconnected successfully.')), {ttl: -1})
+          break
+        }
+
+        scope.$apply(function() {
+          scope.socketState = state
+        })
+      }
+
+      var socketListeners = {
+        connect: function() {
+          setState('connect')
+        }
+      , disconnect: function() {
+          setState('disconnect')
+          hasFailedOnce = true
+        }
+      , error: function() {
+          setState('error')
+          hasFailedOnce = true
+        }
+      , connect_error: function() {
+          setState('connect_error')
+          hasFailedOnce = true
+        }
+      , reconnect_error: function() {
+          setState('reconnect_error')
+          hasFailedOnce = true
+        }
+      , reconnect_failed: function() {
+          setState('reconnect_failed')
+          hasFailedOnce = true
+        }
+      , reconnect: function() {
+          setState('reconnect')
+          hasFailedOnce = true
+        }
+      }
+
+      Object.keys(socketListeners).forEach(function(event) {
+        socket.on(event, socketListeners[event])
+      })
+
+      function unloadListener() {
+        // On at least Firefox, the socket connection will close
+        // before the page unloads, causing the "socket disconnected"
+        // message to display on every unload. To prevent that from
+        // happening, let's unbind all the listeners when it's time.
+        Object.keys(socketListeners).forEach(function(event) {
+          socket.removeListener(event, socketListeners[event])
+        })
+      }
+
+      $window.addEventListener('beforeunload', unloadListener, false)
+
+      scope.$on('$destroy', function() {
+        $window.removeEventListener('beforeunload', unloadListener, false)
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/socket/socket-state/socket-state.jade b/crowdstf/res/app/components/stf/socket/socket-state/socket-state.jade
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crowdstf/res/app/components/stf/socket/socket-state/socket-state.jade
diff --git a/crowdstf/res/app/components/stf/standalone/index.js b/crowdstf/res/app/components/stf/standalone/index.js
new file mode 100644
index 0000000..a785be2
--- /dev/null
+++ b/crowdstf/res/app/components/stf/standalone/index.js
@@ -0,0 +1,17 @@
+require('./standalone.css')
+
+module.exports = angular.module('device-control.standalone', [
+  require('stf/device').name,
+  require('stf/control').name,
+  require('stf/screen').name,
+  require('stf/settings').name,
+  require('stf/screen/scaling').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/device-control/standalone/standalone.jade',
+      require('./standalone.jade')
+    )
+  }])
+  .controller('StandaloneCtrl', require('./standalone-controller'))
+  .factory('StandaloneService', require('./standalone-service'))
+  .directive('standalone', require('./standalone-directive'))
diff --git a/crowdstf/res/app/components/stf/standalone/standalone-controller.js b/crowdstf/res/app/components/stf/standalone/standalone-controller.js
new file mode 100644
index 0000000..8bd15d3
--- /dev/null
+++ b/crowdstf/res/app/components/stf/standalone/standalone-controller.js
@@ -0,0 +1,3 @@
+module.exports = function StandaloneCtrl() {
+
+}
diff --git a/crowdstf/res/app/components/stf/standalone/standalone-directive.js b/crowdstf/res/app/components/stf/standalone/standalone-directive.js
new file mode 100644
index 0000000..4662f6b
--- /dev/null
+++ b/crowdstf/res/app/components/stf/standalone/standalone-directive.js
@@ -0,0 +1,10 @@
+module.exports = function standaloneDirective($rootScope, $location) {
+  return {
+    restrict: 'AE',
+    link: function() {
+      //$rootScope.standalone = $window.history.length < 2
+      var standalone = $location.search().standalone
+      $rootScope.standalone = standalone
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/standalone/standalone-service.js b/crowdstf/res/app/components/stf/standalone/standalone-service.js
new file mode 100644
index 0000000..d37d894
--- /dev/null
+++ b/crowdstf/res/app/components/stf/standalone/standalone-service.js
@@ -0,0 +1,116 @@
+module.exports =
+  function StandaloneServiceFactory($window, $rootScope, SettingsService,
+    ScalingService, GroupService, $timeout) {
+    var service = {}
+
+    //SettingsService.sync($scope, 'ControlWindow', {
+    //  controlWindowWidth: 600,
+    //  controlWindowHeight: 900,
+    //  controlWindowTop: 50,
+    //  controlWindowLeft: 50
+    //})
+
+    var screenWidth = $window.screen.availWidth || $window.screen.width || 1024
+    var screenHeight = $window.screen.availHeight || $window.screen.height ||
+      768
+    var windowSizeRatio = 0.5
+
+    function fitDeviceInGuestScreen(device) {
+      //console.log('device.width', device.width)
+      //console.log('device', device)
+
+      var screen = {
+        scaler: ScalingService.coordinator(
+          device.display.width, device.display.height
+        ),
+        rotation: device.display.rotation,
+        bounds: {
+          x: 0, y: 0, w: screenWidth, h: screenHeight
+        }
+      }
+
+      var projectedSize = screen.scaler.projectedSize(
+        screen.bounds.w * windowSizeRatio,
+        screen.bounds.h * windowSizeRatio,
+        screen.rotation
+      )
+
+      return projectedSize
+    }
+
+
+    service.open = function(device) {
+      var url = '#!/c/' + (device.serial ? device.serial : '') + '?standalone'
+
+      var projected = fitDeviceInGuestScreen(device)
+
+      var features = [
+        'width=' + projected.width,
+        'height=' + projected.height,
+        'top=' + (screenHeight / 4),
+        'left=' + (screenWidth / 5),
+        'toolbar=no',
+        'location=no',
+        'dialog=yes',
+        'personalbar=no',
+        'directories=no',
+        'status=no',
+        'menubar=no',
+        'scrollbars=no',
+        'copyhistory=no',
+        'resizable=yes'
+      ].join(',')
+
+      var newWindow = $window.open(url, 'STF-' + device.serial, features)
+
+      function setWindowTitle(newWindow, device) {
+        var windowTitle = 'STF - ' + device.name
+        if (device.name !== device.model) {
+          windowTitle += ' (' + device.model + ')'
+        }
+        //windowTitle += ' (' + device.serial + ')'
+
+        if (newWindow.document) {
+          newWindow.document.title = windowTitle
+        }
+
+        $timeout(function() {
+          if (newWindow.document) {
+            newWindow.document.title = windowTitle
+          }
+        }, 400)
+      }
+
+      setWindowTitle(newWindow, device)
+
+
+      newWindow.onbeforeunload = function() {
+
+        // TODO: check for usage
+        GroupService.kick(device).then(function() {
+          $rootScope.$digest()
+        })
+
+        // TODO: save coordinates
+        //  $scope.controlWindowWidth = windowOpen.innerWidth
+        //  $scope.controlWindowHeight = windowOpen.innerHeight
+        //  $scope.controlWindowTop = windowOpen.screenTop
+        //  $scope.controlWindowLeft = windowOpen.screenLeft
+      }
+
+      // TODO: Resize on-demand
+      //newWindow.onresize = function (e) {
+      //  var windowWidth =  e.target.outerWidth
+      //  var windowHeight =  e.target.outerHeight
+      //
+      //  var newWindowWidth = Math.floor(projected.width * windowHeight / projected.height)
+      //  console.log('newWindowWidth', newWindowWidth)
+      //  console.log('windowWidth', windowWidth)
+      //
+      //  newWindow.resizeTo(newWindowWidth, windowHeight)
+      //}
+    }
+
+
+    return service
+  }
diff --git a/crowdstf/res/app/components/stf/standalone/standalone.css b/crowdstf/res/app/components/stf/standalone/standalone.css
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crowdstf/res/app/components/stf/standalone/standalone.css
diff --git a/crowdstf/res/app/components/stf/standalone/standalone.jade b/crowdstf/res/app/components/stf/standalone/standalone.jade
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crowdstf/res/app/components/stf/standalone/standalone.jade
diff --git a/crowdstf/res/app/components/stf/storage/index.js b/crowdstf/res/app/components/stf/storage/index.js
new file mode 100644
index 0000000..fcf64f2
--- /dev/null
+++ b/crowdstf/res/app/components/stf/storage/index.js
@@ -0,0 +1,6 @@
+require('ng-file-upload')
+
+module.exports = angular.module('stf/storage', [
+  'angularFileUpload'
+])
+  .factory('StorageService', require('./storage-service'))
diff --git a/crowdstf/res/app/components/stf/storage/storage-service.js b/crowdstf/res/app/components/stf/storage/storage-service.js
new file mode 100644
index 0000000..7c3a1f7
--- /dev/null
+++ b/crowdstf/res/app/components/stf/storage/storage-service.js
@@ -0,0 +1,48 @@
+var Promise = require('bluebird')
+
+module.exports = function StorageServiceFactory($http, $upload) {
+  var service = {}
+
+  service.storeUrl = function(type, url) {
+    return $http({
+      url: '/s/download/' + type
+    , method: 'POST'
+    , data: {
+        url: url
+      }
+    })
+  }
+
+  service.storeFile = function(type, files, options) {
+    var resolver = Promise.defer()
+    var input = options.filter ? files.filter(options.filter) : files
+
+    if (input.length) {
+      $upload.upload({
+          url: '/s/upload/' + type
+        , method: 'POST'
+        , file: input
+        })
+        .then(
+          function(value) {
+            resolver.resolve(value)
+          }
+        , function(err) {
+            resolver.reject(err)
+          }
+        , function(progressEvent) {
+            resolver.progress(progressEvent)
+          }
+        )
+    }
+    else {
+      var err = new Error('No input files')
+      err.code = 'no_input_files'
+      resolver.reject(err)
+    }
+
+    return resolver.promise
+  }
+
+  return service
+}
diff --git a/crowdstf/res/app/components/stf/text-history/index.js b/crowdstf/res/app/components/stf/text-history/index.js
new file mode 100644
index 0000000..7349613
--- /dev/null
+++ b/crowdstf/res/app/components/stf/text-history/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.text-history', [
+
+])
+  .directive('textHistory', require('./text-history-directive'))
diff --git a/crowdstf/res/app/components/stf/text-history/text-history-directive.js b/crowdstf/res/app/components/stf/text-history/text-history-directive.js
new file mode 100644
index 0000000..d0b183c
--- /dev/null
+++ b/crowdstf/res/app/components/stf/text-history/text-history-directive.js
@@ -0,0 +1,12 @@
+//input.form-control(type='text', placeholder='', ng-model='selectedAction',
+// typeahead='action for action in activityActions')
+
+module.exports = function textHistoryDirective() {
+  return {
+    restrict: 'A',
+    template: '',
+    link: function() {
+
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/text-history/text-history-spec.js b/crowdstf/res/app/components/stf/text-history/text-history-spec.js
new file mode 100644
index 0000000..3ecd7e2
--- /dev/null
+++ b/crowdstf/res/app/components/stf/text-history/text-history-spec.js
@@ -0,0 +1,23 @@
+describe('textHistory', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div text-history name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/timeline/index.js b/crowdstf/res/app/components/stf/timeline/index.js
new file mode 100644
index 0000000..4c72af1
--- /dev/null
+++ b/crowdstf/res/app/components/stf/timeline/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.timeline', [
+
+])
+  .factory('TimelineService', require('./timeline-service'))
diff --git a/crowdstf/res/app/components/stf/timeline/timeline-service.js b/crowdstf/res/app/components/stf/timeline/timeline-service.js
new file mode 100644
index 0000000..1c3a5e9
--- /dev/null
+++ b/crowdstf/res/app/components/stf/timeline/timeline-service.js
@@ -0,0 +1,41 @@
+module.exports = function TimelineServiceFactory() {
+  var TimelineService = {}
+
+  TimelineService.lines = []
+
+  function addLine(line, type) {
+    TimelineService.lines.push({
+      type: type,
+      title: line.title,
+      message: line.message,
+      serial: angular.copy(line.serial),
+      time: Date.now()
+    })
+  }
+
+  TimelineService.info = function(line) {
+    addLine(line, 'info')
+  }
+
+  TimelineService.warn = function(line) {
+    addLine(line, 'warn')
+  }
+
+  TimelineService.success = function(line) {
+    addLine(line, 'success')
+  }
+
+  TimelineService.error = function(line) {
+    addLine(line, 'error')
+  }
+
+  TimelineService.fatal = function(line) {
+    addLine(line, 'fatal')
+  }
+
+  TimelineService.clear = function() {
+    TimelineService.lines = []
+  }
+
+  return TimelineService
+}
diff --git a/crowdstf/res/app/components/stf/timeline/timeline-spec.js b/crowdstf/res/app/components/stf/timeline/timeline-spec.js
new file mode 100644
index 0000000..3f74122
--- /dev/null
+++ b/crowdstf/res/app/components/stf/timeline/timeline-spec.js
@@ -0,0 +1,11 @@
+describe('TimelineService', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  it('should ...', inject(function() {
+
+	//expect(TimelineService.doSomething()).toEqual('something');
+
+  }))
+
+})
diff --git a/crowdstf/res/app/components/stf/timelines/index.js b/crowdstf/res/app/components/stf/timelines/index.js
new file mode 100644
index 0000000..8b9b1f5
--- /dev/null
+++ b/crowdstf/res/app/components/stf/timelines/index.js
@@ -0,0 +1,6 @@
+require('./timelines.css')
+
+module.exports = angular.module('stf.timelines', [
+
+])
+  .directive('timelines', require('./timelines-directive'))
diff --git a/crowdstf/res/app/components/stf/timelines/timeline-message/index.js b/crowdstf/res/app/components/stf/timelines/timeline-message/index.js
new file mode 100644
index 0000000..185c697
--- /dev/null
+++ b/crowdstf/res/app/components/stf/timelines/timeline-message/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.timeline-message', [
+
+])
+  .directive('timelineMessage', require('./timeline-message-directive'))
diff --git a/crowdstf/res/app/components/stf/timelines/timeline-message/timeline-message-directive.js b/crowdstf/res/app/components/stf/timelines/timeline-message/timeline-message-directive.js
new file mode 100644
index 0000000..dd56ee2
--- /dev/null
+++ b/crowdstf/res/app/components/stf/timelines/timeline-message/timeline-message-directive.js
@@ -0,0 +1,41 @@
+module.exports = function timelineMessageDirective(Timelines, $sce, $interpolate) {
+
+  var defaults = {
+    message: '',
+    type: 'info',
+    ttl: 5000
+  }
+
+  return {
+    restrict: 'AE',
+    replace: true,
+    template: '',
+    transclude: true,
+    link: function(scope, iElem, iAttrs, ctrls, transcludeFn) {
+
+      var options = angular.extend({}, defaults, scope.$eval(iAttrs.timelineMessage))
+
+      transcludeFn(function(elem, scope) {
+        var e,
+          html,
+          interpolateFn,
+          safeHtml
+
+        // Create temporary wrapper element so we can grab the inner html
+        e = angular.element(document.createElement('div'))
+        e.append(elem)
+        html = e.html()
+
+        // Interpolate expressions in current scope
+        interpolateFn = $interpolate(html)
+        html = interpolateFn(scope)
+
+        // Tell Angular the HTML can be trusted so it can be used in ng-bind-html
+        safeHtml = $sce.trustAsHtml(html)
+
+        // Add notification
+        Timelines.add(safeHtml, options.type, options.ttl)
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/timelines/timeline-message/timeline-message-spec.js b/crowdstf/res/app/components/stf/timelines/timeline-message/timeline-message-spec.js
new file mode 100644
index 0000000..804bc60
--- /dev/null
+++ b/crowdstf/res/app/components/stf/timelines/timeline-message/timeline-message-spec.js
@@ -0,0 +1,23 @@
+describe('timelineMessage', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div timeline-message name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/timelines/timelines-directive.js b/crowdstf/res/app/components/stf/timelines/timelines-directive.js
new file mode 100644
index 0000000..e010cb3
--- /dev/null
+++ b/crowdstf/res/app/components/stf/timelines/timelines-directive.js
@@ -0,0 +1,12 @@
+module.exports = function timelinesDirective(Timelines) {
+  return {
+    restrict: 'AE',
+    replace: false,
+    scope: {},
+    template: require('./timelines.jade'),
+    link: function(scope) {
+      scope.cssPrefix = Timelines.options.cssPrefix
+      scope.notifications = Timelines.notifications
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/timelines/timelines-spec.js b/crowdstf/res/app/components/stf/timelines/timelines-spec.js
new file mode 100644
index 0000000..c6f0c5c
--- /dev/null
+++ b/crowdstf/res/app/components/stf/timelines/timelines-spec.js
@@ -0,0 +1,23 @@
+describe('timelines', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, compile
+
+  beforeEach(inject(function($rootScope, $compile) {
+    scope = $rootScope.$new()
+    compile = $compile
+  }))
+
+  it('should ...', function() {
+
+    /*
+     To test your directive, you need to create some html that would use your directive,
+     send that through compile() then compare the results.
+
+     var element = compile('<div timelines name="name">hi</div>')(scope);
+     expect(element.text()).toBe('hello, world');
+     */
+
+  })
+})
diff --git a/crowdstf/res/app/components/stf/timelines/timelines.css b/crowdstf/res/app/components/stf/timelines/timelines.css
new file mode 100644
index 0000000..ccbb6c5
--- /dev/null
+++ b/crowdstf/res/app/components/stf/timelines/timelines.css
@@ -0,0 +1,3 @@
+.stf-timelines {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/components/stf/timelines/timelines.jade b/crowdstf/res/app/components/stf/timelines/timelines.jade
new file mode 100644
index 0000000..ea2ce70
--- /dev/null
+++ b/crowdstf/res/app/components/stf/timelines/timelines.jade
@@ -0,0 +1,5 @@
+div.stf-timelines
+  ui.list-unstyled
+    li(ng-repeat='(id, notification) in notifications')
+      div(ng-class='{{cssPrefix}} {{cssPrefix}}-{{notification.type}}')
+        div(ng-bind-html='notification.message')
diff --git a/crowdstf/res/app/components/stf/tokens/access-token-service.js b/crowdstf/res/app/components/stf/tokens/access-token-service.js
new file mode 100644
index 0000000..38b9db6
--- /dev/null
+++ b/crowdstf/res/app/components/stf/tokens/access-token-service.js
@@ -0,0 +1,35 @@
+module.exports = function AccessTokenServiceFactory(
+  $rootScope
+, $http
+, socket
+) {
+  var AccessTokenService = {}
+
+  AccessTokenService.getAccessTokens = function() {
+    return $http.get('/app/api/v1/accessTokens')
+  }
+
+  AccessTokenService.generateAccessToken = function(title) {
+    socket.emit('user.keys.accessToken.generate', {
+      title: title
+    })
+  }
+
+  AccessTokenService.removeAccessToken = function(title) {
+    socket.emit('user.keys.accessToken.remove', {
+      title: title
+    })
+  }
+
+  socket.on('user.keys.accessToken.generated', function(token) {
+    $rootScope.$broadcast('user.keys.accessTokens.generated', token)
+    $rootScope.$apply()
+  })
+
+  socket.on('user.keys.accessToken.removed', function() {
+    $rootScope.$broadcast('user.keys.accessTokens.updated')
+    $rootScope.$apply()
+  })
+
+  return AccessTokenService
+}
diff --git a/crowdstf/res/app/components/stf/tokens/generate-access-token/generate-access-token-directive.js b/crowdstf/res/app/components/stf/tokens/generate-access-token/generate-access-token-directive.js
new file mode 100644
index 0000000..1e12024
--- /dev/null
+++ b/crowdstf/res/app/components/stf/tokens/generate-access-token/generate-access-token-directive.js
@@ -0,0 +1,25 @@
+module.exports = function generateAccessTokenDirective() {
+  return {
+    restrict: 'EA',
+    replace: true,
+    scope: {
+      showGenerate: '='
+    },
+    template: require('./generate-access-token.jade'),
+    controller: function($scope, AccessTokenService) {
+      $scope.generateForm = {
+        title: ''
+      }
+
+      $scope.generateToken = function() {
+        AccessTokenService.generateAccessToken($scope.generateForm.title)
+        $scope.closeGenerateToken()
+      }
+
+      $scope.closeGenerateToken = function() {
+        $scope.title = ''
+        $scope.showGenerate = false
+      }
+    }
+  }
+}
diff --git a/crowdstf/res/app/components/stf/tokens/generate-access-token/generate-access-token-spec.js b/crowdstf/res/app/components/stf/tokens/generate-access-token/generate-access-token-spec.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crowdstf/res/app/components/stf/tokens/generate-access-token/generate-access-token-spec.js
diff --git a/crowdstf/res/app/components/stf/tokens/generate-access-token/generate-access-token.css b/crowdstf/res/app/components/stf/tokens/generate-access-token/generate-access-token.css
new file mode 100644
index 0000000..60eed1f
--- /dev/null
+++ b/crowdstf/res/app/components/stf/tokens/generate-access-token/generate-access-token.css
@@ -0,0 +1,3 @@
+.stf-generate-access-token {
+
+}
diff --git a/crowdstf/res/app/components/stf/tokens/generate-access-token/generate-access-token.jade b/crowdstf/res/app/components/stf/tokens/generate-access-token/generate-access-token.jade
new file mode 100644
index 0000000..541b77e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/tokens/generate-access-token/generate-access-token.jade
@@ -0,0 +1,20 @@
+.panel.panel-default.stf-generate-access-token(ng-show='showGenerate')
+  .panel-heading
+    h3.panel-title(translate) Generate Access Token
+  .panel-body
+
+    form.form-horizontal(name='generateAccessTokenForm', ng-submit='generateToken(title)')
+
+      .form-group
+        label.control-label
+          i.fa.fa-key.fa-fw
+          span(translate) Title
+
+        input(id='access-token-title', type='text', name='accessTokenTitle', ng-model='generateForm.title', ng-required='true',
+        text-focus-select).form-control
+
+      button.btn.btn-primary-outline.btn-sm.pull-right(type='submit')
+        i.fa.fa-plus.fa-fw
+        span(translate) Generate New Token
+
+    error-message(message='{{error}}')
diff --git a/crowdstf/res/app/components/stf/tokens/generate-access-token/index.js b/crowdstf/res/app/components/stf/tokens/generate-access-token/index.js
new file mode 100644
index 0000000..6c055e0
--- /dev/null
+++ b/crowdstf/res/app/components/stf/tokens/generate-access-token/index.js
@@ -0,0 +1,6 @@
+require('./generate-access-token.css')
+
+module.exports = angular.module('stf.tokens.generate-access-token', [
+
+])
+  .directive('generateAccessToken', require('./generate-access-token-directive'))
diff --git a/crowdstf/res/app/components/stf/tokens/index.js b/crowdstf/res/app/components/stf/tokens/index.js
new file mode 100644
index 0000000..b1ea635
--- /dev/null
+++ b/crowdstf/res/app/components/stf/tokens/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.tokens', [
+  require('./generate-access-token').name
+])
+.factory('AccessTokenService', require('./access-token-service'))
diff --git a/crowdstf/res/app/components/stf/transaction/index.js b/crowdstf/res/app/components/stf/transaction/index.js
new file mode 100644
index 0000000..4dce00d
--- /dev/null
+++ b/crowdstf/res/app/components/stf/transaction/index.js
@@ -0,0 +1,5 @@
+module.exports = angular.module('stf/transaction', [
+  require('stf/socket').name
+])
+  .constant('TransactionError', require('./transaction-error'))
+  .factory('TransactionService', require('./transaction-service'))
diff --git a/crowdstf/res/app/components/stf/transaction/transaction-error.js b/crowdstf/res/app/components/stf/transaction/transaction-error.js
new file mode 100644
index 0000000..711b98f
--- /dev/null
+++ b/crowdstf/res/app/components/stf/transaction/transaction-error.js
@@ -0,0 +1,10 @@
+function TransactionError(result) {
+  this.code = this.message = result.error
+  this.name = 'TransactionError'
+  Error.captureStackTrace(this, TransactionError)
+}
+
+TransactionError.prototype = Object.create(Error.prototype)
+TransactionError.prototype.constructor = TransactionError
+
+module.exports = TransactionError
diff --git a/crowdstf/res/app/components/stf/transaction/transaction-service.js b/crowdstf/res/app/components/stf/transaction/transaction-service.js
new file mode 100644
index 0000000..fc1454d
--- /dev/null
+++ b/crowdstf/res/app/components/stf/transaction/transaction-service.js
@@ -0,0 +1,240 @@
+var Promise = require('bluebird')
+var uuid = require('node-uuid')
+
+module.exports = function TransactionServiceFactory(socket, TransactionError) {
+  var transactionService = {}
+
+  function createChannel() {
+    return 'tx.' + uuid.v4()
+  }
+
+  function MultiTargetTransaction(targets, options) {
+    var pending = Object.create(null)
+    var results = []
+    var channel = createChannel()
+
+    function doneListener(someChannel, data) {
+      if (someChannel === channel) {
+        pending[data.source].done(data)
+      }
+    }
+
+    function progressListener(someChannel, data) {
+      if (someChannel === channel) {
+        pending[data.source].progress(data)
+      }
+    }
+
+    function cancelListener(someChannel, data) {
+      if (someChannel === channel) {
+        Object.keys(pending).forEach(function(source) {
+          pending[source].cancel(data)
+        })
+      }
+    }
+
+    socket.on('tx.done', doneListener)
+    socket.on('tx.progress', progressListener)
+    socket.on('tx.cancel', cancelListener)
+
+    this.channel = channel
+    this.results = results
+    this.promise = Promise.settle(targets.map(function(target) {
+        var result = new options.result(target)
+        var pendingResult = new PendingTransactionResult(result)
+        pending[options.id ? target[options.id] : target.id] = pendingResult
+        results.push(result)
+        return pendingResult.promise
+      }))
+      .finally(function() {
+        socket.removeListener('tx.done', doneListener)
+        socket.removeListener('tx.progress', progressListener)
+        socket.removeListener('tx.cancel', cancelListener)
+        socket.emit('tx.cleanup', channel)
+      })
+      .progressed(function() {
+        return results
+      })
+      .then(function() {
+        return results
+      })
+  }
+
+  function SingleTargetTransaction(target, options) {
+    var result = new options.result(target)
+    var pending = new PendingTransactionResult(result)
+    var channel = createChannel()
+
+    function doneListener(someChannel, data) {
+      if (someChannel === channel) {
+        pending.done(data)
+      }
+    }
+
+    function progressListener(someChannel, data) {
+      if (someChannel === channel) {
+        pending.progress(data)
+      }
+    }
+
+    function cancelListener(someChannel, data) {
+      if (someChannel === channel) {
+        pending.cancel(data)
+      }
+    }
+
+    socket.on('tx.done', doneListener)
+    socket.on('tx.progress', progressListener)
+    socket.on('tx.cancel', cancelListener)
+
+    this.channel = channel
+    this.result = result
+    this.results = [result]
+    this.promise = pending.promise
+      .finally(function() {
+        socket.removeListener('tx.done', doneListener)
+        socket.removeListener('tx.progress', progressListener)
+        socket.removeListener('tx.cancel', cancelListener)
+        socket.emit('tx.cleanup', channel)
+      })
+      .progressed(function() {
+        return result
+      })
+      .then(function() {
+        return result
+      })
+  }
+
+  function PendingTransactionResult(result) {
+    var resolver = Promise.defer()
+    var seq = 0
+    var last = Infinity
+    var unplaced = []
+
+    function readQueue() {
+      var message
+      var foundAny = false
+
+      while (seq <= last && (message = unplaced[seq])) {
+        unplaced[seq] = undefined
+
+        if (seq === last) {
+          result.success = message.success
+
+          if (message.body) {
+            result.body = JSON.parse(message.body)
+          }
+
+          if (result.success) {
+            if (message.data) {
+              result.lastData = result.data[seq] = message.data
+            }
+            resolver.resolve(result)
+          }
+          else {
+            result.lastData = result.error = message.data
+            resolver.reject(new TransactionError(result))
+          }
+
+          return
+        }
+        else {
+          if (message.progress) {
+            result.progress = message.progress
+          }
+        }
+
+        foundAny = true
+        result.lastData = result.data[seq++] = message.data
+      }
+
+      if (foundAny) {
+        resolver.progress(result)
+      }
+    }
+
+    this.progress = function(message) {
+      unplaced[message.seq] = message
+      readQueue()
+    }
+
+    this.done = function(message) {
+      last = message.seq
+      unplaced[message.seq] = message
+      readQueue()
+    }
+
+    this.cancel = function(message) {
+      if (!result.settled) {
+        last = message.seq = seq
+        unplaced[message.seq] = message
+        readQueue()
+      }
+    }
+
+    this.result = result
+    this.promise = resolver.promise.finally(function() {
+      result.settled = true
+      result.progress = 100
+    })
+  }
+
+  function TransactionResult(source) {
+    this.source = source
+    this.settled = false
+    this.success = false
+    this.progress = 0
+    this.error = null
+    this.data = []
+    this.lastData = null
+    this.body = null
+  }
+
+  function DeviceTransactionResult(device) {
+    TransactionResult.call(this, device)
+    this.device = this.source
+  }
+
+  DeviceTransactionResult.prototype = Object.create(TransactionResult)
+  DeviceTransactionResult.constructor = DeviceTransactionResult
+
+  transactionService.create = function(target, options) {
+    if (options && !options.result) {
+      options.result = TransactionResult
+    }
+
+    if (Array.isArray(target)) {
+      return new MultiTargetTransaction(target, options || {
+        result: DeviceTransactionResult
+      , id: 'serial'
+      })
+    }
+    else {
+      return new SingleTargetTransaction(target, options || {
+        result: DeviceTransactionResult
+      , id: 'serial'
+      })
+    }
+  }
+
+  transactionService.punch = function(channel) {
+    var resolver = Promise.defer()
+
+    function punchListener(someChannel) {
+      if (channel === someChannel) {
+        resolver.resolve(channel)
+      }
+    }
+
+    socket.on('tx.punch', punchListener)
+    socket.emit('tx.punch', channel)
+
+    return resolver.promise
+      .timeout(5000)
+      .finally(function() {
+        socket.removeListener('tx.punch', punchListener)
+      })
+  }
+
+  return transactionService
+}
diff --git a/crowdstf/res/app/components/stf/upload/index.js b/crowdstf/res/app/components/stf/upload/index.js
new file mode 100644
index 0000000..05c380e
--- /dev/null
+++ b/crowdstf/res/app/components/stf/upload/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.upload-service', [
+  require('gettext').name
+])
+  .filter('uploadError', require('./upload-error-filter'))
diff --git a/crowdstf/res/app/components/stf/upload/upload-error-filter.js b/crowdstf/res/app/components/stf/upload/upload-error-filter.js
new file mode 100644
index 0000000..4537f24
--- /dev/null
+++ b/crowdstf/res/app/components/stf/upload/upload-error-filter.js
@@ -0,0 +1,10 @@
+module.exports = function uploadErrorFilter(gettext) {
+  return function(text) {
+    return {
+      fail_invalid_app_file: gettext('Uploaded file is not valid'),
+      fail_download: gettext('Failed to download file'),
+      fail_invalid_url: gettext('Cannot access specified URL'),
+      fail: gettext('Upload failed')
+    }[text] || gettext('Upload unknown error')
+  }
+}
diff --git a/crowdstf/res/app/components/stf/upload/upload-spec.js b/crowdstf/res/app/components/stf/upload/upload-spec.js
new file mode 100644
index 0000000..a2abb45
--- /dev/null
+++ b/crowdstf/res/app/components/stf/upload/upload-spec.js
@@ -0,0 +1,13 @@
+describe('upload', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+	it('should ...', inject(function() {
+
+    //var filter = $filter('uploadError')
+
+		//expect(filter('input')).toEqual('output')
+
+	}))
+
+})
diff --git a/crowdstf/res/app/components/stf/user/group/group-service.js b/crowdstf/res/app/components/stf/user/group/group-service.js
new file mode 100644
index 0000000..100d08b
--- /dev/null
+++ b/crowdstf/res/app/components/stf/user/group/group-service.js
@@ -0,0 +1,58 @@
+var Promise = require('bluebird')
+
+module.exports = function GroupServiceFactory(
+  socket
+, TransactionService
+, TransactionError
+) {
+  var groupService = {
+  }
+
+  groupService.invite = function(device) {
+    if (!device.usable) {
+      return Promise.reject(new Error('Device is not usable'))
+    }
+
+    var tx = TransactionService.create(device)
+    socket.emit('group.invite', device.channel, tx.channel, {
+      requirements: {
+        serial: {
+          value: device.serial
+        , match: 'exact'
+        }
+      }
+    })
+    return tx.promise
+      .then(function(result) {
+        return result.device
+      })
+      .catch(TransactionError, function() {
+        throw new Error('Device refused to join the group')
+      })
+  }
+
+  groupService.kick = function(device, force) {
+    if (!force && !device.usable) {
+      return Promise.reject(new Error('Device is not usable'))
+    }
+
+    var tx = TransactionService.create(device)
+    socket.emit('group.kick', device.channel, tx.channel, {
+      requirements: {
+        serial: {
+          value: device.serial
+        , match: 'exact'
+        }
+      }
+    })
+    return tx.promise
+      .then(function(result) {
+        return result.device
+      })
+      .catch(TransactionError, function() {
+        throw new Error('Device refused to join the group')
+      })
+  }
+
+  return groupService
+}
diff --git a/crowdstf/res/app/components/stf/user/group/index.js b/crowdstf/res/app/components/stf/user/group/index.js
new file mode 100644
index 0000000..0737a3d
--- /dev/null
+++ b/crowdstf/res/app/components/stf/user/group/index.js
@@ -0,0 +1,6 @@
+module.exports = angular.module('stf/group', [
+  require('stf/socket').name,
+  require('stf/user').name,
+  require('stf/transaction').name
+])
+  .factory('GroupService', require('./group-service'))
diff --git a/crowdstf/res/app/components/stf/user/index.js b/crowdstf/res/app/components/stf/user/index.js
new file mode 100644
index 0000000..d547328
--- /dev/null
+++ b/crowdstf/res/app/components/stf/user/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf/user', [
+  require('stf/app-state').name
+])
+  .factory('UserService', require('./user-service'))
diff --git a/crowdstf/res/app/components/stf/user/user-service.js b/crowdstf/res/app/components/stf/user/user-service.js
new file mode 100644
index 0000000..e1de7c0
--- /dev/null
+++ b/crowdstf/res/app/components/stf/user/user-service.js
@@ -0,0 +1,50 @@
+module.exports = function UserServiceFactory(
+  $rootScope
+, socket
+, AppState
+, AddAdbKeyModalService
+) {
+  var UserService = {}
+
+  var user = UserService.currentUser = AppState.user
+
+  UserService.getAdbKeys = function() {
+    return (user.adbKeys || (user.adbKeys = []))
+  }
+
+  UserService.addAdbKey = function(key) {
+    socket.emit('user.keys.adb.add', key)
+  }
+
+  UserService.acceptAdbKey = function(key) {
+    socket.emit('user.keys.adb.accept', key)
+  }
+
+  UserService.removeAdbKey = function(key) {
+    socket.emit('user.keys.adb.remove', key)
+  }
+
+  socket.on('user.keys.adb.added', function(key) {
+    UserService.getAdbKeys().push(key)
+    $rootScope.$broadcast('user.keys.adb.updated', user.adbKeys)
+    $rootScope.$apply()
+  })
+
+  socket.on('user.keys.adb.removed', function(key) {
+    user.adbKeys = UserService.getAdbKeys().filter(function(someKey) {
+      return someKey.fingerprint !== key.fingerprint
+    })
+    $rootScope.$broadcast('user.keys.adb.updated', user.adbKeys)
+    $rootScope.$apply()
+  })
+
+  socket.on('user.keys.adb.confirm', function(data) {
+    AddAdbKeyModalService.open(data).then(function(result) {
+      if (result) {
+        UserService.acceptAdbKey(data)
+      }
+    })
+  })
+
+  return UserService
+}
diff --git a/crowdstf/res/app/components/stf/util/vendor/index.js b/crowdstf/res/app/components/stf/util/vendor/index.js
new file mode 100644
index 0000000..59c9985
--- /dev/null
+++ b/crowdstf/res/app/components/stf/util/vendor/index.js
@@ -0,0 +1,2 @@
+module.exports = angular.module('stf/util/vendor', [])
+  .factory('VendorUtil', require('./vendor-util'))
diff --git a/crowdstf/res/app/components/stf/util/vendor/vendor-util.js b/crowdstf/res/app/components/stf/util/vendor/vendor-util.js
new file mode 100644
index 0000000..ea42ffe
--- /dev/null
+++ b/crowdstf/res/app/components/stf/util/vendor/vendor-util.js
@@ -0,0 +1,15 @@
+module.exports = function VendorUtilFactory() {
+  var vendorUtil = {}
+
+  vendorUtil.style = function(props) {
+    var testee = document.createElement('span')
+    for (var i = 0, l = props.length; i < l; ++i) {
+      if (typeof testee.style[props[i]] !== 'undefined') {
+        return props[i]
+      }
+    }
+    return props[0]
+  }
+
+  return vendorUtil
+}
diff --git a/crowdstf/res/app/control-panes/activity/activity-controller.js b/crowdstf/res/app/control-panes/activity/activity-controller.js
new file mode 100644
index 0000000..cd866f5
--- /dev/null
+++ b/crowdstf/res/app/control-panes/activity/activity-controller.js
@@ -0,0 +1,32 @@
+module.exports = function ActivityCtrl($scope, gettext, TimelineService) {
+  $scope.timeline = TimelineService
+
+  $scope.$watch('device.state', function(newValue, oldValue) {
+
+    if (newValue !== oldValue) {
+
+      var title = ''
+      var message = ''
+
+      if (oldValue === 'using') {
+
+        title = newValue
+        message = 'Device is now ' + newValue
+
+
+      } else {
+        title = newValue
+        message = '!Device is now ' + newValue
+      }
+
+      $scope.timeline.info({
+        title: title,
+        message: message,
+        serial: $scope.device.serial
+      })
+
+    }
+
+
+  }, true)
+}
diff --git a/crowdstf/res/app/control-panes/activity/activity-spec.js b/crowdstf/res/app/control-panes/activity/activity-spec.js
new file mode 100644
index 0000000..76212d0
--- /dev/null
+++ b/crowdstf/res/app/control-panes/activity/activity-spec.js
@@ -0,0 +1,17 @@
+describe('ActivityCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('ActivityCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/activity/activity.css b/crowdstf/res/app/control-panes/activity/activity.css
new file mode 100644
index 0000000..9087ac5
--- /dev/null
+++ b/crowdstf/res/app/control-panes/activity/activity.css
@@ -0,0 +1,23 @@
+.stf-activity .activity-list .activity-date,
+.stf-activity .activity-list .activity-buttons {
+  opacity: 0;
+  transition: opacity 0.25s ease-in-out;
+}
+
+.stf-activity .activity-list li:hover .activity-date,
+.stf-activity .activity-list li:hover .activity-buttons {
+  opacity: 1;
+}
+
+.stf-activity .activity-title {
+  display: inline-block;
+}
+
+.stf-activity .activity-icon {
+  width: 30px;
+  height: 30px;
+}
+
+.stf-activity {
+  background: #e8e8e8;
+}
diff --git a/crowdstf/res/app/control-panes/activity/activity.jade b/crowdstf/res/app/control-panes/activity/activity.jade
new file mode 100644
index 0000000..b3eb760
--- /dev/null
+++ b/crowdstf/res/app/control-panes/activity/activity.jade
@@ -0,0 +1,23 @@
+.widget-container.scrollableX.messages.stf-activity(ng-controller='ActivityCtrl')
+  .widget-content.padded
+
+    ul(ng-repeat='line in timeline.lines')
+      li
+        h3 {{line.title}}
+        p {{line.message}}
+
+
+    ul.timeline.activity-list
+      li.active
+        .timeline-time
+          strong Oct 3
+          span 4:53 PM
+        .timeline-icon
+          .bg-primary
+            i.fa.fa-exclamation-circle.fa-1x
+        .timeline-content
+          h2 WebSocket Disconnected
+          p Socket connection was lost, try again reloading the page.
+          div
+            refresh-page
+
diff --git a/crowdstf/res/app/control-panes/activity/index.js b/crowdstf/res/app/control-panes/activity/index.js
new file mode 100644
index 0000000..965c1b8
--- /dev/null
+++ b/crowdstf/res/app/control-panes/activity/index.js
@@ -0,0 +1,13 @@
+require('./activity.css')
+
+module.exports = angular.module('stf.activity', [
+  require('gettext').name,
+  require('stf/common-ui').name,
+  require('stf/timeline').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/activity/activity.jade',
+      require('./activity.jade')
+    )
+  }])
+  .controller('ActivityCtrl', require('./activity-controller'))
diff --git a/crowdstf/res/app/control-panes/advanced/advanced-controller.js b/crowdstf/res/app/control-panes/advanced/advanced-controller.js
new file mode 100644
index 0000000..d25901b
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/advanced-controller.js
@@ -0,0 +1,3 @@
+module.exports = function AdvancedCtrl() {
+
+}
diff --git a/crowdstf/res/app/control-panes/advanced/advanced-spec.js b/crowdstf/res/app/control-panes/advanced/advanced-spec.js
new file mode 100644
index 0000000..8bccaea
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/advanced-spec.js
@@ -0,0 +1,17 @@
+describe('AdvancedCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('AdvancedCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/advanced/advanced.css b/crowdstf/res/app/control-panes/advanced/advanced.css
new file mode 100644
index 0000000..73e29f1
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/advanced.css
@@ -0,0 +1,3 @@
+.stf-advanced {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/control-panes/advanced/advanced.jade b/crowdstf/res/app/control-panes/advanced/advanced.jade
new file mode 100644
index 0000000..809e40c
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/advanced.jade
@@ -0,0 +1,13 @@
+.row
+  //.col-md-12
+    div(ng-include='"control-panes/advanced/run-js/run-js.jade"')
+  .col-md-6
+    div(ng-include='"control-panes/advanced/input/input.jade"')
+  .col-md-6
+    div(ng-include='"control-panes/advanced/port-forwarding/port-forwarding.jade"')
+.row
+  //.col-md-6
+  //  div(ng-include='"control-panes/advanced/vnc/vnc.jade"')
+
+  .col-md-6
+    div(ng-include='"control-panes/advanced/maintenance/maintenance.jade"')
diff --git a/crowdstf/res/app/control-panes/advanced/index.js b/crowdstf/res/app/control-panes/advanced/index.js
new file mode 100644
index 0000000..ebcd724
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/index.js
@@ -0,0 +1,16 @@
+require('./advanced.css')
+
+module.exports = angular.module('stf.advanced', [
+  require('./input').name,
+//  require('./run-js').name,
+//  require('./usb').name,
+//  require('./vnc').name,
+  require('./port-forwarding').name,
+  require('./maintenance').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/advanced/advanced.jade',
+      require('./advanced.jade')
+    )
+  }])
+  .controller('AdvancedCtrl', require('./advanced-controller'))
diff --git a/crowdstf/res/app/control-panes/advanced/input/index.js b/crowdstf/res/app/control-panes/advanced/input/index.js
new file mode 100644
index 0000000..f6d1376
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/input/index.js
@@ -0,0 +1,10 @@
+require('./input.css')
+
+module.exports = angular.module('stf.advanced.input', [
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/advanced/input/input.jade',
+      require('./input.jade')
+    )
+  }])
+  .controller('InputAdvancedCtrl', require('./input-controller'))
diff --git a/crowdstf/res/app/control-panes/advanced/input/input-controller.js b/crowdstf/res/app/control-panes/advanced/input/input-controller.js
new file mode 100644
index 0000000..d941424
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/input/input-controller.js
@@ -0,0 +1,6 @@
+module.exports = function InputCtrl($scope) {
+
+  $scope.press = function(key) {
+    $scope.control.keyPress(key)
+  }
+}
diff --git a/crowdstf/res/app/control-panes/advanced/input/input-spec.js b/crowdstf/res/app/control-panes/advanced/input/input-spec.js
new file mode 100644
index 0000000..6c61e97
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/input/input-spec.js
@@ -0,0 +1,17 @@
+describe('InputAdvancedCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('InputAdvancedCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/advanced/input/input.css b/crowdstf/res/app/control-panes/advanced/input/input.css
new file mode 100644
index 0000000..9d95a16
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/input/input.css
@@ -0,0 +1,3 @@
+.stf-input {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/control-panes/advanced/input/input.jade b/crowdstf/res/app/control-panes/advanced/input/input.jade
new file mode 100644
index 0000000..7c92e2b
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/input/input.jade
@@ -0,0 +1,64 @@
+.widget-container.fluid-height(ng-controller='InputAdvancedCtrl')
+  .heading
+    stacked-icon(icon='fa-gear', color='color-pink')
+    span(translate) Advanced Input
+  .widget-content.padded
+    div
+      h6(translate) Special Keys
+      div.special-keys-buttons
+        button(uib-tooltip='{{ "Power" | translate }}', ng-click='press("power")').btn.btn-danger.btn-xs
+          i.fa.fa-power-off
+        button(uib-tooltip='{{ "Camera" | translate }}', ng-click='press("camera")').btn.btn-primary.btn-xs
+          i.fa.fa-camera
+        button(uib-tooltip='{{ "Switch Charset" | translate }}', ng-click='press("switch_charset")').btn.btn-primary.btn-info.btn-xs
+          i.fa  Aa
+        button(uib-tooltip='{{ "Search" | translate }}', ng-click='press("search")').btn.btn-primary.btn-xs
+          i.fa.fa-search
+
+        h6(translate) Volume
+        .btn-group
+          button(uib-tooltip='{{ "Mute" | translate }}', ng-click='press("mute")').btn.btn-primary.btn-xs
+            i.fa.fa-volume-off
+          button(uib-tooltip='{{ "Volume Down" | translate }}', ng-click='press("volume_down")').btn.btn-primary.btn-xs
+            i.fa.fa-volume-down
+          button(uib-tooltip='{{ "Volume Up" | translate }}', ng-click='press("volume_up")').btn.btn-primary.btn-xs
+            i.fa.fa-volume-up
+
+        h6(translate) Media
+        .btn-group
+          button(uib-tooltip='{{ "Rewind" | translate }}', ng-click='press("media_rewind")').btn.btn-primary.btn-xs
+            i.fa.fa-fast-backward
+          button(uib-tooltip='{{ "Previous" | translate }}', ng-click='press("media_previous")').btn.btn-primary.btn-xs
+            i.fa.fa-step-backward
+          button(uib-tooltip='{{ "Play/Pause" | translate }}', ng-click='press("media_play_pause")').btn.btn-primary.btn-xs
+            i.fa.fa-youtube-play
+          button(uib-tooltip='{{ "Stop" | translate }}', ng-click='press("media_stop")').btn.btn-primary.btn-xs
+            i.fa.fa-stop
+          button(uib-tooltip='{{ "Next" | translate }}', ng-click='press("media_next")').btn.btn-primary.btn-xs
+            i.fa.fa-step-forward
+          button(uib-tooltip='{{ "Fast Forward" | translate }}', ng-click='press("media_fast_forward")').btn.btn-primary.btn-xs
+            i.fa.fa-fast-forward
+    //h6 D-pad
+    //table.special-keys-dpad-buttons
+      tr
+        td
+        td
+          button(uib-tooltip='{{ "D-pad Up" | translate }}', ng-click='press("dpad_up")').btn.btn-info.btn-xs
+            i.fa.fa-chevron-up
+        td
+      tr
+        td
+          button(uib-tooltip='{{ "D-pad Left" | translate }}', ng-click='press("dpad_left")').btn.btn-info.btn-xs
+            i.fa.fa-chevron-left
+        td
+          button(uib-tooltip='{{ "D-pad Center" | translate }}', ng-click='press("dpad_center")').btn.btn-info.btn-xs
+            i.fa.fa-circle-o
+        td
+          button(uib-tooltip='{{ "D-pad Right" | translate }}', ng-click='press("dpad_right")').btn.btn-info.btn-xs
+            i.fa.fa-chevron-right
+      tr
+        td
+        td
+          button(uib-tooltip='{{ "D-pad Down" | translate }}', ng-click='press("dpad_down")').btn.btn-info.btn-xs
+            i.fa.fa-chevron-down
+        td
diff --git a/crowdstf/res/app/control-panes/advanced/maintenance/index.js b/crowdstf/res/app/control-panes/advanced/maintenance/index.js
new file mode 100644
index 0000000..37dc848
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/maintenance/index.js
@@ -0,0 +1,9 @@
+module.exports = angular.module('stf.advanced.maintenance', [
+  require('gettext').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/advanced/maintenance/maintenance.jade',
+      require('./maintenance.jade')
+    )
+  }])
+  .controller('MaintenanceCtrl', require('./maintenance-controller'))
diff --git a/crowdstf/res/app/control-panes/advanced/maintenance/maintenance-controller.js b/crowdstf/res/app/control-panes/advanced/maintenance/maintenance-controller.js
new file mode 100644
index 0000000..f63535b
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/maintenance/maintenance-controller.js
@@ -0,0 +1,20 @@
+module.exports = function($scope, gettext, $filter) {
+
+  $scope.reboot = function() {
+    var config = {
+      rebootEnabled: true
+    }
+
+    /* eslint no-console: 0 */
+    if (config.rebootEnabled) {
+      var line1 = $filter('translate')(gettext('Are you sure you want to reboot this device?'))
+      var line2 = $filter('translate')(gettext('The device will be unavailable for a moment.'))
+      if (confirm(line1 + '\n' + line2)) {
+        $scope.control.reboot().then(function(result) {
+          console.error(result)
+        })
+      }
+    }
+  }
+
+}
diff --git a/crowdstf/res/app/control-panes/advanced/maintenance/maintenance-spec.js b/crowdstf/res/app/control-panes/advanced/maintenance/maintenance-spec.js
new file mode 100644
index 0000000..c710f22
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/maintenance/maintenance-spec.js
@@ -0,0 +1,17 @@
+describe('MaintenanceCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('MaintenanceCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/advanced/maintenance/maintenance.jade b/crowdstf/res/app/control-panes/advanced/maintenance/maintenance.jade
new file mode 100644
index 0000000..356737a
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/maintenance/maintenance.jade
@@ -0,0 +1,9 @@
+.widget-container.fluid-height(ng-controller='MaintenanceCtrl')
+  .heading
+    stacked-icon(icon='fa-gears', color='color-darkgray')
+    span(translate) Maintenance
+    .pull-right
+  .widget-content.padded
+    button(ng-click='reboot()').btn.btn-sm.btn-danger
+      i.fa.fa-refresh
+      span(translate) Restart Device
diff --git a/crowdstf/res/app/control-panes/advanced/port-forwarding/index.js b/crowdstf/res/app/control-panes/advanced/port-forwarding/index.js
new file mode 100644
index 0000000..0b9421b
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/port-forwarding/index.js
@@ -0,0 +1,14 @@
+require('./port-forwarding.css')
+
+module.exports = angular.module('stf.port-forwarding', [
+  require('stf/common-ui/table').name,
+  require('stf/settings').name,
+  require('gettext').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put(
+      'control-panes/advanced/port-forwarding/port-forwarding.jade',
+      require('./port-forwarding.jade')
+    )
+  }])
+  .controller('PortForwardingCtrl', require('./port-forwarding-controller'))
diff --git a/crowdstf/res/app/control-panes/advanced/port-forwarding/port-forwarding-controller.js b/crowdstf/res/app/control-panes/advanced/port-forwarding/port-forwarding-controller.js
new file mode 100644
index 0000000..21fb590
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/port-forwarding/port-forwarding-controller.js
@@ -0,0 +1,72 @@
+var uuid = require('node-uuid')
+var Promise = require('bluebird')
+
+module.exports = function PortForwardingCtrl(
+  $scope
+, SettingsService
+) {
+  function defaults(id) {
+    return {
+      id: id
+    , targetHost: 'localhost'
+    , targetPort: 8080
+    , devicePort: 8080
+    , enabled: false
+    }
+  }
+
+  $scope.reversePortForwards = [defaults('_default')]
+
+  SettingsService.bind($scope, {
+    target: 'reversePortForwards'
+  , source: 'reversePortForwards'
+  })
+
+  $scope.$watch('device.reverseForwards', function(newValue) {
+    var map = Object.create(null)
+
+    if (newValue) {
+      newValue.forEach(function(forward) {
+        map[forward.id] = forward
+      })
+    }
+
+    $scope.reversePortForwards.forEach(function(forward) {
+      var deviceForward = map[forward.id]
+      forward.enabled = !!(deviceForward && deviceForward.id === forward.id &&
+        deviceForward.devicePort === forward.devicePort)
+    })
+  })
+
+  $scope.applyForward = function(forward) {
+    return forward.enabled ?
+      $scope.control.createForward(forward) :
+      $scope.control.removeForward(forward)
+  }
+
+  $scope.enableForward = function(forward) {
+    if (forward.enabled) {
+      return Promise.resolve()
+    }
+
+    return $scope.control.createForward(forward)
+  }
+
+  $scope.disableForward = function(forward) {
+    if (!forward.enabled) {
+      return Promise.resolve()
+    }
+
+    return $scope.control.removeForward(forward)
+  }
+
+  $scope.addRow = function() {
+    $scope.reversePortForwards.push(defaults(uuid.v4()))
+  }
+
+  $scope.removeRow = function(forward) {
+    $scope.disableForward(forward)
+    $scope.reversePortForwards.splice(
+      $scope.reversePortForwards.indexOf(forward), 1)
+  }
+}
diff --git a/crowdstf/res/app/control-panes/advanced/port-forwarding/port-forwarding-spec.js b/crowdstf/res/app/control-panes/advanced/port-forwarding/port-forwarding-spec.js
new file mode 100644
index 0000000..28e2046
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/port-forwarding/port-forwarding-spec.js
@@ -0,0 +1,17 @@
+describe('PortForwardingCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('PortForwardingCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/advanced/port-forwarding/port-forwarding.css b/crowdstf/res/app/control-panes/advanced/port-forwarding/port-forwarding.css
new file mode 100644
index 0000000..8a0b74b
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/port-forwarding/port-forwarding.css
@@ -0,0 +1,22 @@
+.stf-port-forwarding {
+
+}
+
+.stf-port-forwarding .padded {
+  padding-top: 0;
+}
+
+.stf-port-forwarding table {
+  white-space: nowrap;
+}
+
+.stf-port-forwarding .port-forwarding-image {
+  text-align: center;
+  color: #b7b7b7;
+  padding-bottom: 4px;
+}
+
+.stf-port-forwarding .port-forwarding-image .fa-arrow-right {
+  vertical-align: 38%;
+  margin-right: 6px;
+}
diff --git a/crowdstf/res/app/control-panes/advanced/port-forwarding/port-forwarding.jade b/crowdstf/res/app/control-panes/advanced/port-forwarding/port-forwarding.jade
new file mode 100644
index 0000000..ed9aea5
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/port-forwarding/port-forwarding.jade
@@ -0,0 +1,46 @@
+.widget-container.fluid-height.stf-port-forwarding(ng-controller='PortForwardingCtrl')
+  .heading
+    span
+      stacked-icon(icon='fa-random', color='color-orange')
+      span(translate).pointer Port Forwarding
+
+      button.btn.pull-right.btn-sm.btn-primary-outline(
+        ng-click='addRow()')
+        i.fa.fa-plus.fa-fw
+
+  .widget-content.padded
+
+    nothing-to-show(icon='fa-random', message='{{"No Ports Forwarded" | translate}}',
+    ng-if='!reversePortForwards.length')
+
+    div(ng-show='reversePortForwards.length')
+      .port-forwarding-image
+        i.fa.fa-mobile.fa-2x.fa-fw(title='{{"Device"|translate}}')
+        i.fa.fa-arrow-right.fa-fw
+        i.fa.fa-laptop.fa-2x.fa-fw(title='{{"Host"|translate}}')
+
+      form(editable-form, name='portsform', onaftersave='saveTable()', oncancel='cancel()')
+        table.table.table-condensed
+          thead
+            tr
+              th(colspan='1')
+                span(translate) Device
+              th(colspan='3')
+                span(translate) Host
+              th(colspan='1')
+          tbody
+            tr(ng-repeat='forward in reversePortForwards track by $index')
+              td(width='35%')
+                div.input-group.input-group-sm
+                  span.input-group-addon
+                    input(type='checkbox', ng-model='forward.enabled', ng-change='applyForward(forward)')
+                  input.form-control(type='text', min='0', ng-model='forward.devicePort', ng-model-options="{ updateOn: 'default blur' }", placeholder='{{"Port"|translate}}', ng-change='disableForward(forward)')
+              td(width='40%')
+                input.form-control.input-sm(type='text', ng-model='forward.targetHost', ng-model-options="{ updateOn: 'default blur' }", placeholder='{{"Hostname"|translate}}', ng-change='disableForward(forward)')
+              td
+                span :
+              td(width='25%')
+                input.form-control.input-sm(type='text', min='0', ng-model='forward.targetPort', ng-model-options="{ updateOn: 'default blur' }", placeholder='{{"Port"|translate}}', ng-change='disableForward(forward)')
+              td
+                button.btn.btn-sm.btn-danger-outline(ng-click='removeRow(forward)')
+                  i.fa.fa-trash-o
diff --git a/crowdstf/res/app/control-panes/advanced/run-js/index.js b/crowdstf/res/app/control-panes/advanced/run-js/index.js
new file mode 100644
index 0000000..e833d2d
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/run-js/index.js
@@ -0,0 +1,11 @@
+require('./run-js.css')
+
+module.exports = angular.module('stf.run-js', [
+
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/advanced/run-js/run-js.jade',
+      require('./run-js.jade')
+    )
+  }])
+  .controller('RunJsCtrl', require('./run-js-controller'))
diff --git a/crowdstf/res/app/control-panes/advanced/run-js/run-js-controller.js b/crowdstf/res/app/control-panes/advanced/run-js/run-js-controller.js
new file mode 100644
index 0000000..cf42a38
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/run-js/run-js-controller.js
@@ -0,0 +1,3 @@
+module.exports = function RunJsCtrl() {
+
+}
diff --git a/crowdstf/res/app/control-panes/advanced/run-js/run-js-spec.js b/crowdstf/res/app/control-panes/advanced/run-js/run-js-spec.js
new file mode 100644
index 0000000..1bde5f9
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/run-js/run-js-spec.js
@@ -0,0 +1,17 @@
+describe('RunJsCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('RunJsCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/advanced/run-js/run-js.css b/crowdstf/res/app/control-panes/advanced/run-js/run-js.css
new file mode 100644
index 0000000..e6bd1c9
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/run-js/run-js.css
@@ -0,0 +1,3 @@
+.stf-run-js {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/control-panes/advanced/run-js/run-js.jade b/crowdstf/res/app/control-panes/advanced/run-js/run-js.jade
new file mode 100644
index 0000000..da8388e
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/run-js/run-js.jade
@@ -0,0 +1,65 @@
+.stf-run.js(ng-if='$root.platform == "web" && $root.browser == "webview"')
+  .widget-container.fluid-height(ng-controller='InjectJavaScriptCtrl')
+    .heading
+      i.fa.fa-code
+      span(translate) Run JavaScript
+      .btn-group
+        script(type='text/ng-template', id='saveSnippetModal.html')
+          .modal-header
+            h2 Save snippet
+          .modal-body
+            p Save as:
+              input(type='text', ng-model='result', required, autofocus, ng-autoselect='true')
+          .modal-footer
+            button(ng-click='$dismiss("cancel")').btn.btn-default Cancel
+            button(ng-click='$close(result)', ng-disabled='!result').btn.btn-primary Save
+        button(ng-click='saveDialog()', ng-disabled='!snippet.editorText').btn.btn-sm.btn-default-outline
+          i.fa.fa-save
+          span(translate)  Save...
+        button(type='button',
+        ng-disabled='!hasSnippets()').btn.btn-sm.btn-default-outline.uib-dropdown-toggle
+          span.caret
+        ul.uib-dropdown-menu.pull-right
+          li(ng-repeat='snip in snippets')
+            a(ng-click='openSnippet(snip)').btn-link {{snip.name}}
+          li.divider
+          li
+            a(ng-click='clearSnippets()', type='button', translate).btn-link Clear
+      .btn-group.pull-right
+        button.btn.btn-sm.btn-primary-outline(ng-click='injectJS()', ng-disabled='!snippet.editorText')
+          i.fa.fa-play
+          span(translate)  Run
+    .widget-content.padded
+      p
+        div(ui-ace="aceOptions", scope-instance='editorInstance', ng-model='snippet.editorText').stf-ace-editor
+      div(ng-controller='ReturnJavaScriptCtrl')
+        uib-tabset.unselectable(ng-show='results.length')
+          uib-tab(heading='Results')
+            table.table.table-hover(ng-table='tableParams').selectable
+              tr(ng-repeat='result in $data')
+                td(width='30%', title="'Device'", sortable='deviceName')
+                  img(ng-src='{{ result.deviceImage }}').device-icon-smallest
+                  span {{ result.deviceName }}
+                td(width='30%', title="'Returns'", sortable='prettyValue')
+                  div(ng-show='result.isObject')
+                    code.value-next-to-progress {{ result.prettyValue }}
+                  div(ng-hide='result.isObject')
+                    .value-next-to-progress {{ result.value }}
+                td(width='40%', ng-show='result.isSpecialValue')
+                  div(ng-show='result.isNumber')
+                    uib-progressbar.table-progress(value='result.percentage', max='100')
+                  div(ng-show='result.isObject')
+                    div.label.label-info Object
+                  div(ng-show='result.isFunction')
+                    div.label.label-info Function
+                  div(ng-show='result.isArray')
+                    div.label.label-info Array
+                  div(ng-show='result.isNull')
+                    div.label Null
+                  div(ng-show='result.isBoolean')
+                    div.label(style='width=100%', ng-class="{'label-success': result.value, 'label-important': !result.value}")
+                      i.fa(ng-class="{'fa-check': result.value, 'fa-times-circle': !result.value }")
+                      span {{ result.value.toString() }}
+          uib-tab(heading='Raw')
+            pre.selectable {{results | json}}
+          clear-button(ng-click='clear()', ng-disabled='!results.length')
diff --git a/crowdstf/res/app/control-panes/advanced/usb/index.js b/crowdstf/res/app/control-panes/advanced/usb/index.js
new file mode 100644
index 0000000..845ccc9
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/usb/index.js
@@ -0,0 +1,11 @@
+require('./usb.css')
+
+module.exports = angular.module('stf.usb', [
+
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/advanced/usb/usb.jade',
+      require('./usb.jade')
+    )
+  }])
+  .controller('UsbCtrl', require('./usb-controller'))
diff --git a/crowdstf/res/app/control-panes/advanced/usb/usb-controller.js b/crowdstf/res/app/control-panes/advanced/usb/usb-controller.js
new file mode 100644
index 0000000..e7ad248
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/usb/usb-controller.js
@@ -0,0 +1,3 @@
+module.exports = function UsbCtrl() {
+
+}
diff --git a/crowdstf/res/app/control-panes/advanced/usb/usb-spec.js b/crowdstf/res/app/control-panes/advanced/usb/usb-spec.js
new file mode 100644
index 0000000..6eae31f
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/usb/usb-spec.js
@@ -0,0 +1,17 @@
+describe('UsbCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('UsbCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/advanced/usb/usb.css b/crowdstf/res/app/control-panes/advanced/usb/usb.css
new file mode 100644
index 0000000..05f22f4
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/usb/usb.css
@@ -0,0 +1,3 @@
+.stf-usb {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/control-panes/advanced/usb/usb.jade b/crowdstf/res/app/control-panes/advanced/usb/usb.jade
new file mode 100644
index 0000000..a565fd5
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/usb/usb.jade
@@ -0,0 +1,6 @@
+.widget-container.fluid-height.stf-usb
+  .heading
+    i.fa
+    span(translate) Usb speed
+  .widget-content.padded
+    div
\ No newline at end of file
diff --git a/crowdstf/res/app/control-panes/advanced/vnc/index.js b/crowdstf/res/app/control-panes/advanced/vnc/index.js
new file mode 100644
index 0000000..0532ece
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/vnc/index.js
@@ -0,0 +1,12 @@
+require('./vnc.css')
+
+module.exports = angular.module('stf.vnc', [
+  require('gettext').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put(
+      'control-panes/advanced/vnc/vnc.jade',
+      require('./vnc.jade')
+    )
+  }])
+  .controller('VNCCtrl', require('./vnc-controller'))
diff --git a/crowdstf/res/app/control-panes/advanced/vnc/vnc-controller.js b/crowdstf/res/app/control-panes/advanced/vnc/vnc-controller.js
new file mode 100644
index 0000000..f3cce8c
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/vnc/vnc-controller.js
@@ -0,0 +1,11 @@
+module.exports = function RemoteDebugCtrl($scope) {
+  $scope.vnc = {}
+
+  $scope.generateVNCLogin = function() {
+    $scope.vnc = {
+      serverHost: 'localhost'
+    , serverPort: '7042'
+    , serverPassword: '12345678'
+    }
+  }
+}
diff --git a/crowdstf/res/app/control-panes/advanced/vnc/vnc-spec.js b/crowdstf/res/app/control-panes/advanced/vnc/vnc-spec.js
new file mode 100644
index 0000000..11c4997
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/vnc/vnc-spec.js
@@ -0,0 +1,17 @@
+describe('VNCCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('VNCCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/advanced/vnc/vnc.css b/crowdstf/res/app/control-panes/advanced/vnc/vnc.css
new file mode 100644
index 0000000..7f2a668
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/vnc/vnc.css
@@ -0,0 +1,4 @@
+.stf-vnc {
+
+}
+
diff --git a/crowdstf/res/app/control-panes/advanced/vnc/vnc.jade b/crowdstf/res/app/control-panes/advanced/vnc/vnc.jade
new file mode 100644
index 0000000..949d7ca
--- /dev/null
+++ b/crowdstf/res/app/control-panes/advanced/vnc/vnc.jade
@@ -0,0 +1,28 @@
+.widget-container.fluid-height.stf-vnc(ng-controller='VNCCtrl')
+  .heading
+    stacked-icon(icon='fa-eye', color='color-darkgreen')
+    span(translate) VNC
+
+    button.btn.pull-right.btn-sm.btn-primary-outline(
+    ng-click='generateVNCLogin()', uib-tooltip='{{"Generate Login for VNC"|translate}}')
+      i.fa.fa-plus.fa-fw
+
+  .widget-content.padded
+    form(name='vncloginform', ng-show='vnc.serverHost')
+      table.table.table-condensed
+        thead
+          tr
+            th(colspan='1')
+              span(translate) Server
+            th(colspan='1')
+              span(translate) Port
+            th(colspan='1')
+              span(translate) Password
+        tbody
+          tr
+            td(width='50%')
+              input.form-control.input-sm(type='text', ng-model='vnc.serverHost', readonly, text-focus-select).vnc-server-host
+            td(width='20%')
+              input.form-control.input-sm(type='text', ng-model='vnc.serverPort', readonly, text-focus-select).vnc-server-port
+            td(width='30%')
+              input.form-control.input-sm(type='text', ng-model='vnc.serverPassword', readonly, text-focus-select).vnc-server-password
diff --git a/crowdstf/res/app/control-panes/automation/automation.jade b/crowdstf/res/app/control-panes/automation/automation.jade
new file mode 100644
index 0000000..ada06d2
--- /dev/null
+++ b/crowdstf/res/app/control-panes/automation/automation.jade
@@ -0,0 +1,5 @@
+.row
+  .col-md-6
+    div(ng-include='"control-panes/automation/store-account/store-account.jade"')
+  .col-md-6
+    div(ng-include='"control-panes/automation/device-settings/device-settings.jade"')
diff --git a/crowdstf/res/app/control-panes/automation/device-settings/device-settings-controller.js b/crowdstf/res/app/control-panes/automation/device-settings/device-settings-controller.js
new file mode 100644
index 0000000..22de39d
--- /dev/null
+++ b/crowdstf/res/app/control-panes/automation/device-settings/device-settings-controller.js
@@ -0,0 +1,41 @@
+module.exports = function DeviceSettingsCtrl($scope, $timeout) {
+  $scope.wifiEnabled = true
+
+  function getWifiStatus() {
+    if ($scope.control) {
+      $scope.control.getWifiStatus().then(function(result) {
+        $scope.$apply(function() {
+          $scope.wifiEnabled = (result.lastData === 'wifi_enabled')
+        })
+      })
+    }
+  }
+  getWifiStatus()
+
+  $scope.toggleWifi = function(enable) {
+    if ($scope.control) {
+      $scope.control.setWifiEnabled(enable)
+      $timeout(getWifiStatus, 2500)
+    }
+  }
+
+  $scope.$watch('ringerMode', function(newValue, oldValue) {
+    if (oldValue) {
+      if ($scope.control) {
+        $scope.control.setRingerMode(newValue)
+      }
+    }
+  })
+
+  function getRingerMode() {
+    if ($scope.control) {
+      $scope.control.getRingerMode().then(function(result) {
+        $scope.$apply(function() {
+          $scope.ringerMode = result.body
+        })
+      })
+    }
+  }
+  getRingerMode()
+
+}
diff --git a/crowdstf/res/app/control-panes/automation/device-settings/device-settings-spec.js b/crowdstf/res/app/control-panes/automation/device-settings/device-settings-spec.js
new file mode 100644
index 0000000..6c49994
--- /dev/null
+++ b/crowdstf/res/app/control-panes/automation/device-settings/device-settings-spec.js
@@ -0,0 +1,17 @@
+describe('DeviceSettingsCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('DeviceSettingsCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/automation/device-settings/device-settings.css b/crowdstf/res/app/control-panes/automation/device-settings/device-settings.css
new file mode 100644
index 0000000..e780180
--- /dev/null
+++ b/crowdstf/res/app/control-panes/automation/device-settings/device-settings.css
@@ -0,0 +1,3 @@
+.stf-device-settings {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/control-panes/automation/device-settings/device-settings.jade b/crowdstf/res/app/control-panes/automation/device-settings/device-settings.jade
new file mode 100644
index 0000000..6e31287
--- /dev/null
+++ b/crowdstf/res/app/control-panes/automation/device-settings/device-settings.jade
@@ -0,0 +1,32 @@
+.widget-container.fluid-height.stf-device-settings(ng-controller='DeviceSettingsCtrl')
+  .heading
+    stacked-icon(icon='fa-gears', color='color-darkgray')
+    span(translate) Device Settings
+  .widget-content.padded
+    .row
+      .col-md-6
+        h6(translate) Manner Mode
+        .btn-group
+          label.btn.btn-sm.btn-primary-outline(ng-model='ringerMode', uib-btn-radio='"SILENT"', uib-tooltip='{{"Silent Mode" | translate}}')
+            i.fa.fa-volume-off.fa-fw
+          label.btn.btn-sm.btn-primary-outline(ng-model='ringerMode', uib-btn-radio='"VIBRATE"', uib-tooltip='{{"Vibrate Mode" | translate}}')
+            i.fa.fa-mobile.fa-fw
+          label.btn.btn-sm.btn-primary-outline(ng-model='ringerMode', uib-btn-radio='"NORMAL"', uib-tooltip='{{"Normal Mode" | translate}}')
+            i.fa.fa-volume-up.fa-fw
+
+      .col-md-6
+        h6(translate) WiFi
+        .btn-group
+          label.btn.btn-sm.btn-primary-outline(ng-model='wifiEnabled', ng-click='toggleWifi(false)', uib-btn-radio='false', uib-tooltip='{{"Disable WiFi" | translate}}')
+            i.fa.fa-power-off.fa-fw
+          label.btn.btn-sm.btn-primary-outline(ng-model='wifiEnabled', ng-click='toggleWifi(true)', uib-btn-radio='true', uib-tooltip='{{"Enable WiFi" | translate}}')
+            i.fa.fa-wifi.fa-fw
+
+    //.row
+      .col-md-12
+        h6(translate) Lock Rotation
+
+        button.btn.btn-sm.btn-primary-outline(ng-click='toggleLockRotation()',
+        ng-model='lockRotation', uib-btn-checkbox)
+          i.fa.fa-repeat.fa-fw(ng-show='lockRotation', uib-tooltip='{{"Unlock Rotation" | translate}}')
+          i.fa.fa-repeat.fa-fw(ng-hide='lockRotation', uib-tooltip='{{"Lock Rotation" | translate}}')
diff --git a/crowdstf/res/app/control-panes/automation/device-settings/index.js b/crowdstf/res/app/control-panes/automation/device-settings/index.js
new file mode 100644
index 0000000..a3602cc
--- /dev/null
+++ b/crowdstf/res/app/control-panes/automation/device-settings/index.js
@@ -0,0 +1,11 @@
+require('./device-settings.css')
+
+module.exports = angular.module('stf.device-settings', [
+
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/automation/device-settings/device-settings.jade',
+      require('./device-settings.jade')
+    )
+  }])
+  .controller('DeviceSettingsCtrl', require('./device-settings-controller'))
diff --git a/crowdstf/res/app/control-panes/automation/index.js b/crowdstf/res/app/control-panes/automation/index.js
new file mode 100644
index 0000000..3ba2e85
--- /dev/null
+++ b/crowdstf/res/app/control-panes/automation/index.js
@@ -0,0 +1,10 @@
+module.exports = angular.module('stf.automation', [
+  require('./store-account').name,
+  require('./device-settings').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put(
+      'control-panes/automation/automation.jade'
+      , require('./automation.jade')
+    )
+  }])
diff --git a/crowdstf/res/app/control-panes/automation/store-account/index.js b/crowdstf/res/app/control-panes/automation/store-account/index.js
new file mode 100644
index 0000000..28dfc41
--- /dev/null
+++ b/crowdstf/res/app/control-panes/automation/store-account/index.js
@@ -0,0 +1,13 @@
+require('./store-account.css')
+require('angular-ladda')
+
+module.exports = angular.module('stf.store-account', [
+  'angular-ladda',
+  require('stf/common-ui/table').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/automation/store-account/store-account.jade',
+      require('./store-account.jade')
+    )
+  }])
+  .controller('StoreAccountCtrl', require('./store-account-controller'))
diff --git a/crowdstf/res/app/control-panes/automation/store-account/store-account-controller.js b/crowdstf/res/app/control-panes/automation/store-account/store-account-controller.js
new file mode 100644
index 0000000..83ebda3
--- /dev/null
+++ b/crowdstf/res/app/control-panes/automation/store-account/store-account-controller.js
@@ -0,0 +1,53 @@
+module.exports = function StoreAccountCtrl($scope, ngTableParams, $timeout) {
+  // TODO: This should come from the DB
+  $scope.currentAppStore = 'google-play-store'
+  $scope.deviceAppStores = {
+    'google-play-store': {
+      type: 'google-play-store',
+      name: 'Google Play Store',
+      package: 'com.google'
+    }
+  }
+
+  $scope.addingAccount = false
+
+  $scope.addAccount = function() {
+    $scope.addingAccount = true
+    var user = $scope.storeLogin.username.$modelValue
+    var pass = $scope.storeLogin.password.$modelValue
+
+    $scope.control.addAccount(user, pass).then(function() {
+    }).catch(function(result) {
+      throw new Error('Adding account failed', result)
+    }).finally(function() {
+      $scope.addingAccount = false
+      $timeout(function() {
+        getAccounts()
+      }, 500)
+    })
+  }
+
+  $scope.removeAccount = function(account) {
+    var storeAccountType = $scope.deviceAppStores[$scope.currentAppStore].package
+    $scope.control.removeAccount(storeAccountType, account)
+      .then(function() {
+        getAccounts()
+      })
+      .catch(function(result) {
+        throw new Error('Removing account failed', result)
+      })
+  }
+
+  function getAccounts() {
+    var storeAccountType = $scope.deviceAppStores[$scope.currentAppStore].package
+    if ($scope.control) {
+      $scope.control.getAccounts(storeAccountType).then(function(result) {
+        $scope.$apply(function() {
+          $scope.accountsList = result.body
+        })
+      })
+    }
+  }
+
+  getAccounts()
+}
diff --git a/crowdstf/res/app/control-panes/automation/store-account/store-account-spec.js b/crowdstf/res/app/control-panes/automation/store-account/store-account-spec.js
new file mode 100644
index 0000000..e0a3d55
--- /dev/null
+++ b/crowdstf/res/app/control-panes/automation/store-account/store-account-spec.js
@@ -0,0 +1,17 @@
+describe('StoreAccountCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('StoreAccountCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/automation/store-account/store-account.css b/crowdstf/res/app/control-panes/automation/store-account/store-account.css
new file mode 100644
index 0000000..72ab6b5
--- /dev/null
+++ b/crowdstf/res/app/control-panes/automation/store-account/store-account.css
@@ -0,0 +1,8 @@
+.stf-store-account {
+
+}
+
+.stf-store-account .appstore-icon {
+  width: 16px;
+  height: 16px;
+}
diff --git a/crowdstf/res/app/control-panes/automation/store-account/store-account.jade b/crowdstf/res/app/control-panes/automation/store-account/store-account.jade
new file mode 100644
index 0000000..83c9d29
--- /dev/null
+++ b/crowdstf/res/app/control-panes/automation/store-account/store-account.jade
@@ -0,0 +1,56 @@
+.widget-container.fluid-height.stf-store-account(ng-controller='StoreAccountCtrl')
+  .heading
+    stacked-icon(icon='fa-cloud', color='color-skyblue')
+    span(translate) Store Account
+
+    button.btn.btn-sm.btn-primary-outline.pull-right(ng-click='control.openStore()',
+    uib-tooltip='{{"App Store" | translate}}', tooltip-placement="bottom")
+      i.fa.fa-shopping-cart
+  .widget-content.padded
+    div
+      form(name='storeLogin', novalidate, enable-autofill)
+        .form-group
+          .input-group
+            span.input-group-addon
+              i.fa.fa-user.fa-fw
+            input.form-control(ng-model='username', name='username', required, type='text', placeholder='{{"Username"|translate}}',
+            autocorrect='off', autocapitalize='off', spellcheck='false', autocomplete='store-login username')
+          .alert.alert-warning(ng-show='storeLogin.username.$dirty && storeLogin.username.$invalid')
+            span(ng-show='storeLogin.username.$error.required', translate) Please enter your Store username
+        .form-group
+          .input-group
+            span.input-group-addon
+              i.fa.fa-lock.fa-fw
+            input.form-control(ng-model='password', name='password', required, type='password', placeholder='{{"Password"|translate}}',
+            autocorrect='off', autocapitalize='off', spellcheck='false', autocomplete='store-login password')
+          .alert.alert-warning(ng-show='storeLogin.password.$dirty && storeLogin.password.$invalid')
+            span(translate) Please enter your Store password
+
+        .btn-group
+          label.btn.btn-sm.btn-default-outline(ng-repeat='a in deviceAppStores track by a.id',
+            ng-model='currentAppStore', uib-btn-radio='currentAppStore', uib-tooltip='{{a.name}}')
+            img(ng-src='/static/app/appstores/icon/24x24/{{a.type}}.png', ng-show='a.type').appstore-icon.pointer
+
+        // TODO: ladda disabled for now because of a gettext bug
+        button.btn.btn-sm.btn-primary-outline(ng-click='addAccount()', ng-disabled='storeLogin.$invalid',
+        ladda-DISABLED='addingAccount', data-style='expand-left', data-spinner-color='#157afb').pull-right
+          i.fa.fa-spinner.fa-spin.fa-fw(ng-show='addingAccount')
+          i.fa.fa-sign-in.fa-fw
+          span(translate) Sign In
+
+      table(ng-show='accountsList.length').table.table-striped
+        thead
+          tr
+            th
+              span(translate) Account
+            th
+              span(translate) Actions
+        tbody
+          tr(ng-repeat='account in accountsList')
+            td
+              i.fa.fa-user.fa-fw
+              span(ng-bind='account').selectable
+            td
+              button.btn.btn-xs.btn-danger-outline(ng-click='removeAccount(account)')
+                i.fa.fa-sign-out
+                span(translate) Sign Out
diff --git a/crowdstf/res/app/control-panes/control-panes-controller.js b/crowdstf/res/app/control-panes/control-panes-controller.js
new file mode 100644
index 0000000..f026d39
--- /dev/null
+++ b/crowdstf/res/app/control-panes/control-panes-controller.js
@@ -0,0 +1,94 @@
+module.exports =
+  function ControlPanesController($scope, $http, gettext, $routeParams,
+    $timeout, $location, DeviceService, GroupService, ControlService,
+    StorageService, FatalMessageService, SettingsService) {
+
+    var sharedTabs = [
+      {
+        title: gettext('Screenshots'),
+        icon: 'fa-camera color-skyblue',
+        templateUrl: 'control-panes/screenshots/screenshots.jade',
+        filters: ['native', 'web']
+      },
+      {
+        title: gettext('Automation'),
+        icon: 'fa-road color-lila',
+        templateUrl: 'control-panes/automation/automation.jade',
+        filters: ['native', 'web']
+      },
+      {
+        title: gettext('Advanced'),
+        icon: 'fa-bolt color-brown',
+        templateUrl: 'control-panes/advanced/advanced.jade',
+        filters: ['native', 'web']
+      },
+      {
+        title: gettext('File Explorer'),
+        icon: 'fa-folder-open color-blue',
+        templateUrl: 'control-panes/explorer/explorer.jade',
+        filters: ['native', 'web']
+      },
+      {
+        title: gettext('Info'),
+        icon: 'fa-info color-orange',
+        templateUrl: 'control-panes/info/info.jade',
+        filters: ['native', 'web']
+      }
+    ]
+
+    $scope.topTabs = [
+      {
+        title: gettext('Dashboard'),
+        icon: 'fa-dashboard fa-fw color-pink',
+        templateUrl: 'control-panes/dashboard/dashboard.jade',
+        filters: ['native', 'web']
+      }
+    ].concat(angular.copy(sharedTabs))
+
+    $scope.belowTabs = [
+      {
+        title: gettext('Logs'),
+        icon: 'fa-list-alt color-red',
+        templateUrl: 'control-panes/logs/logs.jade',
+        filters: ['native', 'web']
+      }
+    ].concat(angular.copy(sharedTabs))
+
+    $scope.device = null
+    $scope.control = null
+
+    // TODO: Move this out to Ctrl.resolve
+    function getDevice(serial) {
+      DeviceService.get(serial, $scope)
+        .then(function(device) {
+          return GroupService.invite(device)
+        })
+        .then(function(device) {
+          $scope.device = device
+          $scope.control = ControlService.create(device, device.channel)
+
+          // TODO: Change title, flickers too much on Chrome
+          // $rootScope.pageTitle = device.name
+
+          SettingsService.set('lastUsedDevice', serial)
+
+          return device
+        })
+        .catch(function() {
+          $timeout(function() {
+            $location.path('/')
+          })
+        })
+    }
+
+    getDevice($routeParams.serial)
+
+    $scope.$watch('device.state', function(newValue, oldValue) {
+      if (newValue !== oldValue) {
+        if (oldValue === 'using') {
+          FatalMessageService.open($scope.device, false)
+        }
+      }
+    }, true)
+
+  }
diff --git a/crowdstf/res/app/control-panes/control-panes-hotkeys-controller.js b/crowdstf/res/app/control-panes/control-panes-hotkeys-controller.js
new file mode 100644
index 0000000..c4b8bb8
--- /dev/null
+++ b/crowdstf/res/app/control-panes/control-panes-hotkeys-controller.js
@@ -0,0 +1,109 @@
+module.exports =
+  function($scope, gettext, $location, $rootScope, ScopedHotkeysService,
+    $window) {
+
+    $scope.remotePaneSize = '30% + 2px'
+
+    var actions = {
+      previousDevice: function() {
+        // console.log('prev')
+      },
+      nextDevice: function() {
+        // console.log('next')
+      },
+      deviceList: function() {
+        $location.path('/devices/')
+      },
+      switchCharset: function() {
+        $scope.control.keyPress('switch_charset')
+      },
+      // TODO: Refactor this
+      rotateLeft: function() {
+        var angle = 0
+        if ($scope.device && $scope.device.display) {
+          angle = $scope.device.display.rotation
+        }
+        if (angle === 0) {
+          angle = 270
+        } else {
+          angle -= 90
+        }
+        $scope.control.rotate(angle)
+
+        if ($rootScope.standalone) {
+          $window.resizeTo($window.outerHeight, $window.outerWidth)
+        }
+
+      },
+      rotateRight: function() {
+        var angle = 0
+        if ($scope.device && $scope.device.display) {
+          angle = $scope.device.display.rotation
+        }
+        if (angle === 270) {
+          angle = 0
+        } else {
+          angle += 90
+        }
+        $scope.control.rotate(angle)
+
+        if ($rootScope.standalone) {
+          $window.resizeTo($window.outerHeight, $window.outerWidth)
+        }
+      },
+      focusUrlBar: function() {
+        // TODO: Switch tab and focus
+        // console.log('focus')
+      },
+      takeScreenShot: function() {
+        // TODO: Switch tab and take screenshot
+        //$scope.takeScreenShot()
+      },
+      pressMenu: function() {
+        $scope.control.menu()
+      },
+      pressHome: function() {
+        $scope.control.home()
+      },
+      pressBack: function() {
+        $scope.control.back()
+      },
+      toggleDevice: function() {
+        // $scope.controlScreen.show = !$scope.controlScreen.show
+      },
+      togglePlatform: function() {
+        if ($rootScope.platform === 'web') {
+          $rootScope.platform = 'native'
+        } else {
+          $rootScope.platform = 'web'
+        }
+      },
+      scale: function() {
+        // TODO: scale size
+      }
+    }
+
+    ScopedHotkeysService($scope, [
+      // ['shift+up', gettext('Previous Device'), actions.previousDevice],
+      // ['shift+down', gettext('Next Device'), actions.nextDevice],
+      ['command+shift+d', gettext('Go to Device List'), actions.deviceList],
+
+      ['shift+space', gettext('Selects Next IME'), actions.switchCharset],
+      ['command+left', gettext('Rotate Left'), actions.rotateLeft],
+      ['command+right', gettext('Rotate Right'), actions.rotateRight],
+
+      // ['command+1', gettext('Scale 100%'), actions.scale],
+      // ['command+2', gettext('Scale 75%'), actions.scale],
+      // ['command+3', gettext('Scale 50%'), actions.scale],
+
+      // ['shift+l', gettext('Focus URL bar'), actions.focusUrlBar],
+      // ['shift+s', gettext('Take Screenshot'), actions.takeScreenShot],
+
+      ['command+shift+m', gettext('Press Menu button'), actions.pressMenu],
+      ['command+shift+h', gettext('Press Home button'), actions.pressHome],
+      ['command+shift+b', gettext('Press Back button'), actions.pressBack],
+
+      // ['shift+i', gettext('Show/Hide device'), actions.toggleDevice],
+      ['shift+w', gettext('Toggle Web/Native'), actions.togglePlatform, false]
+    ])
+  }
diff --git a/crowdstf/res/app/control-panes/control-panes-no-device-controller.js b/crowdstf/res/app/control-panes/control-panes-no-device-controller.js
new file mode 100644
index 0000000..398202a
--- /dev/null
+++ b/crowdstf/res/app/control-panes/control-panes-no-device-controller.js
@@ -0,0 +1,10 @@
+module.exports =
+  function ControlPanesNoDeviceController($location, SettingsService) {
+    var lastUsedDevice = SettingsService.get('lastUsedDevice')
+
+    if (lastUsedDevice) {
+      $location.path('/control/' + lastUsedDevice)
+    } else {
+      $location.path('/')
+    }
+  }
diff --git a/crowdstf/res/app/control-panes/control-panes-service.js b/crowdstf/res/app/control-panes/control-panes-service.js
new file mode 100644
index 0000000..53daeae
--- /dev/null
+++ b/crowdstf/res/app/control-panes/control-panes-service.js
@@ -0,0 +1,7 @@
+module.exports = function ControlPanesServiceFactory() {
+  var ControlPanesService = {
+  }
+
+
+  return ControlPanesService
+}
diff --git a/crowdstf/res/app/control-panes/control-panes.jade b/crowdstf/res/app/control-panes/control-panes.jade
new file mode 100644
index 0000000..45ee602
--- /dev/null
+++ b/crowdstf/res/app/control-panes/control-panes.jade
@@ -0,0 +1,18 @@
+div(ng-controller='ControlPanesHotKeysCtrl').fill-height
+  div(ng-if='$root.basicMode || $root.standalone').fill-height
+    div.fill-height.basic-remote-control
+      .remote-control
+        div(ng-include='"control-panes/device-control/device-control-standalone.jade"').fill-height
+
+  div(ng-if='!$root.basicMode && !$root.standalone')
+    div(fa-pane, pane-id='control-device', pane-anchor='west', pane-size='{{remotePaneSize}}', pane-min='200px', pane-max='100% + 2px', pane-handle='4', pane-no-toggle='false')
+
+      .remote-control
+        div(ng-include='"control-panes/device-control/device-control.jade"').fill-height
+
+    div(fa-pane, pane-id='control-bottom-tabs', pane-anchor='south', pane-size='30% + 2px', pane-handle='4').pane-bottom-p
+      .widget-container.fluid-height
+        nice-tabs(key='ControlBottomTabs', direction='below', tabs='belowTabs', filter='$root.platform')
+    div(fa-pane, pane-id='control-top-tabs', pane-anchor='')
+      .widget-container.fluid-height
+        nice-tabs(key='ControlBottomTabs', tabs='topTabs', filter='$root.platform')
diff --git a/crowdstf/res/app/control-panes/cpu/cpu-controller.js b/crowdstf/res/app/control-panes/cpu/cpu-controller.js
new file mode 100644
index 0000000..bc4038e
--- /dev/null
+++ b/crowdstf/res/app/control-panes/cpu/cpu-controller.js
@@ -0,0 +1,3 @@
+module.exports = function CpuCtrl() {
+
+}
diff --git a/crowdstf/res/app/control-panes/cpu/cpu-spec.js b/crowdstf/res/app/control-panes/cpu/cpu-spec.js
new file mode 100644
index 0000000..1798789
--- /dev/null
+++ b/crowdstf/res/app/control-panes/cpu/cpu-spec.js
@@ -0,0 +1,17 @@
+describe('CpuCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('CpuCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/cpu/cpu.css b/crowdstf/res/app/control-panes/cpu/cpu.css
new file mode 100644
index 0000000..1ea7563
--- /dev/null
+++ b/crowdstf/res/app/control-panes/cpu/cpu.css
@@ -0,0 +1,3 @@
+.stf-cpu {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/control-panes/cpu/cpu.jade b/crowdstf/res/app/control-panes/cpu/cpu.jade
new file mode 100644
index 0000000..4b7be67
--- /dev/null
+++ b/crowdstf/res/app/control-panes/cpu/cpu.jade
@@ -0,0 +1,9 @@
+.widget-container.fluid-height.stf-cpu(ng-controller='CpuCtrl')
+  .widget-content.padded
+    div.overflow-x
+      ul
+        li(ng-repeat="(id, stats) in data")
+          span {{ stats.deviceName }}
+          ul
+            li(ng-repeat="(cpu, load) in stats.loads")
+              span {{cpu}}: {{load.user + load.nice + load.system}}%
diff --git a/crowdstf/res/app/control-panes/cpu/index.js b/crowdstf/res/app/control-panes/cpu/index.js
new file mode 100644
index 0000000..49ce5b4
--- /dev/null
+++ b/crowdstf/res/app/control-panes/cpu/index.js
@@ -0,0 +1,11 @@
+require('./cpu.css')
+
+module.exports = angular.module('stf.cpu', [
+
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/cpu/cpu.jade',
+      require('./cpu.jade')
+    )
+  }])
+  .controller('CpuCtrl', require('./cpu-controller'))
diff --git a/crowdstf/res/app/control-panes/dashboard/apps/apps-controller.js b/crowdstf/res/app/control-panes/dashboard/apps/apps-controller.js
new file mode 100644
index 0000000..f8d5ca0
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/apps/apps-controller.js
@@ -0,0 +1,67 @@
+// See https://github.com/android/platform_packages_apps_settings/blob/master/AndroidManifest.xml
+
+module.exports = function ShellCtrl($scope) {
+  $scope.result = null
+
+  var run = function(cmd) {
+    var command = cmd
+    // Force run activity
+    command += ' --activity-clear-top'
+    return $scope.control.shell(command)
+      .then(function(result) {
+        // console.log(result)
+      })
+  }
+
+  // TODO: Move this to server side
+  // TODO: Android 2.x doesn't support openSetting(), account for that on the UI
+
+  function openSetting(activity) {
+    run('am start -a android.intent.action.MAIN -n com.android.settings/.Settings\\$' +
+    activity)
+  }
+
+  $scope.openSettings = function() {
+    run('am start -a android.intent.action.MAIN -n com.android.settings/.Settings')
+  }
+
+  $scope.openWiFiSettings = function() {
+    //openSetting('WifiSettingsActivity')
+    run('am start -a android.settings.WIFI_SETTINGS')
+  }
+
+  $scope.openLocaleSettings = function() {
+    openSetting('LocalePickerActivity')
+  }
+
+  $scope.openIMESettings = function() {
+    openSetting('KeyboardLayoutPickerActivity')
+  }
+
+  $scope.openDisplaySettings = function() {
+    openSetting('DisplaySettingsActivity')
+  }
+
+  $scope.openDeviceInfo = function() {
+    openSetting('DeviceInfoSettingsActivity')
+  }
+
+  $scope.openManageApps = function() {
+    //openSetting('ManageApplicationsActivity')
+    run('am start -a android.settings.APPLICATION_SETTINGS')
+  }
+
+  $scope.openRunningApps = function() {
+    openSetting('RunningServicesActivity')
+  }
+
+  $scope.openDeveloperSettings = function() {
+    openSetting('DevelopmentSettingsActivity')
+  }
+
+  $scope.clear = function() {
+    $scope.command = ''
+    $scope.data = ''
+    $scope.result = null
+  }
+}
diff --git a/crowdstf/res/app/control-panes/dashboard/apps/apps.css b/crowdstf/res/app/control-panes/dashboard/apps/apps.css
new file mode 100644
index 0000000..f866a1b
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/apps/apps.css
@@ -0,0 +1,26 @@
+.stf-apps .icon-title {
+  font-size: 12px;
+  width: 70px;
+}
+
+.stf-apps .icon-app {
+  margin: 0;
+  text-align: center;
+}
+
+.stf-apps .heading .icon-app {
+  margin-top: -8px;
+}
+
+.stf-apps .icon-app:hover .fa {
+  color: #fff;
+}
+
+.stf-apps .heading .icon-app .fa {
+  font-size: 19px !important;
+  line-height: 14px !important;
+}
+
+.stf-apps .padded {
+  padding-bottom: 8px;
+}
diff --git a/crowdstf/res/app/control-panes/dashboard/apps/apps.jade b/crowdstf/res/app/control-panes/dashboard/apps/apps.jade
new file mode 100644
index 0000000..211e877
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/apps/apps.jade
@@ -0,0 +1,27 @@
+.widget-container.fluid-height.stf-apps(ng-controller='AppsCtrl')
+  .heading
+    stacked-icon(icon='fa-th-large', color='color-lila')
+    span(translate) Apps
+
+    button.btn.btn-primary-outline.icon-app.pull-right(ng-click='openSettings()')
+      i.fa.fa-gear.fa-lg.color-darkgray
+      .icon-title {{"Settings" | translate}}
+
+    button.btn.btn-primary-outline.icon-app.pull-right(ng-click='control.openStore()').color-pink
+      i.fa.fa-shopping-cart.fa-lg.color-blue
+      .icon-title {{"App Store" | translate}}
+
+  .widget-content.padded
+    button.btn.btn-primary-outline.icon-app.pull-right(ng-click='openWiFiSettings()')
+      i.fa.fa-wifi.fa-lg.color-skyblue
+      .icon-title {{"WiFi" | translate}}
+
+    button.btn.btn-primary-outline.icon-app.pull-right(ng-click='openManageApps()')
+      i.fa.fa-list.fa-lg.color-orange
+      .icon-title {{"Manage Apps" | translate}}
+
+    button.btn.btn-primary-outline.icon-app.pull-right(ng-click='openDeveloperSettings()')
+      i.fa.fa-sliders.fa-lg.color-darkgreen
+      .icon-title {{"Developer" | translate}}
+
+    .clearfix
diff --git a/crowdstf/res/app/control-panes/dashboard/apps/index.js b/crowdstf/res/app/control-panes/dashboard/apps/index.js
new file mode 100644
index 0000000..9ecb204
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/apps/index.js
@@ -0,0 +1,11 @@
+require('./apps.css')
+
+module.exports = angular.module('stf.apps', [
+  require('stf/common-ui').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/dashboard/apps/apps.jade',
+      require('./apps.jade')
+    )
+  }])
+  .controller('AppsCtrl', require('./apps-controller'))
diff --git a/crowdstf/res/app/control-panes/dashboard/clipboard/clipboard-controller.js b/crowdstf/res/app/control-panes/dashboard/clipboard/clipboard-controller.js
new file mode 100644
index 0000000..1523878
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/clipboard/clipboard-controller.js
@@ -0,0 +1,21 @@
+module.exports = function ClipboardCtrl() {
+//  $scope.clipboardContent = null
+//
+//  $scope.getClipboardContent = function () {
+//    console.log('getting')
+//
+//    $scope.control.copy().then(function (result) {
+//      $scope.$apply(function () {
+//        if (result.success) {
+//          if (result.lastData) {
+//            $scope.clipboardContent = result.lastData
+//          } else {
+//            $scope.clipboardContent = gettext('No clipboard data')
+//          }
+//        } else {
+//          $scope.clipboardContent = gettext('Error while getting data')
+//        }
+//      })
+//    })
+//  }
+}
diff --git a/crowdstf/res/app/control-panes/dashboard/clipboard/clipboard-spec.js b/crowdstf/res/app/control-panes/dashboard/clipboard/clipboard-spec.js
new file mode 100644
index 0000000..d836c56
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/clipboard/clipboard-spec.js
@@ -0,0 +1,17 @@
+describe('ClipboardCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('ClipboardCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/dashboard/clipboard/clipboard.css b/crowdstf/res/app/control-panes/dashboard/clipboard/clipboard.css
new file mode 100644
index 0000000..2cfe392
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/clipboard/clipboard.css
@@ -0,0 +1,7 @@
+.stf-clipboard {
+
+}
+
+.stf-clipboard .clipboard-textarea {
+  resize: none;
+}
diff --git a/crowdstf/res/app/control-panes/dashboard/clipboard/clipboard.jade b/crowdstf/res/app/control-panes/dashboard/clipboard/clipboard.jade
new file mode 100644
index 0000000..3378e4c
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/clipboard/clipboard.jade
@@ -0,0 +1,11 @@
+.widget-container.fluid-height.stf-clipboard(ng-controller='ClipboardCtrl')
+  .heading
+    stacked-icon(icon='fa-copy', color='color-brown')
+    span(translate) Clipboard
+  .widget-content.padded
+    .input-group.form-inline
+      textarea(rows='1', ng-model='control.clipboardContent', msd-elastic, text-focus-select,
+      tabindex='20').form-control.clipboard-textarea
+      span.input-group-btn
+        button.btn.btn-primary-outline(ng-click='control.getClipboardContent()', uib-tooltip='{{ "Get clipboard contents" | translate }}')
+          i.fa.fa-refresh
diff --git a/crowdstf/res/app/control-panes/dashboard/clipboard/index.js b/crowdstf/res/app/control-panes/dashboard/clipboard/index.js
new file mode 100644
index 0000000..d51c363
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/clipboard/index.js
@@ -0,0 +1,14 @@
+require('./clipboard.css')
+
+require('angular-elastic')
+
+module.exports = angular.module('stf.clipboard', [
+  'monospaced.elastic',
+  require('gettext').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/dashboard/clipboard/clipboard.jade',
+      require('./clipboard.jade')
+    )
+  }])
+  .controller('ClipboardCtrl', require('./clipboard-controller'))
diff --git a/crowdstf/res/app/control-panes/dashboard/dashboard-controller.js b/crowdstf/res/app/control-panes/dashboard/dashboard-controller.js
new file mode 100644
index 0000000..7db018f
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/dashboard-controller.js
@@ -0,0 +1,3 @@
+module.exports = function DashboardCtrl() {
+
+}
diff --git a/crowdstf/res/app/control-panes/dashboard/dashboard-spec.js b/crowdstf/res/app/control-panes/dashboard/dashboard-spec.js
new file mode 100644
index 0000000..7b73334
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/dashboard-spec.js
@@ -0,0 +1,17 @@
+describe('DashboardCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('DashboardCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/dashboard/dashboard.jade b/crowdstf/res/app/control-panes/dashboard/dashboard.jade
new file mode 100644
index 0000000..ae38fd2
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/dashboard.jade
@@ -0,0 +1,16 @@
+.row
+  .col-md-6
+    div(ng-include='"control-panes/dashboard/navigation/navigation.jade"')
+  .col-md-6
+    div(ng-include='"control-panes/dashboard/clipboard/clipboard.jade"')
+
+.row
+  .col-md-6
+    div(ng-include='"control-panes/dashboard/install/install.jade"')
+  .col-md-6(ng-if='$root.platform == "native"')
+    div(ng-include='"control-panes/dashboard/shell/shell.jade"')
+.row
+  .col-md-6
+    div(ng-include='"control-panes/dashboard/apps/apps.jade"')
+  .col-md-6
+    div(ng-include='"control-panes/advanced/remote-debug/remote-debug.jade"')
diff --git a/crowdstf/res/app/control-panes/dashboard/index.js b/crowdstf/res/app/control-panes/dashboard/index.js
new file mode 100644
index 0000000..4ee62e6
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/index.js
@@ -0,0 +1,15 @@
+module.exports = angular.module('stf.dashboard', [
+  require('./navigation/index').name,
+  require('./shell/index').name,
+  require('./install/index').name,
+  require('./apps/index').name,
+  require('./clipboard/index').name,
+  require('./remote-debug/index').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put(
+      'control-panes/dashboard/dashboard.jade'
+      , require('./dashboard.jade')
+    )
+  }])
+  .controller('DashboardCtrl', require('./dashboard-controller'))
diff --git a/crowdstf/res/app/control-panes/dashboard/install/activities/activities-controller.js b/crowdstf/res/app/control-panes/dashboard/install/activities/activities-controller.js
new file mode 100644
index 0000000..b1afb24
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/install/activities/activities-controller.js
@@ -0,0 +1,96 @@
+var _ = require('lodash')
+
+module.exports = function ActivitiesCtrl($scope) {
+  $scope.selectedAction = ''
+  $scope.selectedCategory = ''
+  $scope.selectedData = ''
+  $scope.selectedPackageName = $scope.installation &&
+    $scope.installation.manifest && $scope.installation.manifest.package ?
+    $scope.installation.manifest.package : ''
+  $scope.selectedActivityName = ''
+
+  $scope.activityActions = []
+  $scope.activityCategories = []
+  $scope.activityData = []
+  $scope.packageNames = [$scope.selectedPackageName]
+  $scope.activityNames = []
+
+  $scope.$watch('installation.manifest.application', function(newValue) {
+    if (newValue.activities) {
+      var activityActions = []
+      var activityCategories = []
+      var activityData = []
+      var activityNames = []
+
+      _.forEach(newValue.activities, function(activity) {
+        if (activity.name) {
+          activityNames.push(activity.name)
+        }
+
+        _.forEach(activity.intentFilters, function(intentFilter) {
+
+          _.forEach(intentFilter.actions, function(action) {
+            if (action.name) {
+              activityActions.push(action.name)
+            }
+          })
+
+          _.forEach(intentFilter.categories, function(category) {
+            if (category.name) {
+              activityCategories.push(category.name)
+            }
+          })
+
+          _.forEach(intentFilter.data, function(data) {
+            if (data.scheme) {
+              var uri = data.scheme + '://'
+              if (data.host) {
+                uri += data.host
+              }
+              if (data.port) {
+                uri += data.port
+              }
+              if (data.path) {
+                uri += '/' + data.path
+              } else if (data.pathPrefix) {
+                uri += '/' + data.pathPrefix
+              } else if (data.pathPattern) {
+                uri += '/' + data.pathPattern
+              }
+              activityData.push(uri)
+            }
+            if (data.mimeType) {
+              activityData.push(data.mimeType)
+            }
+          })
+        })
+      })
+      $scope.activityActions = _.uniq(activityActions)
+      $scope.activityCategories = _.uniq(activityCategories)
+      $scope.activityData = _.uniq(activityData)
+      $scope.activityNames = _.uniq(activityNames)
+    }
+  })
+
+  $scope.runActivity = function() {
+    var command = 'am start'
+    if ($scope.selectedAction) {
+      command += ' -a ' + $scope.selectedAction
+    }
+    if ($scope.selectedCategory) {
+      command += ' -c ' + $scope.selectedCategory
+    }
+    if ($scope.selectedData) {
+      command += ' -d ' + $scope.selectedData
+    }
+    if ($scope.selectedPackageName && $scope.selectedActivityName) {
+      command += ' -n ' +
+        $scope.selectedPackageName + '/' + $scope.selectedActivityName
+    }
+
+    return $scope.control.shell(command)
+      .then(function(result) {
+        // console.log(result)
+      })
+  }
+}
diff --git a/crowdstf/res/app/control-panes/dashboard/install/activities/activities-spec.js b/crowdstf/res/app/control-panes/dashboard/install/activities/activities-spec.js
new file mode 100644
index 0000000..511582f
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/install/activities/activities-spec.js
@@ -0,0 +1,17 @@
+describe('ActivitiesCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('ActivitiesCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/dashboard/install/activities/activities.css b/crowdstf/res/app/control-panes/dashboard/install/activities/activities.css
new file mode 100644
index 0000000..c23327f
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/install/activities/activities.css
@@ -0,0 +1,3 @@
+.stf-activities {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/control-panes/dashboard/install/activities/activities.jade b/crowdstf/res/app/control-panes/dashboard/install/activities/activities.jade
new file mode 100644
index 0000000..62f3070
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/install/activities/activities.jade
@@ -0,0 +1,48 @@
+div(ng-controller='ActivitiesCtrl')
+  form
+    table.table.table-condensed
+      tbody
+        tr
+          td(translate) Package
+          td
+            input.form-control(type='text', placeholder='', ng-model='selectedPackageName',
+              list='packageList')
+            datalist(id='packageList')
+              option(ng-repeat='packageName in packageNames', ng-value='packageName')
+            //typeahead='packageName for packageName in packageNames')
+        tr
+          td(translate) Activity
+          td
+            input.form-control(type='text', placeholder='', ng-model='selectedActivityName',
+              list='activityList')
+            datalist(id='activityList')
+              option(ng-repeat='activityName in activityNames', ng-value='activityName')
+            //typeahead='activityName for activityName in activityNames')
+        tr
+          td(translate) Action
+          td
+            input.form-control(type='text', placeholder='', ng-model='selectedAction',
+            list='actionList')
+            datalist(id='actionList')
+              option(ng-repeat='action in activityActions', ng-value='action')
+            //typeahead='action for action in activityActions')
+        tr
+          td(translate) Category
+          td
+            input.form-control(type='text', placeholder='', ng-model='selectedCategory',
+            list='categoryList')
+            datalist(id='categoryList')
+              option(ng-repeat='category in activityCategories', ng-value='category')
+            //typeahead='category for category in activityCategories')
+        tr
+          td(translate) Data
+          td
+            input.form-control(type='text', placeholder='', ng-model='selectedData',
+            list='dataList')
+            datalist(id='dataList')
+              option(ng-repeat='data in activityData', ng-value='data')
+            //typeahead='data for data in activityData', id='selectedData')
+
+    button.btn.btn-sm.btn-primary-outline(ng-click='runActivity()').pull-right
+      i.fa.fa-play
+      span(translate) Launch Activity
diff --git a/crowdstf/res/app/control-panes/dashboard/install/activities/index.js b/crowdstf/res/app/control-panes/dashboard/install/activities/index.js
new file mode 100644
index 0000000..490414a
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/install/activities/index.js
@@ -0,0 +1,12 @@
+require('./activities.css')
+
+module.exports = angular.module('stf.activities', [
+  require('stf/common-ui').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put(
+      'control-panes/dashboard/install/activities/activities.jade',
+      require('./activities.jade')
+    )
+  }])
+  .controller('ActivitiesCtrl', require('./activities-controller'))
diff --git a/crowdstf/res/app/control-panes/dashboard/install/activities/test/manifest-1.json b/crowdstf/res/app/control-panes/dashboard/install/activities/test/manifest-1.json
new file mode 100644
index 0000000..51feb2e
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/install/activities/test/manifest-1.json
@@ -0,0 +1,376 @@
+{
+  "versionCode": 20,
+  "versionName": "1.0.18",
+  "installLocation": 0,
+  "package": "jp.ameba.palette",
+  "usesPermissions": [
+    {
+      "name": "android.permission.INTERNET"
+    },
+    {
+      "name": "android.permission.ACCESS_NETWORK_STATE"
+    },
+    {
+      "name": "android.permission.WRITE_EXTERNAL_STORAGE"
+    },
+    {
+      "name": "jp.ameba.palette.permission.C2D_MESSAGE"
+    },
+    {
+      "name": "com.google.android.c2dm.permission.RECEIVE"
+    },
+    {
+      "name": "android.permission.GET_ACCOUNTS"
+    },
+    {
+      "name": "android.permission.WAKE_LOCK"
+    },
+    {
+      "name": "android.permission.VIBRATE"
+    }
+  ],
+  "permissions": [
+    {
+      "name": "jp.ameba.palette.permission.C2D_MESSAGE",
+      "protectionLevel": 2
+    }
+  ],
+  "permissionTrees": [],
+  "permissionGroups": [],
+  "instrumentation": null,
+  "usesSdk": {
+    "minSdkVersion": 10
+  },
+  "usesConfiguration": null,
+  "usesFeatures": [],
+  "supportsScreens": {
+    "xlargeScreens": false
+  },
+  "compatibleScreens": [],
+  "supportsGlTextures": [],
+  "application": {
+    "theme": "resourceId:0x7f0d005c",
+    "label": "resourceId:0x7f090019",
+    "icon": "resourceId:0x7f0200b4",
+    "name": "jp.ameba.palette.LaPaletteApplication",
+    "debuggable": true,
+    "allowBackup": true,
+    "hardwareAccelerated": true,
+    "activities": [
+      {
+        "theme": "resourceId:0x7f0d0055",
+        "label": "resourceId:0x7f090023",
+        "name": "jp.ameba.palette.SplashActivity",
+        "screenOrientation": 1,
+        "configChanges": 160,
+        "intentFilters": [
+          {
+            "actions": [
+              {
+                "name": "android.intent.action.MAIN"
+              }
+            ],
+            "categories": [
+              {
+                "name": "android.intent.category.LAUNCHER"
+              }
+            ],
+            "data": []
+          },
+          {
+            "actions": [
+              {
+                "name": "android.intent.action.VIEW"
+              }
+            ],
+            "categories": [
+              {
+                "name": "android.intent.category.DEFAULT"
+              }
+            ],
+            "data": []
+          }
+        ],
+        "metaData": []
+      },
+      {
+        "theme": "resourceId:0x7f0d0055",
+        "label": "resourceId:0x7f090023",
+        "name": "jp.ameba.palette.MainActivity",
+        "screenOrientation": 1,
+        "configChanges": 160,
+        "intentFilters": [
+          {
+            "actions": [
+              {
+                "name": "android.intent.action.VIEW"
+              }
+            ],
+            "categories": [
+              {
+                "name": "android.intent.category.BROWSABLE"
+              },
+              {
+                "name": "android.intent.category.DEFAULT"
+              }
+            ],
+            "data": [
+              {
+                "scheme": "jp-ajmp-6333"
+              }
+            ]
+          }
+        ],
+        "metaData": []
+      },
+      {
+        "theme": "resourceId:0x1030006",
+        "label": "resourceId:0x7f090019",
+        "name": "jp.ameba.palette.DecoEditorActivity",
+        "screenOrientation": 1,
+        "configChanges": 160,
+        "windowSoftInputMode": 16,
+        "intentFilters": [],
+        "metaData": []
+      },
+      {
+        "name": "jp.ameba.palette.GateWayActivity",
+        "intentFilters": [
+          {
+            "actions": [
+              {
+                "name": "android.intent.action.VIEW"
+              }
+            ],
+            "categories": [
+              {
+                "name": "android.intent.category.DEFAULT"
+              },
+              {
+                "name": "android.intent.category.BROWSABLE"
+              }
+            ],
+            "data": [
+              {
+                "scheme": "ca-palette"
+              }
+            ]
+          },
+          {
+            "actions": [
+              {
+                "name": "android.intent.action.VIEW"
+              }
+            ],
+            "categories": [
+              {
+                "name": "android.intent.category.DEFAULT"
+              },
+              {
+                "name": "android.intent.category.BROWSABLE"
+              }
+            ],
+            "data": [
+              {
+                "scheme": "https",
+                "host": "ca-palette.jp"
+              },
+              {
+                "scheme": "http",
+                "host": "ca-palette.jp"
+              }
+            ]
+          },
+          {
+            "actions": [
+              {
+                "name": "android.intent.action.VIEW"
+              }
+            ],
+            "categories": [
+              {
+                "name": "android.intent.category.DEFAULT"
+              },
+              {
+                "name": "android.intent.category.BROWSABLE"
+              }
+            ],
+            "data": [
+              {
+                "scheme": "https",
+                "host": "stg-ca-palette.jp"
+              },
+              {
+                "scheme": "http",
+                "host": "stg-ca-palette.jp"
+              }
+            ]
+          }
+        ],
+        "metaData": []
+      },
+      {
+        "theme": "resourceId:0x1030006",
+        "label": "resourceId:0x7f090019",
+        "name": "jp.ameba.palette.CredibleSiteActivity",
+        "screenOrientation": 1,
+        "configChanges": 160,
+        "intentFilters": [],
+        "metaData": []
+      }
+    ],
+    "activityAliases": [],
+    "launcherActivities": [
+      {
+        "theme": "resourceId:0x7f0d0055",
+        "label": "resourceId:0x7f090023",
+        "name": "jp.ameba.palette.SplashActivity",
+        "screenOrientation": 1,
+        "configChanges": 160,
+        "intentFilters": [
+          {
+            "actions": [
+              {
+                "name": "android.intent.action.MAIN"
+              }
+            ],
+            "categories": [
+              {
+                "name": "android.intent.category.LAUNCHER"
+              }
+            ],
+            "data": []
+          },
+          {
+            "actions": [
+              {
+                "name": "android.intent.action.VIEW"
+              }
+            ],
+            "categories": [
+              {
+                "name": "android.intent.category.DEFAULT"
+              }
+            ],
+            "data": []
+          }
+        ],
+        "metaData": []
+      }
+    ],
+    "services": [
+      {
+        "name": "jp.ameba.palette.service.DecoResourceService",
+        "intentFilters": [],
+        "metaData": []
+      },
+      {
+        "name": "jp.ameba.palette.service.PushSynchronizeService",
+        "intentFilters": [],
+        "metaData": []
+      },
+      {
+        "name": ".GCMIntentService",
+        "intentFilters": [],
+        "metaData": []
+      },
+      {
+        "name": "jp.co.CAReward_Ack.CARAck",
+        "intentFilters": [],
+        "metaData": []
+      },
+      {
+        "name": "com.appanalyzerseed.ReferrerService",
+        "intentFilters": [],
+        "metaData": []
+      }
+    ],
+    "receivers": [
+      {
+        "name": "com.google.android.gcm.GCMBroadcastReceiver",
+        "permission": "com.google.android.c2dm.permission.SEND",
+        "intentFilters": [
+          {
+            "actions": [
+              {
+                "name": "com.google.android.c2dm.intent.RECEIVE"
+              },
+              {
+                "name": "com.google.android.c2dm.intent.REGISTRATION"
+              }
+            ],
+            "categories": [
+              {
+                "name": "jp.ameba.palette"
+              }
+            ],
+            "data": []
+          }
+        ],
+        "metaData": []
+      },
+      {
+        "name": "jp.ameba.palette.push.GrowthPushReceiver",
+        "permission": "com.google.android.c2dm.permission.SEND",
+        "intentFilters": [
+          {
+            "actions": [
+              {
+                "name": "com.google.android.c2dm.intent.RECEIVE"
+              },
+              {
+                "name": "com.google.android.c2dm.intent.REGISTRATION"
+              }
+            ],
+            "categories": [
+              {
+                "name": "jp.ameba.palette"
+              }
+            ],
+            "data": []
+          }
+        ],
+        "metaData": []
+      },
+      {
+        "name": "jp.co.CAReward_Receiver.CARReceiver",
+        "exported": true,
+        "intentFilters": [
+          {
+            "actions": [
+              {
+                "name": "com.android.vending.INSTALL_REFERRER"
+              }
+            ],
+            "categories": [],
+            "data": []
+          }
+        ],
+        "metaData": []
+      },
+      {
+        "name": "com.appanalyzerseed.ReferrerReceiver",
+        "exported": true,
+        "intentFilters": [
+          {
+            "actions": [
+              {
+                "name": "com.android.vending.INSTALL_REFERRER"
+              }
+            ],
+            "categories": [],
+            "data": []
+          }
+        ],
+        "metaData": [
+          {
+            "name": " com.appanalyzerseed.FORWARD_REFERRER",
+            "value": "jp.co.CAReward_Receiver.CARReceiver"
+          }
+        ]
+      }
+    ],
+    "providers": [],
+    "usesLibraries": []
+  }
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/control-panes/dashboard/install/index.js b/crowdstf/res/app/control-panes/dashboard/install/index.js
new file mode 100644
index 0000000..b5ebb85
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/install/index.js
@@ -0,0 +1,18 @@
+require('./install.css')
+
+require('ng-file-upload')
+
+module.exports = angular.module('stf.install', [
+  'angularFileUpload',
+  require('./activities').name,
+  require('stf/settings').name,
+  require('stf/storage').name,
+  require('stf/install').name,
+  require('stf/upload').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/dashboard/install/install.jade',
+      require('./install.jade')
+    )
+  }])
+  .controller('InstallCtrl', require('./install-controller'))
diff --git a/crowdstf/res/app/control-panes/dashboard/install/install-controller.js b/crowdstf/res/app/control-panes/dashboard/install/install-controller.js
new file mode 100644
index 0000000..a49193c
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/install/install-controller.js
@@ -0,0 +1,36 @@
+module.exports = function InstallCtrl(
+  $scope
+, InstallService
+) {
+  $scope.accordionOpen = true
+  $scope.installation = null
+
+  $scope.clear = function() {
+    $scope.installation = null
+    $scope.accordionOpen = false
+  }
+
+  $scope.$on('installation', function(e, installation) {
+    $scope.installation = installation.apply($scope)
+  })
+
+  $scope.installUrl = function(url) {
+    return InstallService.installUrl($scope.control, url)
+  }
+
+  $scope.installFile = function($files) {
+    if ($files.length) {
+      return InstallService.installFile($scope.control, $files)
+    }
+  }
+
+  $scope.uninstall = function(packageName) {
+    // TODO: After clicking uninstall accordion opens
+    return $scope.control.uninstall(packageName)
+      .then(function() {
+        $scope.$apply(function() {
+          $scope.clear()
+        })
+      })
+  }
+}
diff --git a/crowdstf/res/app/control-panes/dashboard/install/install-spec.js b/crowdstf/res/app/control-panes/dashboard/install/install-spec.js
new file mode 100644
index 0000000..438ab6d
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/install/install-spec.js
@@ -0,0 +1,17 @@
+describe('InstallCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('InstallCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/dashboard/install/install.css b/crowdstf/res/app/control-panes/dashboard/install/install.css
new file mode 100644
index 0000000..0bb05df
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/install/install.css
@@ -0,0 +1,45 @@
+.stf-upload .btn-file {
+  position: relative;
+  overflow: hidden;
+}
+
+.stf-upload .btn-file input[type=file] {
+  position: absolute;
+  top: 0;
+  right: 0;
+  min-width: 100%;
+  min-height: 100%;
+  font-size: 999px;
+  text-align: right;
+  opacity: 0;
+  outline: none;
+  background: white;
+  cursor: inherit;
+  display: block;
+}
+
+.stf-upload .drop-area {
+  text-align: center;
+  color: #b7b7b7;
+  padding-top: 10px;
+  border: 2px transparent dashed;
+  border-radius: 2px;
+  cursor: pointer;
+}
+
+.stf-upload .dragover {
+  border-color: #157afb !important;
+}
+
+.stf-upload .upload-status {
+  margin-top: 15px;
+}
+
+.stf-upload .manifest-text {
+  font-size: 10px;
+}
+
+.stf-upload .drop-area-text {
+  font-size: 14px;
+  font-weight: 300;
+}
diff --git a/crowdstf/res/app/control-panes/dashboard/install/install.jade b/crowdstf/res/app/control-panes/dashboard/install/install.jade
new file mode 100644
index 0000000..d724f0c
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/install/install.jade
@@ -0,0 +1,64 @@
+.widget-container.fluid-height.stf-upload(ng-controller='InstallCtrl')
+  .heading
+    stacked-icon(icon='fa-upload', color='color-red')
+    span(translate) App Upload
+    clear-button(ng-click='clear()', ng-disabled='!installation').btn-xs
+
+  .widget-content.padded()
+    //.input-group.form-inline
+      input(type=text, ng-model='remoteUrl', ng-enter='installUrl(remoteUrl)',
+      placeholder='http://...').form-control
+      span.input-group-btn
+        button.btn.btn-primary-outline(ng-click='installUrl(remoteUrl)',
+        uib-tooltip='{{ "Upload From Link" | translate }}', ng-disabled='!remoteUrl')
+          i.fa.fa-upload
+
+    .drop-area(ng-file-drop='installFile($files)', ng-file-drag-over-class='dragover').file-input.btn-file
+      input(type='file', ng-file-select='installFile($files)')
+
+      i.fa.fa-2x.fa-download.drop-area-icon
+      .drop-area-text(translate) Drop file to upload
+
+    .upload-status(ng-if='installation').selectable
+      uib-progressbar(max='100', value='installation.progress', ng-if='!installation.settled',
+        ng-class='{"active": !installation.settled}').progress-striped
+
+      div(ng-if='!installation.error')
+        span(ng-switch='installation.state')
+          strong(ng-switch-when='uploading')
+            span(translate) Uploading...
+            span  ({{installation.progress}}%)
+          strong(ng-switch-when='processing')
+            span(translate) Processing...
+            span  ({{installation.progress}}%)
+          strong(ng-switch-when='pushing_app')
+            span(translate) Pushing app...
+            span  ({{installation.progress}}%)
+          strong(ng-switch-when='installing_app')
+            span(translate) Installing app...
+            span  ({{installation.progress}}%)
+          strong(ng-switch-when='launching_app')
+            span(translate) Launching activity...
+            span  ({{installation.progress}}%)
+          strong(ng-switch-when='installed')
+            accordion(close-others='false', ng-if='installation').pointer
+              accordion-group(is-open='accordionOpen')
+                accordion-heading.pointer
+                  i.fa.fa-file-o
+                  span  {{installation.manifest.package || "App" }}
+
+                  button.btn.btn-xs.btn-danger-outline.pull-right(
+                  ng-click='uninstall(installation.manifest.package)', ng-show='installation.success')
+                    i.fa.fa-trash-o
+                    span(translate) Uninstall
+                div(ng-include='"control-panes/dashboard/install/activities/activities.jade"')
+                button.btn.btn-sm.btn-primary-outline(uib-btn-checkbox, ng-model='showManifest')
+                  i.fa.fa-list
+                  span(ng-if='showManifest') Hide Manifest
+                  span(ng-if='!showManifest') Show Manifest
+                pre.manifest-text(ng-if='showManifest') {{ installation.manifest | json }}
+
+      alert(type='danger', close='clear()', ng-if='installation.error')
+        strong(translate) Oops!
+        | &#x20;
+        span {{ installation.error | installError | translate }} ({{ installation.error }})
diff --git a/crowdstf/res/app/control-panes/dashboard/navigation/default-favicon.png b/crowdstf/res/app/control-panes/dashboard/navigation/default-favicon.png
new file mode 100644
index 0000000..d486b8c
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/navigation/default-favicon.png
Binary files differ
diff --git a/crowdstf/res/app/control-panes/dashboard/navigation/index.js b/crowdstf/res/app/control-panes/dashboard/navigation/index.js
new file mode 100644
index 0000000..db2068b
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/navigation/index.js
@@ -0,0 +1,11 @@
+require('./navigation.css')
+
+module.exports = angular.module('stf.navigation', [
+
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/dashboard/navigation/navigation.jade',
+      require('./navigation.jade')
+    )
+  }])
+  .controller('NavigationCtrl', require('./navigation-controller'))
diff --git a/crowdstf/res/app/control-panes/dashboard/navigation/navigation-controller.js b/crowdstf/res/app/control-panes/dashboard/navigation/navigation-controller.js
new file mode 100644
index 0000000..8ea10e1
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/navigation/navigation-controller.js
@@ -0,0 +1,77 @@
+var _ = require('lodash')
+
+module.exports = function NavigationCtrl($scope, $rootScope) {
+
+  var faviconIsSet = false
+
+  function setUrlFavicon(url) {
+    var FAVICON_BASE_URL = '//www.google.com/s2/favicons?domain_url='
+    $scope.urlFavicon = FAVICON_BASE_URL + url
+    faviconIsSet = true
+  }
+
+  function resetFavicon() {
+    $scope.urlFavicon = require('./default-favicon.png')
+    faviconIsSet = false
+  }
+
+  resetFavicon()
+
+  $scope.textUrlChanged = function() {
+    if (faviconIsSet) {
+      resetFavicon()
+    }
+  }
+
+  function addHttp(textUrl) {
+    // Check for '://' because a protocol-less URL might include
+    // a username:password combination.
+    // Ignores also any query parameter because it may contain a http:// inside.
+    return (textUrl.replace(/\?.*/, '').indexOf('://') === -1 ? 'http://' : ''
+      ) + textUrl
+  }
+
+  $scope.blurUrl = false
+
+  $scope.openURL = function() {
+    $scope.blurUrl = true
+    $rootScope.screenFocus = true
+
+    var url = addHttp($scope.textURL)
+    setUrlFavicon(url)
+    return $scope.control.openBrowser(url, $scope.browser)
+  }
+
+  function setCurrentBrowser(browser) {
+    if (browser && browser.apps) {
+      var currentBrowser = {}
+      if (browser.selected) {
+        var selectedBrowser = _.first(browser.apps, 'selected')
+        if (!_.isEmpty(selectedBrowser)) {
+          currentBrowser = selectedBrowser
+        }
+      } else {
+        var defaultBrowser = _.find(browser.apps, {name: 'Browser'})
+        if (defaultBrowser) {
+          currentBrowser = defaultBrowser
+        } else {
+          currentBrowser = _.first(browser.apps)
+        }
+      }
+      $rootScope.browser = currentBrowser
+    }
+  }
+
+  setCurrentBrowser($scope.device ? $scope.device.browser : null)
+
+  $scope.$watch('device.browser', function(newValue, oldValue) {
+    if (newValue !== oldValue) {
+      setCurrentBrowser(newValue)
+    }
+  }, true)
+
+  $scope.clearSettings = function() {
+    var browser = $scope.browser
+    $scope.control.clearBrowser(browser)
+  }
+}
diff --git a/crowdstf/res/app/control-panes/dashboard/navigation/navigation-spec.js b/crowdstf/res/app/control-panes/dashboard/navigation/navigation-spec.js
new file mode 100644
index 0000000..9ec253f
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/navigation/navigation-spec.js
@@ -0,0 +1,17 @@
+describe('NavigationCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('NavigationCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/dashboard/navigation/navigation.css b/crowdstf/res/app/control-panes/dashboard/navigation/navigation.css
new file mode 100644
index 0000000..cd29a2b
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/navigation/navigation.css
@@ -0,0 +1,32 @@
+.stf-navigation .browser-icon {
+  width: 18px;
+  height: auto;
+}
+
+.stf-navigation .url-input-container {
+  position: relative;
+  padding: 0;
+  margin: 0;
+}
+
+.stf-navigation .url-input-container input {
+  margin: 0;
+  padding-left: 30px;
+  z-index: 0;
+}
+
+.stf-navigation .url-input-container img {
+  position: absolute;
+  bottom: 14px;
+  left: 9px;
+  width: 16px;
+  height: 16px;
+  pointer-events: none;
+  border: none;
+  margin:0;
+}
+
+.stf-navigation .browser-buttons {
+  min-width: 44px;
+  min-height: 35px;
+}
diff --git a/crowdstf/res/app/control-panes/dashboard/navigation/navigation.jade b/crowdstf/res/app/control-panes/dashboard/navigation/navigation.jade
new file mode 100644
index 0000000..53f1bca
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/navigation/navigation.jade
@@ -0,0 +1,31 @@
+.widget-container.fluid-height.stf-navigation(ng-controller='NavigationCtrl')
+  .heading
+    stacked-icon(icon='fa-globe', color='color-blue')
+    span(translate) Navigation
+    span
+      button.btn.btn-xs.btn-danger-outline.pull-right(ng-click='clearSettings()',
+      uib-tooltip='{{ "Reset all browser settings" | translate }}')
+        i.fa.fa-trash-o
+        span(translate) Reset
+      //.button-spacer.pull-right
+      //i.fa.fa-refresh.pull-right(ng-click='refresh()', title='{{"Reload"|translate}}')
+      //i.fa.fa-step-forward.pull-right(ng-click='forward()', title='{{"Go Forward"|translate}}')
+      //i.fa.fa-step-backward.pull-right(ng-click='back()', title='{{"Go Back"|translate}}')
+  .widget-content.padded
+    form(enable-autofill, ng-submit='openUrl()')
+      .input-group.url-input-container
+        input.form-control(type='text', name='textURL', placeholder='http://...',
+        autocomplete='url', ng-model='textURL', text-focus-select,
+        autocapitalize='off', spellcheck='false', blur-element='blurUrl'
+        accesskey='N', tabindex='10', ng-change='textUrlChanged()',
+        focus-element='nav.focusUrl')
+        img(ng-src='{{urlFavicon}}')
+
+        .input-group-btn
+          button(ng-click='openURL()', ng-disabled='!textURL', translate).btn.btn-primary-outline Open
+
+    .btn-group
+      .btn-group.browser-buttons(ng-click='nav.focusUrl = true')
+        label.btn.btn-default-outline(ng-repeat='browserRadio in device.browser.apps track by browserRadio.id', ng-model='$root.browser', uib-btn-radio='browserRadio',
+        uib-tooltip='{{browserRadio.name}} ({{browserRadio.developer}})')
+          img(ng-src='/static/app/browsers/icon/36x36/{{browserRadio.type}}.png', ng-show='browserRadio.type').browser-icon.pointer
diff --git a/crowdstf/res/app/control-panes/dashboard/remote-debug/index.js b/crowdstf/res/app/control-panes/dashboard/remote-debug/index.js
new file mode 100644
index 0000000..907ea5d
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/remote-debug/index.js
@@ -0,0 +1,12 @@
+require('./remote-debug.css')
+
+module.exports = angular.module('stf.remote-debug', [
+  require('gettext').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put(
+      'control-panes/advanced/remote-debug/remote-debug.jade',
+      require('./remote-debug.jade')
+    )
+  }])
+  .controller('RemoteDebugCtrl', require('./remote-debug-controller'))
diff --git a/crowdstf/res/app/control-panes/dashboard/remote-debug/remote-debug-controller.js b/crowdstf/res/app/control-panes/dashboard/remote-debug/remote-debug-controller.js
new file mode 100644
index 0000000..05fa980
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/remote-debug/remote-debug-controller.js
@@ -0,0 +1,36 @@
+module.exports = function RemoteDebugCtrl($scope, $timeout, gettext) {
+  function startRemoteConnect() {
+    if ($scope.control) {
+      $scope.control.startRemoteConnect().then(function(result) {
+        var url = result.lastData
+        $scope.$apply(function() {
+          $scope.debugCommand = 'adb connect ' + url
+        })
+      })
+
+      return true
+    }
+    return false
+  }
+
+  // TODO: Remove timeout and fix control initialization
+  if (!startRemoteConnect()) {
+    $timeout(function() {
+      if (!startRemoteConnect()) {
+        $timeout(startRemoteConnect, 1000)
+      }
+    }, 200)
+  }
+
+  $scope.$watch('platform', function(newValue) {
+    if (newValue === 'native') {
+      $scope.remoteDebugTooltip =
+        gettext('Run the following on your command line to debug the device from your IDE')
+    } else {
+      $scope.remoteDebugTooltip =
+        gettext('Run the following on your command line to debug the device from your Browser')
+    }
+
+  })
+
+}
diff --git a/crowdstf/res/app/control-panes/dashboard/remote-debug/remote-debug-spec.js b/crowdstf/res/app/control-panes/dashboard/remote-debug/remote-debug-spec.js
new file mode 100644
index 0000000..75ac380
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/remote-debug/remote-debug-spec.js
@@ -0,0 +1,17 @@
+describe('RemoteDebugCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('RemoteDebugCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/dashboard/remote-debug/remote-debug.css b/crowdstf/res/app/control-panes/dashboard/remote-debug/remote-debug.css
new file mode 100644
index 0000000..e914885
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/remote-debug/remote-debug.css
@@ -0,0 +1,13 @@
+.stf-remote-debug {
+
+}
+
+.stf-remote-debug .remote-debug-textarea {
+  resize: none;
+  cursor: text;
+  font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
+  font-size: 12px;
+  width: 100%;
+}
+
+
diff --git a/crowdstf/res/app/control-panes/dashboard/remote-debug/remote-debug.jade b/crowdstf/res/app/control-panes/dashboard/remote-debug/remote-debug.jade
new file mode 100644
index 0000000..b879239
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/remote-debug/remote-debug.jade
@@ -0,0 +1,9 @@
+.widget-container.fluid-height.stf-remote-debug(ng-controller='RemoteDebugCtrl')
+  .heading
+    stacked-icon(icon='fa-bug', color='color-darkgreen')
+    span(translate) Remote debug
+    help-icon(topic='Remote-Debug', uib-tooltip='{{remoteDebugTooltip | translate}}')
+
+  .widget-content.padded
+    .form-inline
+      textarea(readonly, rows='1', ng-model='debugCommand', msd-elastic, text-focus-select).form-control.remote-debug-textarea
diff --git a/crowdstf/res/app/control-panes/dashboard/shell/index.js b/crowdstf/res/app/control-panes/dashboard/shell/index.js
new file mode 100644
index 0000000..6570c3a
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/shell/index.js
@@ -0,0 +1,12 @@
+require('./shell.css')
+
+module.exports = angular.module('stf.shell', [
+  require('stf/common-ui').name,
+  require('gettext').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/dashboard/shell/shell.jade',
+      require('./shell.jade')
+    )
+  }])
+  .controller('ShellCtrl', require('./shell-controller'))
diff --git a/crowdstf/res/app/control-panes/dashboard/shell/shell-controller.js b/crowdstf/res/app/control-panes/dashboard/shell/shell-controller.js
new file mode 100644
index 0000000..47025ff
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/shell/shell-controller.js
@@ -0,0 +1,30 @@
+module.exports = function ShellCtrl($scope) {
+  $scope.result = null
+
+  $scope.run = function(command) {
+    if (command === 'clear') {
+      $scope.clear()
+      return
+    }
+
+    $scope.command = ''
+
+    return $scope.control.shell(command)
+      .progressed(function(result) {
+        $scope.result = result
+        $scope.data = result.data.join('')
+        $scope.$digest()
+      })
+      .then(function(result) {
+        $scope.result = result
+        $scope.data = result.data.join('')
+        $scope.$digest()
+      })
+  }
+
+  $scope.clear = function() {
+    $scope.command = ''
+    $scope.data = ''
+    $scope.result = null
+  }
+}
diff --git a/crowdstf/res/app/control-panes/dashboard/shell/shell-spec.js b/crowdstf/res/app/control-panes/dashboard/shell/shell-spec.js
new file mode 100644
index 0000000..db396ae
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/shell/shell-spec.js
@@ -0,0 +1,20 @@
+describe('ShellCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('ShellCtrl', {$scope: scope})
+  }))
+
+  it('should clear the results', inject(function() {
+    scope.result = ['result']
+    scope.run('clear')
+    expect(scope.result).toBe(null)
+    expect(scope.data).toBe('')
+    expect(scope.command).toBe('')
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/dashboard/shell/shell.css b/crowdstf/res/app/control-panes/dashboard/shell/shell.css
new file mode 100644
index 0000000..fb37545
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/shell/shell.css
@@ -0,0 +1,14 @@
+.stf-shell .shell-results {
+  font-size: 12px;
+  color: #fefefe;
+  background: #444;
+}
+
+.stf-shell .shell-results-empty {
+  font-style: italic;
+}
+
+.stf-shell .shell-input {
+  font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
+  font-size: 12px;
+}
diff --git a/crowdstf/res/app/control-panes/dashboard/shell/shell.jade b/crowdstf/res/app/control-panes/dashboard/shell/shell.jade
new file mode 100644
index 0000000..8cdb6ff
--- /dev/null
+++ b/crowdstf/res/app/control-panes/dashboard/shell/shell.jade
@@ -0,0 +1,20 @@
+.widget-container.fluid-height.stf-shell(ng-controller='ShellCtrl')
+  .heading
+    stacked-icon(icon='fa-terminal', color='color-darkgray')
+    span(translate) Shell
+    clear-button(ng-click='clear()', ng-disabled='!command && !data').btn-xs
+    help-icon(topic='Remote-Shell', uib-tooltip='{{"Executes remote shell commands" | translate}}')
+
+  .widget-content.padded
+
+    // NOTE: autofill doesn't work here
+    form(method='post', enable-autofill, ng-submit='run(command)')
+      .input-group.form-inline
+        input(type=text, ng-model='command', Xtext-focus-select,
+          autocapitalize='off', spellcheck='false',
+          tabindex='30', accesskey='S', autocomplete='on').form-control.shell-input
+        span.input-group-btn
+          button.btn.btn-primary-outline(ng-click='run(command)', ng-disabled='!command')
+            i.fa.fa-play
+    pre.shell-results.selectable(ng-show='data') {{data}}
+    pre.shell-results.selectable.shell-results-empty(ng-show='result.settled && !data') No output
diff --git a/crowdstf/res/app/control-panes/device-control/device-control-controller.js b/crowdstf/res/app/control-panes/device-control/device-control-controller.js
new file mode 100644
index 0000000..6424666
--- /dev/null
+++ b/crowdstf/res/app/control-panes/device-control/device-control-controller.js
@@ -0,0 +1,136 @@
+var _ = require('lodash')
+
+module.exports = function DeviceControlCtrl($scope, DeviceService, GroupService,
+  $location, $timeout, $window, $rootScope) {
+
+  $scope.showScreen = true
+
+  $scope.groupTracker = DeviceService.trackGroup($scope)
+
+  $scope.groupDevices = $scope.groupTracker.devices
+
+  $scope.kickDevice = function(device) {
+
+    if (!device || !$scope.device) {
+      alert('No device found')
+      return
+    }
+
+    try {
+      // If we're trying to kick current device
+      if (device.serial === $scope.device.serial) {
+
+        // If there is more than one device left
+        if ($scope.groupDevices.length > 1) {
+
+          // Control first free device first
+          var firstFreeDevice = _.find($scope.groupDevices, function(dev) {
+            return dev.serial !== $scope.device.serial
+          })
+          $scope.controlDevice(firstFreeDevice)
+
+          // Then kick the old device
+          GroupService.kick(device).then(function() {
+            $scope.$digest()
+          })
+        } else {
+          // Kick the device
+          GroupService.kick(device).then(function() {
+            $scope.$digest()
+          })
+          $location.path('/devices/')
+        }
+      } else {
+        GroupService.kick(device).then(function() {
+          $scope.$digest()
+        })
+      }
+    } catch (e) {
+      alert(e.message)
+    }
+  }
+
+  $scope.controlDevice = function(device) {
+    $location.path('/control/' + device.serial)
+  }
+
+  function isPortrait(val) {
+    var value = val
+    if (typeof value === 'undefined' && $scope.device) {
+      value = $scope.device.display.rotation
+    }
+    return (value === 0 || value === 180)
+  }
+
+  function isLandscape(val) {
+    var value = val
+    if (typeof value === 'undefined' && $scope.device) {
+      value = $scope.device.display.rotation
+    }
+    return (value === 90 || value === 270)
+  }
+
+  $scope.tryToRotate = function(rotation) {
+    if (rotation === 'portrait') {
+      $scope.control.rotate(0)
+      $timeout(function() {
+        if (isLandscape()) {
+          $scope.currentRotation = 'landscape'
+        }
+      }, 400)
+    } else if (rotation === 'landscape') {
+      $scope.control.rotate(90)
+      $timeout(function() {
+        if (isPortrait()) {
+          $scope.currentRotation = 'portrait'
+        }
+      }, 400)
+    }
+  }
+
+  $scope.currentRotation = 'portrait'
+
+  $scope.$watch('device.display.rotation', function(newValue) {
+    if (isPortrait(newValue)) {
+      $scope.currentRotation = 'portrait'
+    } else if (isLandscape(newValue)) {
+      $scope.currentRotation = 'landscape'
+    }
+  })
+
+  // TODO: Refactor this inside control and server-side
+  $scope.rotateLeft = function() {
+    var angle = 0
+    if ($scope.device && $scope.device.display) {
+      angle = $scope.device.display.rotation
+    }
+    if (angle === 0) {
+      angle = 270
+    } else {
+      angle -= 90
+    }
+    $scope.control.rotate(angle)
+
+    if ($rootScope.standalone) {
+      $window.resizeTo($window.outerHeight, $window.outerWidth)
+    }
+  }
+
+  $scope.rotateRight = function() {
+    var angle = 0
+    if ($scope.device && $scope.device.display) {
+      angle = $scope.device.display.rotation
+    }
+    if (angle === 270) {
+      angle = 0
+    } else {
+      angle += 90
+    }
+    $scope.control.rotate(angle)
+
+    if ($rootScope.standalone) {
+      $window.resizeTo($window.outerHeight, $window.outerWidth)
+    }
+  }
+
+}
diff --git a/crowdstf/res/app/control-panes/device-control/device-control-key-directive.js b/crowdstf/res/app/control-panes/device-control/device-control-key-directive.js
new file mode 100644
index 0000000..d7c4e12
--- /dev/null
+++ b/crowdstf/res/app/control-panes/device-control/device-control-key-directive.js
@@ -0,0 +1,46 @@
+module.exports = function DeviceControlKeyDirective() {
+  return {
+    restrict: 'A'
+  , link: function(scope, element, attrs) {
+      var key = attrs.deviceControlKey
+
+      function up() {
+        scope.control.keyUp(key)
+      }
+
+      function down() {
+        scope.control.keyDown(key)
+      }
+
+      function touchUp(e) {
+        if (e.touches.length === 0) {
+          element.unbind('touchleave', touchUp)
+          element.unbind('touchend', touchUp)
+          up()
+        }
+      }
+
+      function mouseUp() {
+        element.unbind('mouseup', mouseUp)
+        element.unbind('mouseleave', mouseUp)
+        up()
+      }
+
+      element.bind('touchstart', function(e) {
+        e.preventDefault()
+        if (e.touches.length === e.changedTouches.length) {
+          element.bind('touchleave', touchUp)
+          element.bind('touchend', touchUp)
+          down()
+        }
+      })
+
+      element.bind('mousedown', function(e) {
+        e.preventDefault()
+        element.bind('mouseup', mouseUp)
+        element.bind('mouseleave', mouseUp)
+        down()
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/control-panes/device-control/device-control-standalone.jade b/crowdstf/res/app/control-panes/device-control/device-control-standalone.jade
new file mode 100644
index 0000000..2d0f863
--- /dev/null
+++ b/crowdstf/res/app/control-panes/device-control/device-control-standalone.jade
@@ -0,0 +1,5 @@
+.interact-control.fill-height.stf-device-control(ng-controller='DeviceControlCtrl').fill-height.fill-width
+  div(ng-controller='DeviceScreenCtrl', ng-if='device').fill-height.fill-width
+    div(ng-file-drop='installFile($files)', ng-file-drag-over-class='dragover').fill-height.fill-width
+      device-context-menu(device='device', control='control')
+        device-screen(device='device', control='control').fill-height.fill-width
diff --git a/crowdstf/res/app/control-panes/device-control/device-control.css b/crowdstf/res/app/control-panes/device-control/device-control.css
new file mode 100644
index 0000000..2042be2
--- /dev/null
+++ b/crowdstf/res/app/control-panes/device-control/device-control.css
@@ -0,0 +1,281 @@
+.enter-fade {
+  -webkit-transition: 1s linear opacity;
+  -moz-transition: 1s linear opacity;
+  -o-transition: 1s linear opacity;
+  transition: 1s linear opacity;
+  opacity: 0;
+}
+
+.enter-fade.enter-fade-active {
+  opacity: 1;
+}
+
+device-screen {
+  width: 100%;
+  height: 100%;
+  background: gray;
+  position: relative;
+  display: block;
+  overflow: hidden;
+  /*cursor: pointer;*/
+  text-align: center; /* needed for centering after rotating to 270 */
+
+  -webkit-touch-callout: none;
+  -webkit-user-select: none;
+  -khtml-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+
+device-screen .positioner {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  margin: auto;
+  pointer-events: none;
+  transform: rotate(0deg);
+}
+
+device-screen canvas.screen {
+  position: absolute;
+  width: 100%;
+  height: auto;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  margin: auto;
+
+  pointer-events: none; /* MUST HAVE or touch coordinates will be off */
+  transition: -webkit-transform 250ms ease-in-out;
+  -webkit-transform-origin: 50% 50%;
+  transform-origin: 50% 50%;
+  -webkit-transform: rotate(0deg);
+  transform: rotate(0deg);
+}
+
+device-screen .positioner .hacky-stretcher {
+  outline: 1px solid red;
+  height: 100%;
+  width: auto;
+  margin: 0;
+  padding: 0;
+  pointer-events: none;
+}
+
+/* screen is in default rotation or upside down, possibly with empty space on left/right */
+
+device-screen/*.portrait*/ .positioner {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+}
+
+device-screen/*.portrait*/ canvas.screen {
+  width: auto;
+  height: 100%;
+}
+
+/* screen is in default rotation or upside down, possibly with empty space on top/bottom */
+
+device-screen/*.portrait*/.letterboxed .positioner {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+}
+
+device-screen/*.portrait*/.letterboxed canvas.screen {
+  width: 100%;
+  height: auto;
+}
+
+/* screen is rotated sideways, possibly with empty space on left/right */
+
+device-screen.rotated .positioner {
+  position: relative;
+  display: inline-block;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  height: 100%;
+}
+
+device-screen.rotated canvas.screen {
+  width: 100%;
+  height: auto;
+}
+
+/* screen is rotated sideways, possibly with empty space on top/bottom */
+
+device-screen.rotated.letterboxed .positioner {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  margin: auto;
+  width: 100%;
+  padding-top: 100%;
+  height: 0;
+}
+
+device-screen.rotated.letterboxed canvas.screen {
+  height: 100%;
+  width: auto;
+}
+
+device-screen .finger {
+  position: absolute;
+  border-radius: 50%;
+  background: #7c7c7c;
+  opacity: 0.5;
+  width: 8mm;
+  height: 8mm;
+  top: -4mm;
+  left: -4mm;
+  pointer-events: none;
+  display: none;
+  border-style: solid;
+  border-width: 1px;
+  border-color: #464646;
+}
+
+device-screen .finger.active {
+  display: block;
+}
+
+device-screen input {
+  position: absolute;
+  z-index: 10;
+  outline: none;
+  pointer-events: none;
+  opacity: 0;
+  ime-mode: disabled;
+  inputmode: verbatim;
+}
+
+.stf-device-control .dragover {
+  opacity: 0.7;
+}
+
+.stf-device-control .dropdown-menu {
+  top: auto;
+}
+
+.stf-device-control .device-small-image {
+  width: 14px;
+  height: 14px;
+  display: inline-block;
+  margin-right: 7px;
+}
+
+.stf-device-control .device-small-image img {
+  width: 100%;
+}
+
+.stf-device-control .kick-device {
+  color: #9c9c9c;
+}
+
+.stf-device-control .kick-device:hover {
+  color: #d9534f;
+}
+
+.stf-device-control .kick-device i {
+  margin-right: 0;
+}
+
+.stf-device-control .orientation-degree {
+  min-width: 34px;
+  display: inline-block;
+}
+
+/* VNC buttons */
+.stf-vnc-bottom .btn-primary:hover {
+  background: rgba(255, 255, 255, 1.0);
+  border: none;
+}
+
+.stf-vnc-bottom .btn {
+  border-radius: 0;
+}
+
+.stf-vnc-bottom .btn-primary:active {
+  background: rgba(250, 250, 250, 0.75);
+  border: none;
+  color: #0d3fa4;
+}
+
+.stf-vnc-navbar-buttons {
+  padding-top: 2px;
+  padding-bottom: 32px;
+}
+
+.stf-vnc-navbar-buttons .btn {
+  margin-bottom: 0;
+  min-width: 37px;
+}
+
+.stf-device-control .stf-vnc-device-name {
+  font-size: 16px;
+  line-height: 20px;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  white-space: nowrap;
+  color: #858585;
+}
+
+.stf-device-control .device-name-container {
+  text-overflow: ellipsis;
+}
+
+.stf-device-control .stf-vnc-device-name .device-small-image {
+  margin-left: 10px;
+}
+
+.stf-device-control .current-device {
+  font-weight: bold;
+}
+
+.stf-vnc-navbar {
+  background: #fff;
+  margin-bottom: 2px;
+}
+
+.stf-vnc-fps {
+  line-height: 28px;
+  color: #D1D1D1;
+  font-family: monospace;
+  display: inline-block;
+  width: 28px;
+  min-height: 10px;
+  text-align: center;
+}
+
+.stf-vnc-right-buttons {
+  margin-top: 6px;
+}
+
+.stf-vnc-right-buttons .button-spacer {
+  width: 12px;
+}
+
+.stf-device-control > .device-name-menu-element {
+  margin-left: 7px;
+}
+
+.no-transition {
+  -webkit-transition: none !important;
+  -moz-transition: none !important;
+  -o-transition: none !important;
+  -ms-transition: none !important;
+  transition: none !important;
+}
diff --git a/crowdstf/res/app/control-panes/device-control/device-control.jade b/crowdstf/res/app/control-panes/device-control/device-control.jade
new file mode 100644
index 0000000..1e1c085
--- /dev/null
+++ b/crowdstf/res/app/control-panes/device-control/device-control.jade
@@ -0,0 +1,57 @@
+.interact-control.fill-height.as-table.stf-device-control(ng-controller='DeviceControlCtrl')
+  .as-cell.fill-height
+    .as-table.fill-height
+      .stf-vnc-navbar.as-row(ng-show='!$root.basicMode && !$root.standalone')
+        .stf-vnc-control-header.as-cell
+          .stf-vnc-right-buttons.pull-right
+            .btn-group
+              label.btn-sm.btn-primary-outline(type='button', ng-click='tryToRotate("portrait")',
+              ng-model='currentRotation', uib-btn-radio='"portrait"',
+              uib-tooltip='{{ "Portrait" | translate }} ({{ "Current rotation:" | translate }} {{ device.display.rotation }}°)', tooltip-placement='bottom').pointer
+                i.fa.fa-mobile
+              label.btn-sm.btn-primary-outline(type='button', ng-click='tryToRotate("landscape")',
+                ng-model='currentRotation', uib-btn-radio='"landscape"',
+              uib-tooltip='{{ "Landscape" | translate }} ({{ "Current rotation:" | translate }} {{ device.display.rotation }}°)', tooltip-placement='bottom').pointer
+                i.fa.fa-mobile.fa-rotate-90
+            .button-spacer
+            button(type='button', ng-model='showScreen', uib-btn-checkbox).btn.btn-xs.btn-info
+              i(ng-show='showScreen', uib-tooltip='{{"Hide Screen"|translate}}', tooltip-placement='bottom').fa.fa-eye
+              i(ng-show='!showScreen', uib-tooltip='{{"Show Screen"|translate}}', tooltip-placement='bottom').fa.fa-eye-slash
+            button(type='button', ng-click='kickDevice(device); $event.stopPropagation()', uib-tooltip='{{"Stop Using"|translate}}', tooltip-placement='bottom').btn.btn-sm.btn-danger-outline
+              i.fa.fa-times
+
+          .device-name-container.pull-left(uib-dropdown)
+            a.stf-vnc-device-name.pointer.unselectable(uib-dropdown-toggle)
+              p
+                .device-small-image
+                  img(ng-src='/static/app/devices/icon/x24/{{ device.image || "E30HT.jpg" }}')
+                span.device-name-text {{ device.enhancedName }}
+                span.caret(ng-show='groupDevices.length > 0')
+
+            ul.dropdown-menu(role='menu', data-toggle='dropdown', ng-show='groupDevices.length > 0').pointer.unselectable
+              li(ng-repeat='groupDevice in groupDevices')
+                a.device-name-menu-element(ng-click='controlDevice(groupDevice); $event.stopPropagation()')
+                  .pull-left
+                    .device-small-image
+                      img(ng-src='/static/app/devices/icon/x24/{{ groupDevice.image || "E30HT.jpg" }}')
+                    span(ng-class='{"current-device": groupDevice.serial === device.serial }') {{ groupDevice.enhancedName }}
+
+                  .pull-right(ng-click='kickDevice(groupDevice); $event.stopPropagation()').kick-device
+                    i.fa.fa-times
+                  .clearfix
+
+      .as-row.fill-height
+        div(ng-controller='DeviceScreenCtrl', ng-if='device').as-cell.fill-height
+          div(ng-file-drop='installFile($files)', ng-file-drag-over-class='dragover').fill-height
+            device-context-menu(device='device', control='control')
+              device-screen(device='device', control='control')
+
+      .stf-vnc-bottom.as-row(ng-hide='$root.standalone')
+        .controls.as-cell
+          .btn-group.btn-group-justified
+            a(device-control-key='menu', title='{{"Menu"|translate}}').btn.btn-primary.btn-lg.no-transition
+              i.fa.fa-bars
+            a(device-control-key='home', title='{{"Home"|translate}}').btn.btn-primary.btn-lg.no-transition
+              i.fa.fa-home
+            a(device-control-key='back', title='{{"Back"|translate}}').btn.btn-primary.btn-lg.no-transition
+              i.fa.fa-mail-reply
diff --git a/crowdstf/res/app/control-panes/device-control/index.js b/crowdstf/res/app/control-panes/device-control/index.js
new file mode 100644
index 0000000..6fb3e3e
--- /dev/null
+++ b/crowdstf/res/app/control-panes/device-control/index.js
@@ -0,0 +1,19 @@
+require('./device-control.css')
+
+module.exports = angular.module('device-control', [
+  require('stf/device').name,
+  require('stf/control').name,
+  require('stf/screen').name,
+  require('ng-context-menu').name,
+  require('stf/device-context-menu').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/device-control/device-control.jade',
+      require('./device-control.jade')
+    )
+    $templateCache.put('control-panes/device-control/device-control-standalone.jade',
+      require('./device-control-standalone.jade')
+    )
+  }])
+  .controller('DeviceControlCtrl', require('./device-control-controller'))
+  .directive('deviceControlKey', require('./device-control-key-directive'))
diff --git a/crowdstf/res/app/control-panes/explorer/explorer-controller.js b/crowdstf/res/app/control-panes/explorer/explorer-controller.js
new file mode 100644
index 0000000..23c2217
--- /dev/null
+++ b/crowdstf/res/app/control-panes/explorer/explorer-controller.js
@@ -0,0 +1,69 @@
+module.exports = function ExplorerCtrl($scope) {
+  $scope.explorer = {
+    search: '',
+    files: [],
+    paths: []
+  }
+
+  $scope.getAbsolutePath = function() {
+    return ('/' + $scope.explorer.paths.join('/')).replace(/\/\/+/g, '/')
+  }
+
+  function resetPaths(path) {
+    $scope.explorer.paths = path.split('/')
+  }
+
+  var listDir = function listDir() {
+    var path = $scope.getAbsolutePath()
+    $scope.explorer.search = path
+
+    $scope.control.fslist(path)
+      .then(function(result) {
+        $scope.explorer.files = result.body
+        $scope.$digest()
+      })
+      .catch(function(err) {
+        throw new Error(err.message)
+      })
+  }
+
+  $scope.dirEnterLocation = function() {
+    if ($scope.explorer.search) {
+      resetPaths($scope.explorer.search)
+      listDir()
+      $scope.explorer.search = $scope.getAbsolutePath()
+    }
+  }
+
+  $scope.dirEnter = function(name) {
+    if (name) {
+      $scope.explorer.paths.push(name)
+    }
+    listDir()
+    $scope.explorer.search = $scope.getAbsolutePath()
+  }
+
+  $scope.dirUp = function() {
+    if ($scope.explorer.paths.length !== 0) {
+      $scope.explorer.paths.pop()
+    }
+    listDir()
+    $scope.explorer.search = $scope.getAbsolutePath()
+  }
+
+  $scope.getFile = function(file) {
+    var path = $scope.getAbsolutePath() + '/' + file
+    $scope.control.fsretrieve(path)
+      .then(function(result) {
+        if (result.body) {
+          location.href = result.body.href + '?download'
+        }
+      })
+      .catch(function(err) {
+        throw new Error(err.message)
+      })
+  }
+
+  // Initialize
+  listDir($scope.dir)
+}
diff --git a/crowdstf/res/app/control-panes/explorer/explorer-spec.js b/crowdstf/res/app/control-panes/explorer/explorer-spec.js
new file mode 100644
index 0000000..bdfb1d3
--- /dev/null
+++ b/crowdstf/res/app/control-panes/explorer/explorer-spec.js
@@ -0,0 +1,17 @@
+describe('FsCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('ExplorerCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/explorer/explorer.css b/crowdstf/res/app/control-panes/explorer/explorer.css
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crowdstf/res/app/control-panes/explorer/explorer.css
diff --git a/crowdstf/res/app/control-panes/explorer/explorer.jade b/crowdstf/res/app/control-panes/explorer/explorer.jade
new file mode 100644
index 0000000..40b420f
--- /dev/null
+++ b/crowdstf/res/app/control-panes/explorer/explorer.jade
@@ -0,0 +1,47 @@
+.widget-container.fluid-height(ng-controller='ExplorerCtrl').stf-explorer
+  .heading
+
+    form.input-group.form-inline(name='explorerForm', ng-submit='dirEnterLocation()')
+      span.input-group-btn
+        button.btn.btn-primary-outline(ng-click='dirUp()')
+          i.fa.fa-level-up
+      input(type='text', ng-model='explorer.search',
+      ng-enter='dirEnterLocation()'
+      autocorrect='off', autocapitalize='off', spellcheck='false').form-control
+      span.input-group-btn
+        button.btn.btn-primary-outline(type='submit')
+          i.fa.fa-play
+
+  .widget-content.padded.selectable
+    table.table.table-hover.table-condensed.dataTable.ng-table
+      thead
+        tr
+          th
+            div(translate) Name
+          th
+            div(translate) Size
+          th
+            div(translate) Date
+          th
+            div(translate) Permissions
+      tbody
+        tr.header(ng-repeat='f in explorer.files | filter:search | orderBy: ["-mode|fileIsDir", "+name"]')
+          td
+            button.btn.btn-sm.btn-primary-outline(
+            ng-click='dirEnter(f.name)', ng-show='f.mode|fileIsDir')
+              span
+                i.fa.fa-folder-open
+              span {{f.name}}
+
+            button.btn.btn-sm.btn-primary-outline(
+            ng-click='getFile(f.name)', ng-hide='f.mode|fileIsDir')
+              span
+                i.fa.fa-file-o
+              span {{f.name}}
+          td
+            span(ng-show='f.mode|fileIsDir') -
+            span(ng-hide='f.mode|fileIsDir') {{f.size|formatFileSize}}
+          td
+            span {{f.mtime|formatFileDate}}
+          td
+            i {{f.mode|formatPermissionMode}}
diff --git a/crowdstf/res/app/control-panes/explorer/index.js b/crowdstf/res/app/control-panes/explorer/index.js
new file mode 100644
index 0000000..4a275cd
--- /dev/null
+++ b/crowdstf/res/app/control-panes/explorer/index.js
@@ -0,0 +1,60 @@
+require('./explorer.css')
+
+module.exports = angular.module('stf.explorer', [])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/explorer/explorer.jade',
+      require('./explorer.jade')
+    )
+  }])
+  .filter('formatPermissionMode', function() {
+    return function(mode) {
+      if (mode !== null) {
+        var res = []
+        var s = ['x', 'w', 'r']
+        for (var i = 0; i < 3; i++) {
+          for (var j = 0; j < 3; j++) {
+            if ((mode >> (i * 3 + j)) & 1 !== 0) {
+              res.unshift(s[j])
+            } else {
+              res.unshift('-')
+            }
+          }
+        }
+        res.unshift(mode & 040000 ? 'd' : '-')
+        return res.join('')
+      }
+    }
+  })
+  .filter('fileIsDir', function() {
+    return function(m) {
+      var mode = m
+      if (mode !== null) {
+        mode = parseInt(mode, 10)
+        mode -= (mode & 0777)
+        return (mode === 040000) || (mode === 0120000)
+      }
+    }
+  })
+  .filter('formatFileSize', function() {
+    return function(size) {
+      var formattedSize
+      if (size < 1024) {
+        formattedSize = size + ' B'
+      } else if (size >= 1024 && size < 1024 * 1024) {
+        formattedSize = Math.round(size / 1024, 1) + ' Kb'
+      } else {
+        formattedSize = Math.round(size / (1024 * 1024), 1) + ' Mb'
+      }
+      return formattedSize
+    }
+  })
+  .filter('formatFileDate', function() {
+    return function(inputString) {
+      var input = new Date(inputString)
+      return input instanceof Date ?
+        input.toISOString().substring(0, 19).replace('T', ' ') :
+        (input.toLocaleString || input.toString).apply(input)
+    }
+  })
+
+  .controller('ExplorerCtrl', require('./explorer-controller'))
diff --git a/crowdstf/res/app/control-panes/index.js b/crowdstf/res/app/control-panes/index.js
new file mode 100644
index 0000000..70fa134
--- /dev/null
+++ b/crowdstf/res/app/control-panes/index.js
@@ -0,0 +1,46 @@
+module.exports = angular.module('control-panes', [
+  require('stf/common-ui/nice-tabs').name,
+  require('stf/device').name,
+  require('stf/control').name,
+  require('stf/scoped-hotkeys').name,
+  require('./device-control').name,
+  require('./advanced').name,
+  require('./automation').name,
+  require('./performance').name,
+  require('./dashboard').name,
+  //require('./inspect').name,
+  //require('./activity').name,
+  require('./logs').name,
+  //require('./resources').name,
+  require('./screenshots').name,
+  require('./explorer').name,
+  require('./info').name
+])
+  .config(['$routeProvider', function($routeProvider) {
+
+    $routeProvider
+      .when('/control', {
+        template: '<div ng-controller="ControlPanesNoDeviceController"></div>',
+        controller: 'ControlPanesNoDeviceController'
+      })
+      .when('/control/:serial', {
+        template: require('./control-panes.jade'),
+        controller: 'ControlPanesCtrl'
+        // TODO: Move device inviting to resolve
+        //resolve: {
+        //  device
+        //  control
+        //}
+      })
+      // TODO: add standalone
+      .when('/c/:serial', {
+        template: require('./control-panes.jade'),
+        controller: 'ControlPanesCtrl'
+      })
+  }])
+  .factory('ControlPanesService', require('./control-panes-service'))
+  .controller('ControlPanesCtrl', require('./control-panes-controller'))
+  .controller('ControlPanesNoDeviceController',
+  require('./control-panes-no-device-controller'))
+  .controller('ControlPanesHotKeysCtrl',
+  require('./control-panes-hotkeys-controller'))
diff --git a/crowdstf/res/app/control-panes/info/index.js b/crowdstf/res/app/control-panes/info/index.js
new file mode 100644
index 0000000..26c9a5a
--- /dev/null
+++ b/crowdstf/res/app/control-panes/info/index.js
@@ -0,0 +1,12 @@
+require('./info.css')
+
+module.exports = angular.module('stf.info', [
+  require('stf/angular-packery').name,
+  require('stf/common-ui/modals/lightbox-image').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/info/info.jade',
+      require('./info.jade')
+    )
+  }])
+  .controller('InfoCtrl', require('./info-controller'))
diff --git a/crowdstf/res/app/control-panes/info/info-controller.js b/crowdstf/res/app/control-panes/info/info-controller.js
new file mode 100644
index 0000000..a2eb185
--- /dev/null
+++ b/crowdstf/res/app/control-panes/info/info-controller.js
@@ -0,0 +1,18 @@
+module.exports = function InfoCtrl($scope, LightboxImageService) {
+  $scope.openDevicePhoto = function(device) {
+    var title = device.name
+    var enhancedPhoto800 = '/static/app/devices/photo/x800/' + device.image
+    LightboxImageService.open(title, enhancedPhoto800)
+  }
+
+  var getSdStatus = function() {
+    if ($scope.control) {
+      $scope.control.getSdStatus().then(function(result) {
+        $scope.$apply(function() {
+          $scope.sdCardMounted = (result.lastData === 'sd_mounted')
+        })
+      })
+    }
+  }
+  getSdStatus()
+}
diff --git a/crowdstf/res/app/control-panes/info/info-spec.js b/crowdstf/res/app/control-panes/info/info-spec.js
new file mode 100644
index 0000000..6000c9f
--- /dev/null
+++ b/crowdstf/res/app/control-panes/info/info-spec.js
@@ -0,0 +1,17 @@
+describe('InfoCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('InfoCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/info/info.css b/crowdstf/res/app/control-panes/info/info.css
new file mode 100644
index 0000000..2cb3ba1
--- /dev/null
+++ b/crowdstf/res/app/control-panes/info/info.css
@@ -0,0 +1,21 @@
+
+.stf-info .table.table-infocard thead > tr > th,
+.stf-info .table.table-infocard tbody > tr > th,
+.stf-info .table.table-infocard tfoot > tr > th,
+.stf-info .table.table-infocard thead > tr > td,
+.stf-info .table.table-infocard tbody > tr > td,
+.stf-info .table.table-infocard tfoot > tr > td {
+  border-top: 0;
+}
+
+.stf-info .table-infocard tbody > tr > td:first-child {
+  text-align: right;
+  margin-right: 20px;
+  font-weight: bold;
+  white-space: nowrap;
+}
+
+.stf-info .progress {
+  margin-bottom: 0;
+  height: 15px;
+}
diff --git a/crowdstf/res/app/control-panes/info/info.jade b/crowdstf/res/app/control-panes/info/info.jade
new file mode 100644
index 0000000..769d7a0
--- /dev/null
+++ b/crowdstf/res/app/control-panes/info/info.jade
@@ -0,0 +1,227 @@
+.row.stf-info.selectable(ng-controller='InfoCtrl',
+  angular-packery='{draggable: true, draggableHandle: ".heading i"}')
+
+  .col-md-4-x.packery-item
+    .widget-container.fluid-height
+      .heading
+        stacked-icon(icon='fa-location-arrow', color='color-pink')
+        span(translate) Physical Device
+        .pull-right
+          button(ng-click='control.identify()').btn.btn-xs.btn-primary-outline
+            i.fa.fa-info
+            span(translate) Find Device
+
+      .widget-content.padded-small
+        table.table.table-condensed.table-hover.table-infocard
+          tbody
+            tr
+              td(translate) Place
+              td {{device.provider.name}}
+            tr(ng-show='device.name && device.image')
+              td
+              td
+                button(ng-click='openDevicePhoto(device)').btn.btn-xs.btn-primary-outline
+                  i.fa.fa-picture-o
+                  span(translate) Device Photo
+
+  .col-md-4-x.packery-item
+    .widget-container.fluid-height
+      .heading
+        stacked-icon(icon='fa-bolt', color='color-yellow')
+        span(translate) Battery
+      .widget-content.padded-small
+        table.table.table-condensed.table-hover.table-infocard
+          tbody
+            tr
+              td(translate) Health
+              td {{device.battery.health | batteryHealth | translate}}
+            tr
+              td(translate) Power Source
+              td {{device.battery.source | batterySource | translate}}
+            tr
+              td(translate) Status
+              td {{device.battery.status | batteryStatus | translate}}
+            tr
+              td(translate) Level
+              td
+                uib-progressbar(value='device.battery.level', animate='false', max='device.battery.scale', type='success')
+                  span {{ device.battery.level / device.battery.scale * 100 }}%
+            tr
+              td(translate) Temperature
+              td {{device.battery.temp}} °C
+            tr
+              td(translate) Voltage
+              td {{device.battery.voltage}} v
+
+  .col-md-4-x.packery-item
+    .widget-container.fluid-height
+      .heading
+        stacked-icon(icon='fa-tablet', color='color-blue')
+        span(translate) Display
+      .widget-content.padded-small
+        table.table.table-condensed.table-hover.table-infocard
+          tbody
+            tr
+              td(translate) Size
+              td {{device.display.inches ? device.display.inches + '″' : '-'}}
+            tr
+              td(translate) Density
+              td {{device.display.density | displayDensity}}
+            tr
+              td(translate) FPS
+              td {{device.display.fps}}
+            tr
+              td(translate) Width
+              td {{device.display.width}} px
+            tr
+              td(translate) Height
+              td {{device.display.height}} px
+            tr
+              td(translate) ID
+              td {{device.display.id}}
+            tr
+              td(translate) Orientation
+              td {{device.display.rotation}}°
+            tr
+              td(translate) Encrypted
+              td {{device.display.secure | humanizedBool}}
+            tr
+              td(translate) X DPI
+              td {{device.display.xdpi}}
+            tr
+              td(translate) Y DPI
+              td {{device.display.ydpi}}
+
+  .col-md-4-x.packery-item
+    .widget-container.fluid-height
+      .heading
+        stacked-icon(icon='fa-signal', color='color-brown')
+        span(translate) Network
+      .widget-content.padded-small
+        table.table.table-condensed.table-hover.table-infocard
+          tbody
+            tr
+              td(translate) Connected
+              td {{device.network.connected | humanizedBool}}
+            tr
+              td(translate) Airplane Mode
+              td {{device.airplaneMode | humanizedBool}}
+            tr
+              td(translate) Using Fallback
+              td {{device.network.failover | humanizedBool}}
+            tr
+              td(translate) Roaming
+              td {{device.network.roaming | humanizedBool}}
+            tr
+              td(translate) Type
+              td {{device.network.type | networkType | translate}}
+            tr
+              td(translate) Sub Type
+              td {{device.network.subtype | networkSubType | translate}}
+
+  .col-md-4-x.packery-item
+    .widget-container.fluid-height
+      .heading
+        stacked-icon(icon='fa-credit-card', color='color-lila')
+        span(translate) SIM
+      .widget-content.padded-small
+        table.table.table-condensed.table-hover.table-infocard
+          tbody
+            tr
+              td(translate) Carrier
+              td {{device.operator}}
+            tr
+              td(translate) Network
+              td {{device.phone.network}}
+            tr
+              td(translate) Number
+              td {{device.phone.phoneNumber}}
+            tr
+              td(translate) IMEI
+              td {{device.phone.imei}}
+            tr
+              td(translate) ICCID
+              td
+                small {{device.phone.iccid}}
+
+
+  .col-md-4-x.packery-item
+    .widget-container.fluid-height
+      .heading
+        stacked-icon(icon='fa-phone', color='color-green')
+        span(translate) Hardware
+      .widget-content.padded-small
+        table.table.table-condensed.table-hover.table-infocard
+          tbody
+            tr
+              td(translate) Manufacturer
+              td {{device.manufacturer}}
+            tr
+              td(translate) Product
+              td {{device.name ? device.name : '-'}}
+            tr
+              td(translate) Model
+              td {{device.model}}
+            tr
+              td(translate) Serial
+              td {{device.serial}}
+            tr
+              td(translate) Released
+              td {{device.releasedAt ? (device.releasedAt | date:longDate) : '-'}}
+
+  .col-md-4-x.packery-item
+    .widget-container.fluid-height
+      .heading
+        stacked-icon(icon='fa-mobile', color='color-skyblue')
+        span(translate) Platform
+      .widget-content.padded-small
+        table.table.table-condensed.table-hover.table-infocard
+          tbody
+            tr
+              td(translate) OS
+              td {{device.platform}}
+            tr
+              td(translate) Version
+              td {{device.version}}
+            tr
+              td(translate) SDK
+              td {{device.sdk}}
+            tr
+              td(translate) ABI
+              td {{device.abi}}
+
+  .col-md-4-x.packery-item
+    .widget-container.fluid-height
+      .heading
+        stacked-icon(icon='fa-slack', color='color-black')
+        span(translate) CPU
+      .widget-content.padded-small
+        table.table.table-condensed.table-hover.table-infocard
+          tbody
+            tr
+              td(translate) Name
+              td {{device.cpu.name ? device.cpu.name : '-'}}
+            tr
+              td(translate) Cores
+              td {{device.cpu.cores ? device.cpu.cores : '-'}}
+            tr
+              td(translate) Frequency
+              td {{device.cpu.freq ? device.cpu.freq + ' GHz' : '-'}}
+
+  .col-md-4-x.packery-item
+    .widget-container.fluid-height
+      .heading
+        stacked-icon(icon='fa-database', color='color-orange')
+        span(translate) Memory
+      .widget-content.padded-small
+        table.table.table-condensed.table-hover.table-infocard
+          tbody
+            tr
+              td(translate) RAM
+              td {{device.memory.ram ? device.memory.ram + ' MB' : '-'}}
+            tr
+              td(translate) ROM
+              td {{device.memory.rom ? device.memory.rom + ' MB' : '-'}}
+            tr
+              td(translate) SD Card Mounted
+              td {{ sdCardMounted | humanizedBool }}
diff --git a/crowdstf/res/app/control-panes/inspect/index.js b/crowdstf/res/app/control-panes/inspect/index.js
new file mode 100644
index 0000000..e604b0e
--- /dev/null
+++ b/crowdstf/res/app/control-panes/inspect/index.js
@@ -0,0 +1,11 @@
+require('./inspect.css')
+
+module.exports = angular.module('stf.inspect', [
+
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/inspect/inspect.jade',
+      require('./inspect.jade')
+    )
+  }])
+  .controller('InspectCtrl', require('./inspect-controller'))
diff --git a/crowdstf/res/app/control-panes/inspect/inspect-controller.js b/crowdstf/res/app/control-panes/inspect/inspect-controller.js
new file mode 100644
index 0000000..dbb3a88
--- /dev/null
+++ b/crowdstf/res/app/control-panes/inspect/inspect-controller.js
@@ -0,0 +1,3 @@
+module.exports = function InspectCtrl() {
+
+}
diff --git a/crowdstf/res/app/control-panes/inspect/inspect-spec.js b/crowdstf/res/app/control-panes/inspect/inspect-spec.js
new file mode 100644
index 0000000..4fd98a1
--- /dev/null
+++ b/crowdstf/res/app/control-panes/inspect/inspect-spec.js
@@ -0,0 +1,17 @@
+describe('InspectCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('InspectCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/inspect/inspect.css b/crowdstf/res/app/control-panes/inspect/inspect.css
new file mode 100644
index 0000000..56cf7af
--- /dev/null
+++ b/crowdstf/res/app/control-panes/inspect/inspect.css
@@ -0,0 +1,3 @@
+.stf-inspect {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/control-panes/inspect/inspect.jade b/crowdstf/res/app/control-panes/inspect/inspect.jade
new file mode 100644
index 0000000..1707767
--- /dev/null
+++ b/crowdstf/res/app/control-panes/inspect/inspect.jade
@@ -0,0 +1,16 @@
+.stf-inspect(ng-controller='InspectCtrl')
+  nothing-to-show(ng-if='$root.browser != "webview"', message='{{"Inspecting is currently only supported in WebView"|translate}}', icon='fa-search-plus')
+  .something-white(ng-if='$root.browser == "webview"')
+    nothing-to-show(ng-if='!results.length', message='{{"Nothing to inspect"|translate}}', icon='fa-search-plus')
+
+    table.table.table-striped(ng-table='tableParams', ng-show='results.length').selectable
+      tr(ng-repeat='result in $data')
+        td(title='"Device"|translate', sortable='deviceName')
+          img(ng-src='{{ result.deviceImage }}').device-icon-smallest
+          span {{ result.deviceName }}
+        td(title='"Inspector"|translate')
+          button(ng-click='setUrl(result.value.clientUrl)').btn.btn-primary
+            i.fa.fa-search-plus
+            span(translate) Inspect Device
+  .weinre-window(ng-show='urlToShow')
+    iframe.weinre-content(type='text/html', width='100%', height='100%', ng-src='{{trustSrc(urlToShow)}}', frameborder='0')
diff --git a/crowdstf/res/app/control-panes/logs/index.js b/crowdstf/res/app/control-panes/logs/index.js
new file mode 100644
index 0000000..e8cc0d7
--- /dev/null
+++ b/crowdstf/res/app/control-panes/logs/index.js
@@ -0,0 +1,12 @@
+require('./logs.less')
+
+module.exports = angular.module('stf.logs', [
+  require('stf/logcat').name,
+  require('stf/logcat-table').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/logs/logs.jade',
+      require('./logs.jade')
+    )
+  }])
+  .controller('LogsCtrl', require('./logs-controller'))
diff --git a/crowdstf/res/app/control-panes/logs/logs-controller.js b/crowdstf/res/app/control-panes/logs/logs-controller.js
new file mode 100644
index 0000000..86f91b9
--- /dev/null
+++ b/crowdstf/res/app/control-panes/logs/logs-controller.js
@@ -0,0 +1,53 @@
+module.exports = function LogsCtrl($scope, LogcatService) {
+
+  $scope.started = LogcatService.started
+
+  $scope.filters = {}
+
+  $scope.filters.levelNumbers = LogcatService.filters.levelNumbers
+
+  LogcatService.filters.filterLines()
+
+  $scope.$watch('started', function(newValue, oldValue) {
+    if (newValue !== oldValue) {
+      LogcatService.started = newValue
+      if (newValue) {
+        $scope.control.startLogcat([]).then(function() {
+        })
+      } else {
+        $scope.control.stopLogcat()
+      }
+    }
+  })
+
+  window.onbeforeunload = function() {
+    if ($scope.control) {
+      $scope.control.stopLogcat()
+    }
+  }
+
+  $scope.clear = function() {
+    LogcatService.clear()
+  }
+
+  function defineFilterWatchers(props) {
+    angular.forEach(props, function(prop) {
+      $scope.$watch('filters.' + prop, function(newValue, oldValue) {
+        if (!angular.equals(newValue, oldValue)) {
+          LogcatService.filters[prop] = newValue
+        }
+      })
+    })
+  }
+
+  defineFilterWatchers([
+    'levelNumber',
+    'message',
+    'pid',
+    'tid',
+    'dateLabel',
+    'date',
+    'tag',
+    'priority'
+  ])
+}
diff --git a/crowdstf/res/app/control-panes/logs/logs-spec.js b/crowdstf/res/app/control-panes/logs/logs-spec.js
new file mode 100644
index 0000000..a07264a
--- /dev/null
+++ b/crowdstf/res/app/control-panes/logs/logs-spec.js
@@ -0,0 +1,17 @@
+describe('LogsCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('LogsCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/logs/logs.jade b/crowdstf/res/app/control-panes/logs/logs.jade
new file mode 100644
index 0000000..4ae8afb
--- /dev/null
+++ b/crowdstf/res/app/control-panes/logs/logs.jade
@@ -0,0 +1,29 @@
+.stf-logs(ng-controller='LogsCtrl')
+  .widget-container.fluid-height
+    .widget-content
+      table.table.table-condensed.logcat-filters-table(ng-show='true')
+        tr
+          td(width='1%')
+            button(ng-model='started', uib-btn-checkbox, title='{{"Start/Stop Logging"|translate}}').btn.btn-xs.btn-primary-outline
+              i.fa.fa-list-alt
+              span(ng-if='started') {{"Stop"|translate}}
+              span(ng-if='!started') {{"Get"|translate}}
+          td(width='6%')
+            select(ng-model='filters.priority', ng-options='l.name for l in filters.levelNumbers')
+              option(value='', disabled, selected) {{"Level"|translate}}
+          td(width='10%')
+            input(ng-model='filters.date', type='text', placeholder='{{"Time"|translate}}').input-sm.form-control
+          td(width='8%', ng-if='$root.platform == "native"')
+            input(ng-model='filters.pid', type='text', placeholder='{{"PID"|translate}}').input-sm.form-control
+          td(width='8%', ng-if='$root.platform == "native"')
+            input(ng-model='filters.tid', type='text', placeholder='{{"TID"|translate}}').input-sm.form-control
+          td(width='14%', ng-if='$root.platform == "native"')
+            input(ng-model='filters.tag', type='text', placeholder='{{"Tag"|translate}}').input-sm.form-control
+          td(width='40%')
+            input(ng-model='filters.message', type='text', placeholder='{{"Text"|translate}}').input-sm.form-control
+          td(width='0')
+            button(ng-click='clearTable()', ng-disabled='false', title='{{"Clear"|translate}}').btn.btn-xs.btn-danger-outline
+              i.fa.fa-trash-o
+              span(translate)  Clear
+
+      logcat-table(add-row='lastEntry')
diff --git a/crowdstf/res/app/control-panes/logs/logs.less b/crowdstf/res/app/control-panes/logs/logs.less
new file mode 100644
index 0000000..de10b63
--- /dev/null
+++ b/crowdstf/res/app/control-panes/logs/logs.less
@@ -0,0 +1,19 @@
+.stf-logs {
+  .logcat-filters-table {
+    margin-bottom: 0;
+    box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1);
+  }
+
+  .logcat-filters-table .btn {
+    margin: 0;
+  }
+
+  .logcat-filters-table thead > tr > th,
+  .logcat-filters-table tbody > tr > th,
+  .logcat-filters-table tfoot > tr > th,
+  .logcat-filters-table thead > tr > td,
+  .logcat-filters-table tbody > tr > td,
+  .logcat-filters-table tfoot > tr > td {
+    border-top: none;
+  }
+}
diff --git a/crowdstf/res/app/control-panes/performance/cpu/cpu-controller.js b/crowdstf/res/app/control-panes/performance/cpu/cpu-controller.js
new file mode 100644
index 0000000..bc4038e
--- /dev/null
+++ b/crowdstf/res/app/control-panes/performance/cpu/cpu-controller.js
@@ -0,0 +1,3 @@
+module.exports = function CpuCtrl() {
+
+}
diff --git a/crowdstf/res/app/control-panes/performance/cpu/cpu-spec.js b/crowdstf/res/app/control-panes/performance/cpu/cpu-spec.js
new file mode 100644
index 0000000..d57fd96
--- /dev/null
+++ b/crowdstf/res/app/control-panes/performance/cpu/cpu-spec.js
@@ -0,0 +1,17 @@
+describe('CpuCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./index').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('CpuCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/performance/cpu/cpu.css b/crowdstf/res/app/control-panes/performance/cpu/cpu.css
new file mode 100644
index 0000000..1ea7563
--- /dev/null
+++ b/crowdstf/res/app/control-panes/performance/cpu/cpu.css
@@ -0,0 +1,3 @@
+.stf-cpu {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/control-panes/performance/cpu/cpu.jade b/crowdstf/res/app/control-panes/performance/cpu/cpu.jade
new file mode 100644
index 0000000..a012d85
--- /dev/null
+++ b/crowdstf/res/app/control-panes/performance/cpu/cpu.jade
@@ -0,0 +1,6 @@
+.widget-container.fluid-height.stf-cpu(ng-controller='CpuCtrl')
+  .heading
+    i.fa
+    span(translate) CPU
+  .widget-content.padded
+    div
diff --git a/crowdstf/res/app/control-panes/performance/cpu/index.js b/crowdstf/res/app/control-panes/performance/cpu/index.js
new file mode 100644
index 0000000..2751bbf
--- /dev/null
+++ b/crowdstf/res/app/control-panes/performance/cpu/index.js
@@ -0,0 +1,11 @@
+require('./cpu.css')
+
+module.exports = angular.module('stf.cpu', [
+  require('epoch').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/performance/cpu/cpu.jade',
+      require('./cpu.jade')
+    )
+  }])
+  .controller('CpuCtrl', require('./cpu-controller'))
diff --git a/crowdstf/res/app/control-panes/performance/index.js b/crowdstf/res/app/control-panes/performance/index.js
new file mode 100644
index 0000000..ffb5829
--- /dev/null
+++ b/crowdstf/res/app/control-panes/performance/index.js
@@ -0,0 +1,11 @@
+require('./performance.css')
+
+module.exports = angular.module('stf.performance', [
+  require('./cpu').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/performance/performance.jade',
+      require('./performance.jade')
+    )
+  }])
+  .controller('PerformanceCtrl', require('./performance-controller'))
diff --git a/crowdstf/res/app/control-panes/performance/performance-controller.js b/crowdstf/res/app/control-panes/performance/performance-controller.js
new file mode 100644
index 0000000..d3d6d18
--- /dev/null
+++ b/crowdstf/res/app/control-panes/performance/performance-controller.js
@@ -0,0 +1,3 @@
+module.exports = function PerformanceCtrl() {
+
+}
diff --git a/crowdstf/res/app/control-panes/performance/performance-spec.js b/crowdstf/res/app/control-panes/performance/performance-spec.js
new file mode 100644
index 0000000..d38ac56
--- /dev/null
+++ b/crowdstf/res/app/control-panes/performance/performance-spec.js
@@ -0,0 +1,17 @@
+describe('PerformanceCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./index').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('PerformanceCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/performance/performance.css b/crowdstf/res/app/control-panes/performance/performance.css
new file mode 100644
index 0000000..d7e86dd
--- /dev/null
+++ b/crowdstf/res/app/control-panes/performance/performance.css
@@ -0,0 +1,3 @@
+.stf-performance {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/control-panes/performance/performance.jade b/crowdstf/res/app/control-panes/performance/performance.jade
new file mode 100644
index 0000000..31e58bc
--- /dev/null
+++ b/crowdstf/res/app/control-panes/performance/performance.jade
@@ -0,0 +1,3 @@
+.row
+  .col-md-12
+    div(ng-include='"control-panes/performance/cpu/cpu.jade"')
diff --git a/crowdstf/res/app/control-panes/resources/index.js b/crowdstf/res/app/control-panes/resources/index.js
new file mode 100644
index 0000000..b28c349
--- /dev/null
+++ b/crowdstf/res/app/control-panes/resources/index.js
@@ -0,0 +1,11 @@
+require('./resources.css')
+
+module.exports = angular.module('stf.resources', [
+
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/resources/resources.jade',
+      require('./resources.jade')
+    )
+  }])
+  .controller('ResourcesCtrl', require('./resources-controller'))
diff --git a/crowdstf/res/app/control-panes/resources/resources-controller.js b/crowdstf/res/app/control-panes/resources/resources-controller.js
new file mode 100644
index 0000000..601e7c5
--- /dev/null
+++ b/crowdstf/res/app/control-panes/resources/resources-controller.js
@@ -0,0 +1,3 @@
+module.exports = function ResourcesCtrl() {
+
+}
diff --git a/crowdstf/res/app/control-panes/resources/resources-spec.js b/crowdstf/res/app/control-panes/resources/resources-spec.js
new file mode 100644
index 0000000..6c23d37
--- /dev/null
+++ b/crowdstf/res/app/control-panes/resources/resources-spec.js
@@ -0,0 +1,17 @@
+describe('ResourcesCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('ResourcesCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/resources/resources.css b/crowdstf/res/app/control-panes/resources/resources.css
new file mode 100644
index 0000000..293bbd8
--- /dev/null
+++ b/crowdstf/res/app/control-panes/resources/resources.css
@@ -0,0 +1,3 @@
+.stf-resources {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/control-panes/resources/resources.jade b/crowdstf/res/app/control-panes/resources/resources.jade
new file mode 100644
index 0000000..feb1caf
--- /dev/null
+++ b/crowdstf/res/app/control-panes/resources/resources.jade
@@ -0,0 +1,42 @@
+.widget-container.fluid-height(ng-controller='ResourcesCtrl')
+  .heading
+    i.fa.fa-certificate
+      span(translate)  Cookies
+    button.btn.btn-sm.btn-primary-outline(ng-click='getCookies()')
+      i.fa.fa-download
+      span(translate) Get
+
+    button.btn.btn-sm.btn-danger-outline(ng-click='deleteAllCookies()', ng-disabled='true')
+      i.fa.fa-times(translate)
+      span(translate) Delete
+
+    button.btn.btn-sm.btn-primary-outline(ng-click='enableAddCookie()', ng-disabled='true')
+      i.fa(ng-class="newCookieEnabled ? 'fa-chevron-circle-up' : 'fa-chevron-circle-down'")
+      span(translate) Set
+
+    clear-button(ng-click='clearResults()', ng-disabled='!cookieContainer.results.length')
+
+  .widget-content.padded.overflow-x
+    nothing-to-show(ng-show='!cookieContainer.results.length', message='{{"No cookies to show"|translate}}', icon='fa-certificate')
+
+    div(ng-show='cookieContainer.results.length')
+      ul.cookies-list(ng-show='newCookieEnabled')
+        li
+          h4
+            span(translate) Set Cookie
+          form
+            table.table.table-condensed(ng-table='cookieContainer.tableParams').selectable
+              tr
+                td(width='20%', data-title="'Name'|translate", required='true')
+                  input(select-on-click, ng-model='newCookie.name')
+                td(width='50%', data-title="'Value'|translate", ng-required='true')
+                  input(select-on-click, ng-model='newCookie.value')
+                td(width='15%', data-title="'Domain'|translate")
+                  input(select-on-click, ng-model='newCookie.domain', placeholder='http')
+                td(width='10%', data-title="'Path'|translate")
+                  input(select-on-click, ng-model='newCookie.path', placeholder='/')
+                td(width='5%', data-title="'Secure'|translate")
+                  input(type='checkbox', indeterminate='true', ng-model='newCookie.secure')
+                td(title="'Add'|translate")
+                  button.btn.btn-default(ng-click='addCookie()')
+                    i.fa.fa-plus-circle
diff --git a/crowdstf/res/app/control-panes/screenshots/index.js b/crowdstf/res/app/control-panes/screenshots/index.js
new file mode 100644
index 0000000..6c4d986
--- /dev/null
+++ b/crowdstf/res/app/control-panes/screenshots/index.js
@@ -0,0 +1,12 @@
+require('./screenshots.css')
+
+module.exports = angular.module('stf.screenshots', [
+  require('stf/image-onload').name,
+  require('stf/settings').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('control-panes/screenshots/screenshots.jade',
+      require('./screenshots.jade')
+    )
+  }])
+  .controller('ScreenshotsCtrl', require('./screenshots-controller'))
diff --git a/crowdstf/res/app/control-panes/screenshots/screenshots-controller.js b/crowdstf/res/app/control-panes/screenshots/screenshots-controller.js
new file mode 100644
index 0000000..1ae167d
--- /dev/null
+++ b/crowdstf/res/app/control-panes/screenshots/screenshots-controller.js
@@ -0,0 +1,34 @@
+module.exports = function ScreenshotsCtrl($scope) {
+  $scope.screenshots = []
+  $scope.screenShotSize = 400
+
+  $scope.clear = function() {
+    $scope.screenshots = []
+  }
+
+  $scope.shotSizeParameter = function(maxSize, multiplier) {
+    var finalSize = $scope.screenShotSize * multiplier
+    var finalMaxSize = maxSize * multiplier
+
+    return (finalSize === finalMaxSize) ? '' :
+    '?crop=' + finalSize + 'x'
+  }
+
+  $scope.takeScreenShot = function() {
+    $scope.control.screenshot().then(function(result) {
+      $scope.$apply(function() {
+        $scope.screenshots.unshift(result)
+      })
+    })
+  }
+
+  $scope.zoom = function(param) {
+    var newValue = parseInt($scope.screenShotSize, 10) + param.step
+    if (param.min && newValue < param.min) {
+      newValue = param.min
+    } else if (param.max && newValue > param.max) {
+      newValue = param.max
+    }
+    $scope.screenShotSize = newValue
+  }
+}
diff --git a/crowdstf/res/app/control-panes/screenshots/screenshots-spec.js b/crowdstf/res/app/control-panes/screenshots/screenshots-spec.js
new file mode 100644
index 0000000..7f39708
--- /dev/null
+++ b/crowdstf/res/app/control-panes/screenshots/screenshots-spec.js
@@ -0,0 +1,17 @@
+describe('ScreenshotsCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('ScreenshotsCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/control-panes/screenshots/screenshots.css b/crowdstf/res/app/control-panes/screenshots/screenshots.css
new file mode 100644
index 0000000..22d209d
--- /dev/null
+++ b/crowdstf/res/app/control-panes/screenshots/screenshots.css
@@ -0,0 +1,25 @@
+.stf-screenshots {
+
+}
+
+.stf-screenshots .screenshot-image {
+  display: block !important;
+}
+
+.stf-screenshots .screenshot-image {
+  -webkit-transition: 1000ms;
+  transition: 1000ms;
+}
+
+.stf-screenshots .screenshot-image.ng-image-not-loaded {
+  opacity: 0;
+}
+
+.stf-screenshots .screenshot-image {
+  opacity: 1;
+}
+
+.stf-screenshots .zoom-range {
+  width: 180px;
+  margin-right: 5px;
+}
diff --git a/crowdstf/res/app/control-panes/screenshots/screenshots.jade b/crowdstf/res/app/control-panes/screenshots/screenshots.jade
new file mode 100644
index 0000000..b1317de
--- /dev/null
+++ b/crowdstf/res/app/control-panes/screenshots/screenshots.jade
@@ -0,0 +1,45 @@
+.widget-container.fluid-height(ng-controller='ScreenshotsCtrl').stf-screenshots
+  .heading
+    button.btn.btn-sm.btn-primary-outline(ng-click='takeScreenShot()',
+    title='{{"Take Screenshot"|translate}}')
+      i.fa.fa-camera
+      span(translate) Screenshot
+    //button.btn.btn-sm.btn-info-outline(ng-click='takePageShot()',
+      title='{{"Take Pageshot (Needs WebView running)"|translate}}',
+      ng-if='$root.platform == "web" && $root.browser == "webview"')
+      i.fa.fa-camera-retro(translate)
+      span(translate) Pageshot
+
+    .button-spacer
+
+    clear-button(ng-click='clear()', ng-disabled='!screenshots.length')
+
+    .button-spacer
+
+    .pull-right
+      button.btn.btn-primary-outline.btn-sm.transparent-border(
+        ng-click='zoom({min: 80, step: -50})',
+        ng-disabled='!screenshots.length')
+        i.fa.fa-minus
+
+      input(type='range', ng-model='screenShotSize', min='80', max='480', step='10',
+      ng-model-options='{ updateOn: "default blur", debounce: { default: 100, blur: 0} }',
+      ng-disabled='!screenshots.length').zoom-range
+
+      button.btn.btn-primary-outline.btn-sm.transparent-border(
+        ng-click='zoom({max: 480, step: 50})',
+        ng-disabled='!screenshots.length')
+        i.fa.fa-plus
+
+  .widget-content.padded
+    nothing-to-show(message='{{"No screenshots taken"|translate}}', icon='fa-camera', ng-show='!screenshots.length')
+
+    ul.screenshots-icon-view.clear-fix.selectable
+      li.screenshots-icon-item(ng-repeat='shot in screenshots').cursor-select
+        h5 {{ device.name }}
+        h6 {{ shot.body.date | date:'HH:mm:ss' }}
+        a(ng-href='{{ shot.body.href }}', target='_blank')
+          img(ng-src='{{ shot.body.href + shotSizeParameter(480, 1) }}',
+          ng-srcset='{{ shot.body.href + shotSizeParameter(480, 2) }} 2x').screenshot-image
+
+    .clearfix
diff --git a/crowdstf/res/app/device-list/column/device-column-service.js b/crowdstf/res/app/device-list/column/device-column-service.js
new file mode 100644
index 0000000..750fb42
--- /dev/null
+++ b/crowdstf/res/app/device-list/column/device-column-service.js
@@ -0,0 +1,660 @@
+var _ = require('lodash')
+
+var filterOps = {
+  '<': function(a, filterValue) {
+    return a < filterValue
+  }
+  , '<=': function(a, filterValue) {
+    return a <= filterValue
+  }
+  , '>': function(a, filterValue) {
+    return a > filterValue
+  }
+  , '>=': function(a, filterValue) {
+    return a >= filterValue
+  }
+  , '=': function(a, filterValue) {
+    return a === filterValue
+  }
+}
+
+module.exports = function DeviceColumnService($filter, gettext) {
+  // Definitions for all possible values.
+  return {
+    state: DeviceStatusCell({
+      title: gettext('Status')
+    , value: function(device) {
+        return $filter('translate')(device.enhancedStateAction)
+      }
+    })
+  , model: DeviceModelCell({
+      title: gettext('Model')
+    , value: function(device) {
+        return device.model || device.serial
+      }
+    })
+  , name: DeviceNameCell({
+      title: gettext('Product')
+    , value: function(device) {
+        return device.name || device.model || device.serial
+      }
+    })
+  , operator: TextCell({
+      title: gettext('Carrier')
+    , value: function(device) {
+        return device.operator || ''
+      }
+    })
+  , releasedAt: DateCell({
+      title: gettext('Released')
+    , value: function(device) {
+        return device.releasedAt ? new Date(device.releasedAt) : null
+      }
+    })
+  , version: TextCell({
+      title: gettext('OS')
+    , value: function(device) {
+        return device.version || ''
+      }
+    , compare: function(deviceA, deviceB) {
+        var va = (deviceA.version || '0').split('.')
+        var vb = (deviceB.version || '0').split('.')
+        var la = va.length
+        var lb = vb.length
+
+        for (var i = 0, l = Math.max(la, lb); i < l; ++i) {
+          var a = i < la ? parseInt(va[i], 10) : 0
+          var b = i < lb ? parseInt(vb[i], 10) : 0
+          var diff = a - b
+
+          // One of the values might be something like 'M'. If so, do a string
+          // comparison instead.
+          if (isNaN(diff)) {
+            diff = compareRespectCase(va[i], vb[i])
+          }
+
+          if (diff !== 0) {
+            return diff
+          }
+        }
+
+        return 0
+      }
+    , filter: function(device, filter) {
+        var va = (device.version || '0').split('.')
+        var vb = (filter.query || '0').split('.')
+        var la = va.length
+        var lb = vb.length
+        var op = filterOps[filter.op || '=']
+
+        // We have a single value and no operator or field. It matches
+        // too easily, let's wait for a dot (e.g. '5.'). An example of a
+        // bad match would be an unquoted query for 'Nexus 5', which targets
+        // a very specific device but may easily match every Nexus device
+        // as the two terms are handled separately.
+        if (filter.op === null && filter.field === null && lb === 1) {
+          return false
+        }
+
+        if (vb[lb - 1] === '') {
+          // This means that the query is not complete yet, and we're
+          // looking at something like "4.", which means that the last part
+          // should be ignored.
+          vb.pop()
+          lb -= 1
+        }
+
+        for (var i = 0, l = Math.min(la, lb); i < l; ++i) {
+          var a = parseInt(va[i], 10)
+          var b = parseInt(vb[i], 10)
+
+          // One of the values might be non-numeric, e.g. 'M'. In that case
+          // filter by string value instead.
+          if (isNaN(a) || isNaN(b)) {
+            if (!op(va[i], vb[i])) {
+              return false
+            }
+          }
+          else {
+            if (!op(a, b)) {
+              return false
+            }
+          }
+        }
+
+        return true
+      }
+    })
+  , network: TextCell({
+      title: gettext('Network')
+    , value: function(device) {
+        return device.phone ? device.phone.network : ''
+      }
+    })
+  , display: TextCell({
+      title: gettext('Screen')
+    , defaultOrder: 'desc'
+    , value: function(device) {
+        return device.display && device.display.width
+          ? device.display.width + 'x' + device.display.height
+          : ''
+      }
+    , compare: function(deviceA, deviceB) {
+        var va = deviceA.display && deviceA.display.width
+          ? deviceA.display.width * deviceA.display.height
+          : 0
+        var vb = deviceB.display && deviceB.display.width
+          ? deviceB.display.width * deviceB.display.height
+          : 0
+        return va - vb
+      }
+    })
+  , browser: DeviceBrowserCell({
+      title: gettext('Browser')
+    , value: function(device) {
+        return device.browser || {apps: []}
+      }
+    })
+  , serial: TextCell({
+      title: gettext('Serial')
+    , value: function(device) {
+        return device.serial || ''
+      }
+    })
+  , manufacturer: TextCell({
+      title: gettext('Manufacturer')
+    , value: function(device) {
+        return device.manufacturer || ''
+      }
+    })
+  , sdk: NumberCell({
+      title: gettext('SDK')
+    , defaultOrder: 'desc'
+    , value: function(device) {
+        return device.sdk || ''
+      }
+    })
+  , abi: TextCell({
+      title: gettext('ABI')
+    , value: function(device) {
+        return device.abi || ''
+      }
+    })
+  , phone: TextCell({
+      title: gettext('Phone')
+    , value: function(device) {
+        return device.phone ? device.phone.phoneNumber : ''
+      }
+    })
+  , imei: TextCell({
+      title: gettext('Phone IMEI')
+    , value: function(device) {
+        return device.phone ? device.phone.imei : ''
+      }
+    })
+  , iccid: TextCell({
+      title: gettext('Phone ICCID')
+    , value: function(device) {
+        return device.phone ? device.phone.iccid : ''
+      }
+    })
+  , batteryHealth: TextCell({
+      title: gettext('Battery Health')
+    , value: function(device) {
+        return device.battery
+          ? $filter('translate')(device.enhancedBatteryHealth)
+          : ''
+      }
+    })
+  , batterySource: TextCell({
+      title: gettext('Battery Source')
+    , value: function(device) {
+        return device.battery
+          ? $filter('translate')(device.enhancedBatterySource)
+          : ''
+      }
+    })
+  , batteryStatus: TextCell({
+      title: gettext('Battery Status')
+    , value: function(device) {
+        return device.battery
+          ? $filter('translate')(device.enhancedBatteryStatus)
+          : ''
+      }
+    })
+  , batteryLevel: TextCell({
+      title: gettext('Battery Level')
+    , value: function(device) {
+        return device.battery
+          ? Math.floor(device.battery.level / device.battery.scale * 100) + '%'
+          : ''
+      }
+    , compare: function(deviceA, deviceB) {
+        var va = deviceA.battery ? deviceA.battery.level : 0
+        var vb = deviceB.battery ? deviceB.battery.level : 0
+        return va - vb
+      }
+    })
+  , batteryTemp: TextCell({
+      title: gettext('Battery Temp')
+    , value: function(device) {
+        return device.battery ? device.battery.temp + '°C' : ''
+      }
+    , compare: function(deviceA, deviceB) {
+        var va = deviceA.battery ? deviceA.battery.temp : 0
+        var vb = deviceB.battery ? deviceB.battery.temp : 0
+        return va - vb
+      }
+    })
+  , provider: TextCell({
+      title: gettext('Location')
+    , value: function(device) {
+        return device.provider ? device.provider.name : ''
+      }
+    })
+  , notes: DeviceNoteCell({
+      title: gettext('Notes')
+    , value: function(device) {
+        return device.notes || ''
+      }
+    })
+  , owner: LinkCell({
+      title: gettext('User')
+    , target: '_blank'
+    , value: function(device) {
+        return device.owner ? device.owner.name : ''
+      }
+    , link: function(device) {
+        return device.owner ? device.enhancedUserProfileUrl : ''
+      }
+    })
+  }
+}
+
+function zeroPadTwoDigit(digit) {
+  return digit < 10 ? '0' + digit : '' + digit
+}
+
+function compareIgnoreCase(a, b) {
+  var la = (a || '').toLowerCase()
+  var lb = (b || '').toLowerCase()
+  if (la === lb) {
+    return 0
+  }
+  else {
+    return la < lb ? -1 : 1
+  }
+}
+
+function filterIgnoreCase(a, filterValue) {
+  var va = (a || '').toLowerCase()
+  var vb = filterValue.toLowerCase()
+  return va.indexOf(vb) !== -1
+}
+
+function compareRespectCase(a, b) {
+  if (a === b) {
+    return 0
+  }
+  else {
+    return a < b ? -1 : 1
+  }
+}
+
+
+function TextCell(options) {
+  return _.defaults(options, {
+    title: options.title
+  , defaultOrder: 'asc'
+  , build: function() {
+      var td = document.createElement('td')
+      td.appendChild(document.createTextNode(''))
+      return td
+    }
+  , update: function(td, item) {
+      var t = td.firstChild
+      t.nodeValue = options.value(item)
+      return td
+    }
+  , compare: function(a, b) {
+      return compareIgnoreCase(options.value(a), options.value(b))
+    }
+  , filter: function(item, filter) {
+      return filterIgnoreCase(options.value(item), filter.query)
+    }
+  })
+}
+
+function NumberCell(options) {
+  return _.defaults(options, {
+    title: options.title
+  , defaultOrder: 'asc'
+  , build: function() {
+      var td = document.createElement('td')
+      td.appendChild(document.createTextNode(''))
+      return td
+    }
+  , update: function(td, item) {
+      var t = td.firstChild
+      t.nodeValue = options.value(item)
+      return td
+    }
+  , compare: function(a, b) {
+      return options.value(a) - options.value(b)
+    }
+  , filter: (function() {
+      return function(item, filter) {
+        return filterOps[filter.op || '='](
+          options.value(item)
+        , Number(filter.query)
+        )
+      }
+    })()
+  })
+}
+
+function DateCell(options) {
+  return _.defaults(options, {
+    title: options.title
+  , defaultOrder: 'desc'
+  , build: function() {
+      var td = document.createElement('td')
+      td.appendChild(document.createTextNode(''))
+      return td
+    }
+  , update: function(td, item) {
+      var t = td.firstChild
+      var date = options.value(item)
+      if (date) {
+        t.nodeValue = date.getFullYear()
+          + '-'
+          + zeroPadTwoDigit(date.getMonth() + 1)
+          + '-'
+          + zeroPadTwoDigit(date.getDate())
+      }
+      else {
+        t.nodeValue = ''
+      }
+      return td
+    }
+  , compare: function(a, b) {
+      var va = options.value(a) || 0
+      var vb = options.value(b) || 0
+      return va - vb
+    }
+  , filter: (function() {
+      function dateNumber(d) {
+        return d
+          ? d.getFullYear() * 10000 + d.getMonth() * 100 + d.getDate()
+          : 0
+      }
+      return function(item, filter) {
+        var filterDate = new Date(filter.query)
+        var va = dateNumber(options.value(item))
+        var vb = dateNumber(filterDate)
+        return filterOps[filter.op || '='](va, vb)
+      }
+    })()
+  })
+}
+
+function LinkCell(options) {
+  return _.defaults(options, {
+    title: options.title
+  , defaultOrder: 'asc'
+  , build: function() {
+      var td = document.createElement('td')
+      var a = document.createElement('a')
+      a.appendChild(document.createTextNode(''))
+      td.appendChild(a)
+      return td
+    }
+  , update: function(td, item) {
+      var a = td.firstChild
+      var t = a.firstChild
+      var href = options.link(item)
+      if (href) {
+        a.setAttribute('href', href)
+      }
+      else {
+        a.removeAttribute('href')
+      }
+      a.target = options.target || ''
+      t.nodeValue = options.value(item)
+      return td
+    }
+  , compare: function(a, b) {
+      return compareIgnoreCase(options.value(a), options.value(b))
+    }
+  , filter: function(item, filter) {
+      return filterIgnoreCase(options.value(item), filter.query)
+    }
+  })
+}
+
+function DeviceBrowserCell(options) {
+  return _.defaults(options, {
+    title: options.title
+  , defaultOrder: 'asc'
+  , build: function() {
+      var td = document.createElement('td')
+      var span = document.createElement('span')
+      span.className = 'device-browser-list'
+      td.appendChild(span)
+      return td
+    }
+  , update: function(td, device) {
+      var span = td.firstChild
+      var browser = options.value(device)
+      var apps = browser.apps.slice().sort(function(appA, appB) {
+            return compareIgnoreCase(appA.name, appB.name)
+          })
+
+      for (var i = 0, l = apps.length; i < l; ++i) {
+        var app = apps[i]
+        var img = span.childNodes[i] || span.appendChild(document.createElement('img'))
+        var src = '/static/app/browsers/icon/36x36/' + (app.type || '_default') + '.png'
+
+        // Only change if necessary so that we don't trigger a download
+        if (img.getAttribute('src') !== src) {
+          img.setAttribute('src', src)
+        }
+
+        img.title = app.name + ' (' + app.developer + ')'
+      }
+
+      while (span.childNodes.length > browser.apps.length) {
+        span.removeChild(span.lastChild)
+      }
+
+      return td
+    }
+  , compare: function(a, b) {
+      return options.value(a).apps.length - options.value(b).apps.length
+    }
+  , filter: function(device, filter) {
+      return options.value(device).apps.some(function(app) {
+        return filterIgnoreCase(app.type, filter.query)
+      })
+    }
+  })
+}
+
+function DeviceModelCell(options) {
+  return _.defaults(options, {
+    title: options.title
+  , defaultOrder: 'asc'
+  , build: function() {
+      var td = document.createElement('td')
+      var span = document.createElement('span')
+      var image = document.createElement('img')
+      span.className = 'device-small-image'
+      image.className = 'device-small-image-img pointer'
+      span.appendChild(image)
+      td.appendChild(span)
+      td.appendChild(document.createTextNode(''))
+      return td
+    }
+  , update: function(td, device) {
+      var span = td.firstChild
+      var image = span.firstChild
+      var t = span.nextSibling
+      var src = '/static/app/devices/icon/x24/' +
+            (device.image || '_default.jpg')
+
+      // Only change if necessary so that we don't trigger a download
+      if (image.getAttribute('src') !== src) {
+        image.setAttribute('src', src)
+      }
+
+      t.nodeValue = options.value(device)
+
+      return td
+    }
+  , compare: function(a, b) {
+      return compareRespectCase(options.value(a), options.value(b))
+    }
+  , filter: function(device, filter) {
+      return filterIgnoreCase(options.value(device), filter.query)
+    }
+  })
+}
+
+function DeviceNameCell(options) {
+  return _.defaults(options, {
+    title: options.title
+  , defaultOrder: 'asc'
+  , build: function() {
+      var td = document.createElement('td')
+      var a = document.createElement('a')
+      a.appendChild(document.createTextNode(''))
+      td.appendChild(a)
+      return td
+    }
+  , update: function(td, device) {
+      var a = td.firstChild
+      var t = a.firstChild
+
+      if (device.using) {
+        a.className = 'device-product-name-using'
+        a.href = '#!/control/' + device.serial
+      }
+      else if (device.usable) {
+        a.className = 'device-product-name-usable'
+        a.href = '#!/control/' + device.serial
+      }
+      else {
+        a.className = 'device-product-name-unusable'
+        a.removeAttribute('href')
+      }
+
+      t.nodeValue = options.value(device)
+
+      return td
+    }
+  , compare: function(a, b) {
+      return compareIgnoreCase(options.value(a), options.value(b))
+    }
+  , filter: function(device, filter) {
+      return filterIgnoreCase(options.value(device), filter.query)
+    }
+  })
+}
+
+function DeviceStatusCell(options) {
+  var stateClasses = {
+    using: 'state-using btn-primary'
+  , busy: 'state-busy btn-warning'
+  , available: 'state-available btn-primary-outline'
+  , ready: 'state-ready btn-primary-outline'
+  , present: 'state-present btn-primary-outline'
+  , preparing: 'state-preparing btn-primary-outline btn-success-outline'
+  , unauthorized: 'state-unauthorized btn-danger-outline'
+  , offline: 'state-offline btn-warning-outline'
+  }
+
+  return _.defaults(options, {
+    title: options.title
+  , defaultOrder: 'asc'
+  , build: function() {
+      var td = document.createElement('td')
+      var a = document.createElement('a')
+      a.appendChild(document.createTextNode(''))
+      td.appendChild(a)
+      return td
+    }
+  , update: function(td, device) {
+      var a = td.firstChild
+      var t = a.firstChild
+
+      a.className = 'btn btn-xs device-status ' +
+        (stateClasses[device.state] || 'btn-default-outline')
+
+      if (device.usable && !device.using) {
+        a.href = '#!/control/' + device.serial
+      }
+      else {
+        a.removeAttribute('href')
+      }
+
+      t.nodeValue = options.value(device)
+
+      return td
+    }
+  , compare: (function() {
+      var order = {
+        using: 10
+      , available: 20
+      , busy: 30
+      , ready: 40
+      , preparing: 50
+      , unauthorized: 60
+      , offline: 70
+      , present: 80
+      , absent: 90
+      }
+      return function(deviceA, deviceB) {
+        return order[deviceA.state] - order[deviceB.state]
+      }
+    })()
+  , filter: function(device, filter) {
+      return device.state === filter.query
+    }
+  })
+}
+
+function DeviceNoteCell(options) {
+  return _.defaults(options, {
+    title: options.title
+  , defaultOrder: 'asc'
+  , build: function() {
+      var td = document.createElement('td')
+      var span = document.createElement('span')
+      var i = document.createElement('i')
+
+      td.className = 'device-note'
+      span.className = 'xeditable-wrapper'
+      span.appendChild(document.createTextNode(''))
+
+      i.className = 'device-note-edit fa fa-pencil pointer'
+
+      td.appendChild(span)
+      td.appendChild(i)
+
+      return td
+    }
+  , update: function(td, item) {
+      var span = td.firstChild
+      var t = span.firstChild
+
+      t.nodeValue = options.value(item)
+      return td
+    }
+  , compare: function(a, b) {
+      return compareIgnoreCase(options.value(a), options.value(b))
+    }
+  , filter: function(item, filter) {
+      return filterIgnoreCase(options.value(item), filter.query)
+    }
+  })
+}
diff --git a/crowdstf/res/app/device-list/column/index.js b/crowdstf/res/app/device-list/column/index.js
new file mode 100644
index 0000000..8f4323a
--- /dev/null
+++ b/crowdstf/res/app/device-list/column/index.js
@@ -0,0 +1,4 @@
+module.exports = angular.module('stf.device-list.column', [
+  require('gettext').name
+])
+  .service('DeviceColumnService', require('./device-column-service'))
diff --git a/crowdstf/res/app/device-list/customize/device-list-customize.css b/crowdstf/res/app/device-list/customize/device-list-customize.css
new file mode 100644
index 0000000..973abca
--- /dev/null
+++ b/crowdstf/res/app/device-list/customize/device-list-customize.css
@@ -0,0 +1,15 @@
+.stf-device-list .stf-device-details-customize {
+  white-space: nowrap;
+  padding: 10px;
+  padding-bottom: 0;
+  column-count: 2;
+  -moz-column-count: 2;
+  -webkit-column-count: 2;
+  max-width: 800px;
+}
+
+.stf-device-list .stf-device-details-customize .radio,
+.stf-device-list .stf-device-details-customize .checkbox {
+  margin: 0;
+  margin-bottom: 10px;
+}
diff --git a/crowdstf/res/app/device-list/customize/index.js b/crowdstf/res/app/device-list/customize/index.js
new file mode 100644
index 0000000..e82677e
--- /dev/null
+++ b/crowdstf/res/app/device-list/customize/index.js
@@ -0,0 +1,4 @@
+require('./device-list-customize.css')
+
+module.exports = angular.module('stf.device-list.customize', [
+])
diff --git a/crowdstf/res/app/device-list/details/device-list-details-directive.js b/crowdstf/res/app/device-list/details/device-list-details-directive.js
new file mode 100644
index 0000000..4a6c8c3
--- /dev/null
+++ b/crowdstf/res/app/device-list/details/device-list-details-directive.js
@@ -0,0 +1,576 @@
+var patchArray = require('./../util/patch-array')
+
+module.exports = function DeviceListDetailsDirective(
+  $filter
+, $compile
+, $rootScope
+, gettext
+, DeviceColumnService
+, GroupService
+, DeviceService
+, LightboxImageService
+, StandaloneService
+) {
+  return {
+    restrict: 'E'
+  , template: require('./device-list-details.jade')
+  , scope: {
+      tracker: '&tracker'
+    , columns: '&columns'
+    , sort: '=sort'
+    , filter: '&filter'
+    }
+  , link: function(scope, element) {
+      var tracker = scope.tracker()
+      var activeColumns = []
+      var activeSorting = []
+      var activeFilters = []
+      var table = element.find('table')[0]
+      var tbody = table.createTBody()
+      var rows = tbody.rows
+      var prefix = 'd' + Math.floor(Math.random() * 1000000) + '-'
+      var mapping = Object.create(null)
+      var childScopes = Object.create(null)
+
+
+      function kickDevice(device, force) {
+        return GroupService.kick(device, force).catch(function(e) {
+          alert($filter('translate')(gettext('Device cannot get kicked from the group')))
+          throw new Error(e)
+        })
+      }
+
+      function inviteDevice(device) {
+        return GroupService.invite(device).then(function() {
+          scope.$digest()
+        })
+      }
+
+      function checkDeviceStatus(e) {
+        if (e.target.classList.contains('device-status')) {
+          var id = e.target.parentNode.parentNode.id
+          var device = mapping[id]
+
+          if (e.altKey && device.state === 'available') {
+            inviteDevice(device)
+            e.preventDefault()
+          }
+
+          if (e.shiftKey && device.state === 'available') {
+            StandaloneService.open(device)
+            e.preventDefault()
+          }
+
+          if ($rootScope.adminMode && device.state === 'busy') {
+            kickDevice(device, true)
+            e.preventDefault()
+          }
+          else if (device.using) {
+            kickDevice(device)
+            e.preventDefault()
+          }
+        }
+      }
+
+      function checkDeviceSmallImage(e) {
+        if (e.target.classList.contains('device-small-image-img')) {
+          var id = e.target.parentNode.parentNode.parentNode.id
+          var device = mapping[id]
+
+          if (device.name && device.image) {
+            var title = device.name
+            var enhancedPhoto800 = '/static/app/devices/photo/x800/' + device.image
+            LightboxImageService.open(title, enhancedPhoto800)
+          }
+        }
+      }
+
+      // On clicking device-note-edit icon
+      // This function will create a new angular-xeditable span
+      // inside xeditableWrapper and compile it with
+      // new child scope.
+      // Childscope will be destroyed when the editing will be over
+      function checkDeviceNote(e) {
+        if (e.target.classList.contains('device-note-edit')) {
+
+          var i = e.target
+          var id = i.parentNode.parentNode.id
+          var device = mapping[id]
+          var xeditableWrapper = i.parentNode.firstChild
+          var xeditableSpan = document.createElement('span')
+          var childScope = scope.$new()
+
+          // Ref: http://vitalets.github.io/angular-xeditable/#text-btn
+          xeditableSpan.setAttribute('editable-text', 'device.notes')
+          xeditableSpan.setAttribute('onbeforesave', 'updateNote(id, device.serial, $data)')
+          xeditableSpan.setAttribute('onCancel', 'onDeviceNoteCancel(id)')
+
+          childScope.id = id
+          childScope.device = device
+          childScopes[id] = childScope
+
+          $compile(xeditableSpan)(childScope)
+          xeditableWrapper.appendChild(xeditableSpan)
+
+          // Trigger click to open the form.
+          angular.element(xeditableSpan).triggerHandler('click')
+        }
+      }
+
+      function destroyXeditableNote(id) {
+        var tr = tbody.children[id]
+        for (var i = 0; i < tr.cells.length; i++) {
+          var col = tr.cells[i]
+
+          if (col.firstChild &&
+              col.firstChild.nodeName.toLowerCase() === 'span' &&
+              col.firstChild.classList.contains('xeditable-wrapper')) {
+
+            var xeditableWrapper = col.firstChild
+            var children = xeditableWrapper.children
+
+            // Remove all childs under xeditablerWrapper
+            for (var j = 0; j < children.length; j++) {
+              xeditableWrapper.removeChild(children[j])
+            }
+          }
+        }
+        childScopes[id].$destroy()
+      }
+
+      scope.updateNote = function(id, serial, note) {
+        DeviceService.updateNote(serial, note)
+        destroyXeditableNote(id)
+      }
+
+      scope.onDeviceNoteCancel = function(id) {
+        destroyXeditableNote(id)
+      }
+
+      element.on('click', function(e) {
+        checkDeviceStatus(e)
+        checkDeviceSmallImage(e)
+        checkDeviceNote(e)
+      })
+
+      // Import column definitions
+      scope.columnDefinitions = DeviceColumnService
+
+      // Sorting
+      scope.sortBy = function(column, multiple) {
+        function findInSorting(sorting) {
+          for (var i = 0, l = sorting.length; i < l; ++i) {
+            if (sorting[i].name === column.name) {
+              return sorting[i]
+            }
+          }
+          return null
+        }
+
+        var swap = {
+          asc: 'desc'
+        , desc: 'asc'
+        }
+
+        var fixedMatch = findInSorting(scope.sort.fixed)
+        if (fixedMatch) {
+          fixedMatch.order = swap[fixedMatch.order]
+          return
+        }
+
+        var userMatch = findInSorting(scope.sort.user)
+        if (userMatch) {
+          userMatch.order = swap[userMatch.order]
+          if (!multiple) {
+            scope.sort.user = [userMatch]
+          }
+        }
+        else {
+          if (!multiple) {
+            scope.sort.user = []
+          }
+          scope.sort.user.push({
+            name: column.name
+          , order: scope.columnDefinitions[column.name].defaultOrder || 'asc'
+          })
+        }
+      }
+
+      // Watch for sorting changes
+      scope.$watch(
+        function() {
+          return scope.sort
+        }
+      , function(newValue) {
+          activeSorting = newValue.fixed.concat(newValue.user)
+          scope.sortedColumns = Object.create(null)
+          activeSorting.forEach(function(sort) {
+            scope.sortedColumns[sort.name] = sort
+          })
+          sortAll()
+        }
+      , true
+      )
+
+      // Watch for column updates
+      scope.$watch(
+        function() {
+          return scope.columns()
+        }
+      , function(newValue) {
+          updateColumns(newValue)
+        }
+      , true
+      )
+
+      // Update now so that we don't have to wait for the scope watcher to
+      // trigger.
+      updateColumns(scope.columns())
+
+      // Updates visible columns. This method doesn't necessarily have to be
+      // the fastest because it shouldn't get called all the time.
+      function updateColumns(columnSettings) {
+        var newActiveColumns = []
+
+        // Check what we're supposed to show now
+        columnSettings.forEach(function(column) {
+          if (column.selected) {
+            newActiveColumns.push(column.name)
+          }
+        })
+
+        // Figure out the patch
+        var patch = patchArray(activeColumns, newActiveColumns)
+
+        // Set up new active columns
+        activeColumns = newActiveColumns
+
+        return patchAll(patch)
+      }
+
+      // Updates filters on visible items.
+      function updateFilters(filters) {
+        activeFilters = filters
+        return filterAll()
+      }
+
+      // Applies filterRow() to all rows.
+      function filterAll() {
+        for (var i = 0, l = rows.length; i < l; ++i) {
+          filterRow(rows[i], mapping[rows[i].id])
+        }
+      }
+
+      // Filters a row, perhaps removing it from view.
+      function filterRow(row, device) {
+        if (match(device)) {
+          row.classList.remove('filter-out')
+        }
+        else {
+          row.classList.add('filter-out')
+        }
+      }
+
+      // Checks whether the device matches the currently active filters.
+      function match(device) {
+        for (var i = 0, l = activeFilters.length; i < l; ++i) {
+          var filter = activeFilters[i]
+          var column
+          if (filter.field) {
+            column = scope.columnDefinitions[filter.field]
+            if (column && !column.filter(device, filter)) {
+              return false
+            }
+          }
+          else {
+            var found = false
+            for (var j = 0, k = activeColumns.length; j < k; ++j) {
+              column = scope.columnDefinitions[activeColumns[j]]
+              if (column && column.filter(device, filter)) {
+                found = true
+                break
+              }
+            }
+            if (!found) {
+              return false
+            }
+          }
+        }
+        return true
+      }
+
+      // Update now so we're up to date.
+      updateFilters(scope.filter())
+
+      // Watch for filter updates.
+      scope.$watch(
+        function() {
+          return scope.filter()
+        }
+      , function(newValue) {
+          updateFilters(newValue)
+        }
+      , true
+      )
+
+      // Calculates a DOM ID for the device. Should be consistent for the
+      // same device within the same table, but unique among other tables.
+      function calculateId(device) {
+        return prefix + device.serial
+      }
+
+      // Compares two devices using the currently active sorting. Returns <0
+      // if deviceA is smaller, >0 if deviceA is bigger, or 0 if equal.
+      var compare = (function() {
+        var mapping = {
+          asc: 1
+        , desc: -1
+        }
+        return function(deviceA, deviceB) {
+          var diff
+
+          // Find the first difference
+          for (var i = 0, l = activeSorting.length; i < l; ++i) {
+            var sort = activeSorting[i]
+            diff = scope.columnDefinitions[sort.name].compare(deviceA, deviceB)
+            if (diff !== 0) {
+              diff *= mapping[sort.order]
+              break
+            }
+          }
+
+          return diff
+        }
+      })()
+
+      // Creates a completely new row for the device. Means that this is
+      // the first time we see the device.
+      function createRow(device) {
+        var id = calculateId(device)
+        var tr = document.createElement('tr')
+        var td
+
+        tr.id = id
+
+        if (!device.usable) {
+          tr.classList.add('device-not-usable')
+        }
+
+        for (var i = 0, l = activeColumns.length; i < l; ++i) {
+          td = scope.columnDefinitions[activeColumns[i]].build()
+          scope.columnDefinitions[activeColumns[i]].update(td, device)
+          tr.appendChild(td)
+        }
+
+        mapping[id] = device
+
+        return tr
+      }
+
+      // Patches all rows.
+      function patchAll(patch) {
+        for (var i = 0, l = rows.length; i < l; ++i) {
+          patchRow(rows[i], mapping[rows[i].id], patch)
+        }
+      }
+
+      // Patches the given row by running the given patch operations in
+      // order. The operations must take into account index changes caused
+      // by previous operations.
+      function patchRow(tr, device, patch) {
+        for (var i = 0, l = patch.length; i < l; ++i) {
+          var op = patch[i]
+          switch (op[0]) {
+          case 'insert':
+            var col = scope.columnDefinitions[op[2]]
+            tr.insertBefore(col.update(col.build(), device), tr.cells[op[1]])
+            break
+          case 'remove':
+            tr.deleteCell(op[1])
+            break
+          case 'swap':
+            tr.insertBefore(tr.cells[op[1]], tr.cells[op[2]])
+            tr.insertBefore(tr.cells[op[2]], tr.cells[op[1]])
+            break
+          }
+        }
+
+        return tr
+      }
+
+      // Updates all the columns in the row. Note that the row must be in
+      // the right format already (built with createRow() and patched with
+      // patchRow() if necessary).
+      function updateRow(tr, device) {
+        var id = calculateId(device)
+
+        tr.id = id
+
+        if (!device.usable) {
+          tr.classList.add('device-not-usable')
+        }
+        else {
+          tr.classList.remove('device-not-usable')
+        }
+
+        for (var i = 0, l = activeColumns.length; i < l; ++i) {
+          scope.columnDefinitions[activeColumns[i]].update(tr.cells[i], device)
+        }
+
+        return tr
+      }
+
+      // Inserts a row into the table into its correct position according to
+      // current sorting.
+      function insertRow(tr, deviceA) {
+        return insertRowToSegment(tr, deviceA, 0, rows.length - 1)
+      }
+
+      // Inserts a row into a segment of the table into its correct position
+      // according to current sorting. The value of `hi` is the index
+      // of the last item in the segment, or -1 if none. The value of `lo`
+      // is the index of the first item in the segment, or 0 if none.
+      function insertRowToSegment(tr, deviceA, low, high) {
+        var total = rows.length
+        var lo = low
+        var hi = high
+
+        if (lo > hi) {
+          // This means that `lo` refers to the first item of the next
+          // segment (which may or may not exist), and we should put the
+          // row before it.
+          tbody.insertBefore(tr, lo < total ? rows[lo] : null)
+        }
+        else {
+          var after = true
+          var pivot = 0
+          var deviceB
+
+          while (lo <= hi) {
+            pivot = ~~((lo + hi) / 2)
+            deviceB = mapping[rows[pivot].id]
+
+            var diff = compare(deviceA, deviceB)
+
+            if (diff === 0) {
+              after = true
+              break
+            }
+
+            if (diff < 0) {
+              hi = pivot - 1
+              after = false
+            }
+            else {
+              lo = pivot + 1
+              after = true
+            }
+          }
+
+          if (after) {
+            tbody.insertBefore(tr, rows[pivot].nextSibling)
+          }
+          else {
+            tbody.insertBefore(tr, rows[pivot])
+          }
+        }
+      }
+
+      // Compares a row to its siblings to see if it's still in the correct
+      // position. Returns <0 if the device should actually go somewhere
+      // before the previous row, >0 if it should go somewhere after the next
+      // row, or 0 if the position is already correct.
+      function compareRow(tr, device) {
+        var prev = tr.previousSibling
+        var next = tr.nextSibling
+        var diff
+
+        if (prev) {
+          diff = compare(device, mapping[prev.id])
+          if (diff < 0) {
+            return diff
+          }
+        }
+
+        if (next) {
+          diff = compare(device, mapping[next.id])
+          if (diff > 0) {
+            return diff
+          }
+        }
+
+        return 0
+      }
+
+      // Sort all rows.
+      function sortAll() {
+        // This could be improved by getting rid of the array copying. The
+        // copy is made because rows can't be sorted directly.
+        var sorted = [].slice.call(rows).sort(function(rowA, rowB) {
+          return compare(mapping[rowA.id], mapping[rowB.id])
+        })
+
+        // Now, if we just append all the elements, they will be in the
+        // correct order in the table.
+        for (var i = 0, l = sorted.length; i < l; ++i) {
+          tbody.appendChild(sorted[i])
+        }
+      }
+
+      // Triggers when the tracker sees a device for the first time.
+      function addListener(device) {
+        var row = createRow(device)
+        filterRow(row, device)
+        insertRow(row, device)
+      }
+
+      // Triggers when the tracker notices that a device changed.
+      function changeListener(device) {
+        var id = calculateId(device)
+        var tr = tbody.children[id]
+
+        if (tr) {
+          // First, update columns
+          updateRow(tr, device)
+
+          // Maybe the row is not sorted correctly anymore?
+          var diff = compareRow(tr, device)
+
+          if (diff < 0) {
+            // Should go higher in the list
+            insertRowToSegment(tr, device, 0, tr.rowIndex - 1)
+          }
+          else if (diff > 0) {
+            // Should go lower in the list
+            insertRowToSegment(tr, device, tr.rowIndex + 1, rows.length - 1)
+          }
+        }
+      }
+
+      // Triggers when a device is removed entirely from the tracker.
+      function removeListener(device) {
+        var id = calculateId(device)
+        var tr = tbody.children[id]
+
+        if (tr) {
+          tbody.removeChild(tr)
+        }
+
+        delete mapping[id]
+      }
+
+      tracker.on('add', addListener)
+      tracker.on('change', changeListener)
+      tracker.on('remove', removeListener)
+
+      // Maybe we're already late
+      tracker.devices.forEach(addListener)
+
+      scope.$on('$destroy', function() {
+        tracker.removeListener('add', addListener)
+        tracker.removeListener('change', changeListener)
+        tracker.removeListener('remove', removeListener)
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/device-list/details/device-list-details.css b/crowdstf/res/app/device-list/details/device-list-details.css
new file mode 100644
index 0000000..6fbadd4
--- /dev/null
+++ b/crowdstf/res/app/device-list/details/device-list-details.css
@@ -0,0 +1,17 @@
+.device-list-details-content table {
+  white-space: nowrap;
+}
+
+.device-list-details-content .device-list-product,
+.device-list-details-content .device-list-carrier {
+  white-space: normal;
+}
+
+.device-list-details-content .progress {
+  margin-bottom: 0 !important;
+  height: 17px !important;
+}
+
+.device-list-details-content .device-status.state-available {
+  font-weight: 600;
+}
diff --git a/crowdstf/res/app/device-list/details/device-list-details.jade b/crowdstf/res/app/device-list/details/device-list-details.jade
new file mode 100644
index 0000000..f9cbf40
--- /dev/null
+++ b/crowdstf/res/app/device-list/details/device-list-details.jade
@@ -0,0 +1,6 @@
+table.table.table-hover.dataTable.ng-table
+  thead
+    tr
+      th.header.sortable(ng-repeat='column in columns() track by column.name', ng-if='column.selected', ng-class='["sort-" + (sortedColumns[column.name].order || "none")]', ng-click='sortBy(column, $event.shiftKey)')
+        div(ng-bind='columnDefinitions[column.name].title | translate')
+device-list-empty(tracker='tracker()')
diff --git a/crowdstf/res/app/device-list/details/index.js b/crowdstf/res/app/device-list/details/index.js
new file mode 100644
index 0000000..100fcc1
--- /dev/null
+++ b/crowdstf/res/app/device-list/details/index.js
@@ -0,0 +1,11 @@
+require('./device-list-details.css')
+
+module.exports = angular.module('stf.device-list.details', [
+  require('stf/device').name,
+  require('stf/user/group').name,
+  require('stf/common-ui').name,
+  require('stf/admin-mode').name,
+  require('../column').name,
+  require('../empty').name
+])
+  .directive('deviceListDetails', require('./device-list-details-directive'))
diff --git a/crowdstf/res/app/device-list/device-list-controller.js b/crowdstf/res/app/device-list/device-list-controller.js
new file mode 100644
index 0000000..d1fb19d
--- /dev/null
+++ b/crowdstf/res/app/device-list/device-list-controller.js
@@ -0,0 +1,192 @@
+var QueryParser = require('./util/query-parser')
+
+module.exports = function DeviceListCtrl(
+  $scope
+, DeviceService
+, DeviceColumnService
+, GroupService
+, ControlService
+, SettingsService
+, $location
+) {
+  $scope.tracker = DeviceService.trackAll($scope)
+  $scope.control = ControlService.create($scope.tracker.devices, '*ALL')
+
+  $scope.columnDefinitions = DeviceColumnService
+
+  var defaultColumns = [
+    {
+      name: 'state'
+    , selected: true
+    }
+  , {
+      name: 'model'
+    , selected: true
+    }
+  , {
+      name: 'name'
+    , selected: true
+    }
+  , {
+      name: 'serial'
+    , selected: false
+    }
+  , {
+      name: 'operator'
+    , selected: true
+    }
+  , {
+      name: 'releasedAt'
+    , selected: true
+    }
+  , {
+      name: 'version'
+    , selected: true
+    }
+  , {
+      name: 'network'
+    , selected: false
+    }
+  , {
+      name: 'display'
+    , selected: false
+    }
+  , {
+      name: 'manufacturer'
+    , selected: false
+    }
+  , {
+      name: 'sdk'
+    , selected: false
+    }
+  , {
+      name: 'abi'
+    , selected: false
+    }
+  , {
+      name: 'browser'
+    , selected: false
+    }
+  , {
+      name: 'phone'
+    , selected: false
+    }
+  , {
+      name: 'imei'
+    , selected: false
+    }
+  , {
+      name: 'iccid'
+    , selected: false
+    }
+  , {
+      name: 'batteryHealth'
+    , selected: false
+    }
+  , {
+      name: 'batterySource'
+    , selected: false
+    }
+  , {
+      name: 'batteryStatus'
+    , selected: false
+    }
+  , {
+      name: 'batteryLevel'
+    , selected: false
+    }
+  , {
+      name: 'batteryTemp'
+    , selected: false
+    }
+  , {
+      name: 'provider'
+    , selected: true
+    }
+  , {
+      name: 'notes'
+    , selected: true
+    }
+  , {
+      name: 'owner'
+    , selected: true
+    }
+  ]
+
+  $scope.columns = defaultColumns
+
+  SettingsService.bind($scope, {
+    target: 'columns'
+  , source: 'deviceListColumns'
+  })
+
+  var defaultSort = {
+    fixed: [
+      {
+        name: 'state'
+        , order: 'asc'
+      }
+    ]
+    , user: [
+      {
+        name: 'name'
+        , order: 'asc'
+      }
+    ]
+  }
+
+  $scope.sort = defaultSort
+
+  SettingsService.bind($scope, {
+    target: 'sort'
+  , source: 'deviceListSort'
+  })
+
+  $scope.filter = []
+
+  $scope.activeTabs = {
+    icons: true
+  , details: false
+  }
+
+  SettingsService.bind($scope, {
+    target: 'activeTabs'
+  , source: 'deviceListActiveTabs'
+  })
+
+  $scope.toggle = function(device) {
+    if (device.using) {
+      $scope.kick(device)
+    } else {
+      $location.path('/control/' + device.serial)
+    }
+  }
+
+  $scope.invite = function(device) {
+    return GroupService.invite(device).then(function() {
+      $scope.$digest()
+    })
+  }
+
+  $scope.applyFilter = function(query) {
+    $scope.filter = QueryParser.parse(query)
+  }
+
+  $scope.search = {
+    deviceFilter: '',
+    focusElement: false
+  }
+
+  $scope.focusSearch = function() {
+    if (!$scope.basicMode) {
+      $scope.search.focusElement = true
+    }
+  }
+
+  $scope.reset = function() {
+    $scope.search.deviceFilter = ''
+    $scope.filter = []
+    $scope.sort = defaultSort
+    $scope.columns = defaultColumns
+  }
+}
diff --git a/crowdstf/res/app/device-list/device-list.css b/crowdstf/res/app/device-list/device-list.css
new file mode 100644
index 0000000..1f470df
--- /dev/null
+++ b/crowdstf/res/app/device-list/device-list.css
@@ -0,0 +1,89 @@
+.device-list {
+  color: deepskyblue;
+}
+
+.stf-device-list .filtering-buttons {
+  position: absolute;
+  top: 10px;
+  right: 20px;
+}
+
+.stf-device-list .device-not-usable {
+  color: #bbb
+}
+
+.stf-device-list .device-not-usable img {
+  opacity: 0.8;
+}
+
+.stf-device-list .device-not-usable .btn {
+  cursor: not-allowed;
+}
+
+.stf-device-list .devices-not-available {
+  background: #f6f6f6;
+}
+
+.stf-device-list .line {
+  height: 1px;
+  width: 100%;
+  border-bottom: 1px solid #e2e2e2;
+  margin: 0;
+}
+
+.stf-device-list .device-status {
+  margin-top: 4px;
+}
+
+img.device-icon-smallest {
+  width: 12px;
+  margin-right: 6px;
+}
+
+.stf-device-list .device-small-image {
+  margin-right: 8px;
+  width: 13px;
+  display: inline-block;
+}
+
+.stf-device-list .device-small-image img {
+  height: 20px;
+  min-width: 10px;
+  max-width: 100%;
+}
+
+.stf-device-list .device-browser-list img {
+  width: 18px;
+  height: 18px;
+  margin-right: 3px;
+}
+
+.device-list-active-tabs.ng-enter {
+  -webkit-transition: 250ms;
+  transition: 250ms;
+  opacity: 0;
+}
+
+.device-list-active-tabs.ng-enter-active {
+  opacity: 1;
+}
+
+.stf-device-list .device-product-name-unusable {
+  color: inherit;
+}
+
+.stf-device-list .device-product-name-using {
+  border-bottom: 1px solid; /* leaving out the color inherits text color */
+}
+
+.stf-device-list .device-note {
+}
+
+.stf-device-list .device-note-edit {
+  margin-left: 15px;
+  visibility: hidden;
+}
+
+.stf-device-list .device-note:hover .device-note-edit {
+  visibility: visible;
+}
diff --git a/crowdstf/res/app/device-list/device-list.jade b/crowdstf/res/app/device-list/device-list.jade
new file mode 100644
index 0000000..7833ed8
--- /dev/null
+++ b/crowdstf/res/app/device-list/device-list.jade
@@ -0,0 +1,56 @@
+.stf-device-list
+  .row.stf-stats-container.unselectable
+    .col-md-12
+      device-list-stats(tracker='tracker')
+
+  .row.unselectable
+    .col-md-12
+      .widget-container.fluid-height.stf-device-list-tabs
+        .widget-content.padded
+
+          .filtering-buttons
+            datalist(id='searchFields')
+              select(name='searchFields')
+                option(ng-repeat='column in columns', ng-value='column.name + ": "',
+                ng-bind='columnDefinitions[column.name].title | translate')
+            input(type='search', autosave='deviceFilter'
+              name='deviceFilter', ng-model='search.deviceFilter', ng-change='applyFilter(search.deviceFilter)',
+              ng-model-options='{debounce: 150}'
+              autocorrect='off', autocapitalize='off', spellcheck='false',
+              list='searchFields', multiple, focus-element='search.focusElement',
+              text-focus-select, accesskey='4').form-control.input-sm.device-search.pull-right
+
+            span.pull-right(ng-if='activeTabs.details && !$root.basicMode')
+              .btn-group(uib-dropdown).pull-right
+                button.btn.btn-sm.btn-primary-outline(type='button', uib-dropdown-toggle)
+                  i.fa.fa-columns
+                  span(ng-bind='"Customize"|translate')
+
+                ul.uib-dropdown-menu(role='menu').pointer.stf-device-details-customize
+                  li(ng-repeat='column in columns track by column.name',
+                  ng-hide='!adminMode && columnDefinitions[column.name].admin',
+                  ng-click='$event.stopPropagation()')
+                    label.checkbox.pointer
+                      input(type='checkbox', ng-model='column.selected')
+                      span(ng-bind='columnDefinitions[column.name].title | translate')
+                  li
+                    button(ng-click='reset()').btn.btn-xs.btn-danger-outline
+                      i.fa.fa-trash-o
+                      span(ng-bind='"Reset"|translate')
+
+          uib-tabset.overflow-auto.device-list-active-tabs(ng-if='activeTabs')
+            uib-tab(active='activeTabs.icons', select='focusSearch()')
+              uib-tab-heading
+                i.fa.fa-th-large
+                span(translate) Devices
+              div.device-list-devices-content(ng-if='activeTabs.icons').selectable
+
+                device-list-icons(tracker='tracker', columns='columns', sort='sort', filter='filter')
+
+            uib-tab(active='activeTabs.details', select='focusSearch()', ng-if='!$root.basicMode')
+              uib-tab-heading
+                i.fa.fa-list
+                span(translate) Details
+              div.device-list-details-content(ng-if='activeTabs.details').selectable
+
+                device-list-details(tracker='tracker', columns='columns', sort='sort', filter='filter').selectable
diff --git a/crowdstf/res/app/device-list/empty/device-list-empty-directive.js b/crowdstf/res/app/device-list/empty/device-list-empty-directive.js
new file mode 100644
index 0000000..3cd40dd
--- /dev/null
+++ b/crowdstf/res/app/device-list/empty/device-list-empty-directive.js
@@ -0,0 +1,35 @@
+module.exports = function DeviceListEmptyDirective() {
+  return {
+    restrict: 'E'
+  , template: require('./device-list-empty.jade')
+  , scope: {
+      tracker: '&tracker'
+    }
+  , link: function(scope) {
+      var tracker = scope.tracker()
+
+      scope.empty = !tracker.devices.length
+
+      function update() {
+        var oldEmpty = scope.empty
+        var newEmpty = !tracker.devices.length
+
+        if (oldEmpty !== newEmpty) {
+          scope.$apply(function() {
+            scope.empty = newEmpty
+          })
+        }
+      }
+
+      tracker.on('add', update)
+      tracker.on('change', update)
+      tracker.on('remove', update)
+
+      scope.$on('$destroy', function() {
+        tracker.removeListener('add', update)
+        tracker.removeListener('change', update)
+        tracker.removeListener('remove', update)
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/device-list/empty/device-list-empty.jade b/crowdstf/res/app/device-list/empty/device-list-empty.jade
new file mode 100644
index 0000000..fd95831
--- /dev/null
+++ b/crowdstf/res/app/device-list/empty/device-list-empty.jade
@@ -0,0 +1 @@
+nothing-to-show(message='{{"No devices connected"|translate}}', icon='fa-sitemap', ng-show='empty')
diff --git a/crowdstf/res/app/device-list/empty/index.js b/crowdstf/res/app/device-list/empty/index.js
new file mode 100644
index 0000000..c926f1b
--- /dev/null
+++ b/crowdstf/res/app/device-list/empty/index.js
@@ -0,0 +1,3 @@
+module.exports = angular.module('stf.device-list.empty', [
+])
+  .directive('deviceListEmpty', require('./device-list-empty-directive'))
diff --git a/crowdstf/res/app/device-list/icons/device-list-icons-directive.js b/crowdstf/res/app/device-list/icons/device-list-icons-directive.js
new file mode 100644
index 0000000..1e3d50e
--- /dev/null
+++ b/crowdstf/res/app/device-list/icons/device-list-icons-directive.js
@@ -0,0 +1,547 @@
+var patchArray = require('./../util/patch-array')
+
+module.exports = function DeviceListIconsDirective(
+  $filter
+, gettext
+, DeviceColumnService
+, GroupService
+, StandaloneService
+) {
+  function DeviceItem() {
+    return {
+      build: function() {
+        var li = document.createElement('li')
+        li.className = 'cursor-select pointer thumbnail'
+
+        // the whole li is a link
+        var a = document.createElement('a')
+        li.appendChild(a)
+
+        // .device-photo-small
+        var photo = document.createElement('div')
+        photo.className = 'device-photo-small'
+        var img = document.createElement('img')
+        photo.appendChild(img)
+        a.appendChild(photo)
+
+        // .device-name
+        var name = document.createElement('div')
+        name.className = 'device-name'
+        name.appendChild(document.createTextNode(''))
+        a.appendChild(name)
+
+        // button
+        var button = document.createElement('button')
+        button.appendChild(document.createTextNode(''))
+        a.appendChild(button)
+
+        return li
+      }
+    , update: function(li, device) {
+        var a = li.firstChild
+        var img = a.firstChild.firstChild
+        var name = a.firstChild.nextSibling
+        var nt = name.firstChild
+        var button = name.nextSibling
+        var at = button.firstChild
+        var classes = 'btn btn-xs device-status '
+
+        // .device-photo-small
+        if (img.getAttribute('src') !== device.enhancedImage120) {
+          img.setAttribute('src', device.enhancedImage120)
+        }
+
+        // .device-name
+        nt.nodeValue = device.enhancedName
+
+        // button
+        at.nodeValue = $filter('translate')(device.enhancedStateAction)
+
+        function getStateClasses(state) {
+          var stateClasses = {
+            using: 'state-using btn-primary',
+            busy: 'state-busy btn-warning',
+            available: 'state-available btn-primary-outline',
+            ready: 'state-ready btn-primary-outline',
+            present: 'state-present btn-primary-outline',
+            preparing: 'state-preparing btn-primary-outline btn-success-outline',
+            unauthorized: 'state-unauthorized btn-danger-outline',
+            offline: 'state-offline btn-warning-outline'
+          }[state]
+          if (typeof stateClasses === 'undefined') {
+            stateClasses = 'btn-default-outline'
+          }
+          return stateClasses
+        }
+
+        button.className = classes + getStateClasses(device.state)
+
+        if (device.state === 'available') {
+          name.classList.add('state-available')
+        } else {
+          name.classList.remove('state-available')
+        }
+
+        if (device.usable) {
+          a.href = '#!/control/' + device.serial
+          li.classList.remove('device-is-busy')
+        }
+        else {
+          a.removeAttribute('href')
+          li.classList.add('device-is-busy')
+        }
+
+        return li
+      }
+    }
+  }
+
+  return {
+    restrict: 'E'
+  , template: require('./device-list-icons.jade')
+  , scope: {
+      tracker: '&tracker'
+    , columns: '&columns'
+    , sort: '=sort'
+    , filter: '&filter'
+    }
+  , link: function(scope, element) {
+      var tracker = scope.tracker()
+      var activeColumns = []
+      var activeSorting = []
+      var activeFilters = []
+      var list = element.find('ul')[0]
+      var items = list.childNodes
+      var prefix = 'd' + Math.floor(Math.random() * 1000000) + '-'
+      var mapping = Object.create(null)
+      var builder = DeviceItem()
+
+
+      function kickDevice(device, force) {
+        return GroupService.kick(device, force).catch(function(e) {
+          alert($filter('translate')(gettext('Device cannot get kicked from the group')))
+          throw new Error(e)
+        })
+      }
+
+      function inviteDevice(device) {
+        return GroupService.invite(device).then(function() {
+          scope.$digest()
+        })
+      }
+
+      element.on('click', function(e) {
+
+        var id
+
+        if (e.target.classList.contains('thumbnail')) {
+          id = e.target.id
+        } else if (e.target.classList.contains('device-status') ||
+          e.target.classList.contains('device-photo-small') ||
+          e.target.classList.contains('device-name')) {
+          id = e.target.parentNode.parentNode.id
+        } else if (e.target.parentNode.classList.contains('device-photo-small')) {
+          id = e.target.parentNode.parentNode.parentNode.id
+        }
+
+        if (id) {
+          var device = mapping[id]
+
+          if (e.altKey && device.state === 'available') {
+            inviteDevice(device)
+            e.preventDefault()
+          }
+
+          if (e.shiftKey && device.state === 'available') {
+            StandaloneService.open(device)
+            e.preventDefault()
+          }
+
+          if (device.using) {
+            kickDevice(device)
+            e.preventDefault()
+          }
+        }
+      })
+
+      // Import column definitions
+      scope.columnDefinitions = DeviceColumnService
+
+      // Sorting
+      scope.sortBy = function(column, multiple) {
+        function findInSorting(sorting) {
+          for (var i = 0, l = sorting.length; i < l; ++i) {
+            if (sorting[i].name === column.name) {
+              return sorting[i]
+            }
+          }
+          return null
+        }
+
+        var swap = {
+          asc: 'desc'
+        , desc: 'asc'
+        }
+
+        var fixedMatch = findInSorting(scope.sort.fixed)
+        if (fixedMatch) {
+          fixedMatch.order = swap[fixedMatch.order]
+          return
+        }
+
+        var userMatch = findInSorting(scope.sort.user)
+        if (userMatch) {
+          userMatch.order = swap[userMatch.order]
+          if (!multiple) {
+            scope.sort.user = [userMatch]
+          }
+        }
+        else {
+          if (!multiple) {
+            scope.sort.user = []
+          }
+          scope.sort.user.push({
+            name: column.name
+          , order: scope.columnDefinitions[column.name].defaultOrder || 'asc'
+          })
+        }
+      }
+
+      // Watch for sorting changes
+      scope.$watch(
+        function() {
+          return scope.sort
+        }
+      , function(newValue) {
+          activeSorting = newValue.fixed.concat(newValue.user)
+          scope.sortedColumns = Object.create(null)
+          activeSorting.forEach(function(sort) {
+            scope.sortedColumns[sort.name] = sort
+          })
+          sortAll()
+        }
+      , true
+      )
+
+      // Watch for column updates
+      scope.$watch(
+        function() {
+          return scope.columns()
+        }
+      , function(newValue) {
+          updateColumns(newValue)
+        }
+      , true
+      )
+
+      // Update now so that we don't have to wait for the scope watcher to
+      // trigger.
+      updateColumns(scope.columns())
+
+      // Updates visible columns. This method doesn't necessarily have to be
+      // the fastest because it shouldn't get called all the time.
+      function updateColumns(columnSettings) {
+        var newActiveColumns = []
+
+        // Check what we're supposed to show now
+        columnSettings.forEach(function(column) {
+          if (column.selected) {
+            newActiveColumns.push(column.name)
+          }
+        })
+
+        // Figure out the patch
+        var patch = patchArray(activeColumns, newActiveColumns)
+
+        // Set up new active columns
+        activeColumns = newActiveColumns
+
+        return patchAll(patch)
+      }
+
+      // Updates filters on visible items.
+      function updateFilters(filters) {
+        activeFilters = filters
+        return filterAll()
+      }
+
+      // Applies filteItem() to all items.
+      function filterAll() {
+        for (var i = 0, l = items.length; i < l; ++i) {
+          filterItem(items[i], mapping[items[i].id])
+        }
+      }
+
+      // Filters an item, perhaps removing it from view.
+      function filterItem(item, device) {
+        if (match(device)) {
+          item.classList.remove('filter-out')
+        }
+        else {
+          item.classList.add('filter-out')
+        }
+      }
+
+      // Checks whether the device matches the currently active filters.
+      function match(device) {
+        for (var i = 0, l = activeFilters.length; i < l; ++i) {
+          var filter = activeFilters[i]
+          var column
+          if (filter.field) {
+            column = scope.columnDefinitions[filter.field]
+            if (column && !column.filter(device, filter)) {
+              return false
+            }
+          }
+          else {
+            var found = false
+            for (var j = 0, k = activeColumns.length; j < k; ++j) {
+              column = scope.columnDefinitions[activeColumns[j]]
+              if (column && column.filter(device, filter)) {
+                found = true
+                break
+              }
+            }
+            if (!found) {
+              return false
+            }
+          }
+        }
+        return true
+      }
+
+      // Update now so we're up to date.
+      updateFilters(scope.filter())
+
+      // Watch for filter updates.
+      scope.$watch(
+        function() {
+          return scope.filter()
+        }
+      , function(newValue) {
+          updateFilters(newValue)
+        }
+      , true
+      )
+
+      // Calculates a DOM ID for the device. Should be consistent for the
+      // same device within the same table, but unique among other tables.
+      function calculateId(device) {
+        return prefix + device.serial
+      }
+
+      // Compares two devices using the currently active sorting. Returns <0
+      // if deviceA is smaller, >0 if deviceA is bigger, or 0 if equal.
+      var compare = (function() {
+        var mapping = {
+          asc: 1
+        , desc: -1
+        }
+        return function(deviceA, deviceB) {
+          var diff
+
+          // Find the first difference
+          for (var i = 0, l = activeSorting.length; i < l; ++i) {
+            var sort = activeSorting[i]
+            diff = scope.columnDefinitions[sort.name].compare(deviceA, deviceB)
+            if (diff !== 0) {
+              diff *= mapping[sort.order]
+              break
+            }
+          }
+
+          return diff
+        }
+      })()
+
+      // Creates a completely new item for the device. Means that this is
+      // the first time we see the device.
+      function createItem(device) {
+        var id = calculateId(device)
+        var item = builder.build()
+
+        item.id = id
+        builder.update(item, device)
+        mapping[id] = device
+
+        return item
+      }
+
+      // Patches all items.
+      function patchAll(patch) {
+        for (var i = 0, l = items.length; i < l; ++i) {
+          patchItem(items[i], mapping[items[i].id], patch)
+        }
+      }
+
+      // Patches the given item by running the given patch operations in
+      // order. The operations must take into account index changes caused
+      // by previous operations.
+      function patchItem(/*item, device, patch*/) {
+        // Currently no-op
+      }
+
+      // Updates all the columns in the item. Note that the item must be in
+      // the right format already (built with createItem() and patched with
+      // patchItem() if necessary).
+      function updateItem(item, device) {
+        var id = calculateId(device)
+
+        item.id = id
+        builder.update(item, device)
+
+        return item
+      }
+
+      // Inserts an item into the table into its correct position according to
+      // current sorting.
+      function insertItem(item, deviceA) {
+        return insertItemToSegment(item, deviceA, 0, items.length - 1)
+      }
+
+      // Inserts an item into a segment of the table into its correct position
+      // according to current sorting. The value of `hi` is the index
+      // of the last item in the segment, or -1 if none. The value of `lo`
+      // is the index of the first item in the segment, or 0 if none.
+      function insertItemToSegment(item, deviceA, low, high) {
+        var total = items.length
+        var lo = low
+        var hi = high
+
+        if (lo > hi) {
+          // This means that `lo` refers to the first item of the next
+          // segment (which may or may not exist), and we should put the
+          // row before it.
+          list.insertBefore(item, lo < total ? items[lo] : null)
+        }
+        else {
+          var after = true
+          var pivot = 0
+          var deviceB
+
+          while (lo <= hi) {
+            pivot = ~~((lo + hi) / 2)
+            deviceB = mapping[items[pivot].id]
+
+            var diff = compare(deviceA, deviceB)
+
+            if (diff === 0) {
+              after = true
+              break
+            }
+
+            if (diff < 0) {
+              hi = pivot - 1
+              after = false
+            }
+            else {
+              lo = pivot + 1
+              after = true
+            }
+          }
+
+          if (after) {
+            list.insertBefore(item, items[pivot].nextSibling)
+          }
+          else {
+            list.insertBefore(item, items[pivot])
+          }
+        }
+      }
+
+      // Compares an item to its siblings to see if it's still in the correct
+      // position. Returns <0 if the device should actually go somewhere
+      // before the previous item, >0 if it should go somewhere after the next
+      // item, or 0 if the position is already correct.
+      function compareItem(item, device) {
+        var prev = item.previousSibling
+        var next = item.nextSibling
+        var diff
+
+        if (prev) {
+          diff = compare(device, mapping[prev.id])
+          if (diff < 0) {
+            return diff
+          }
+        }
+
+        if (next) {
+          diff = compare(device, mapping[next.id])
+          if (diff > 0) {
+            return diff
+          }
+        }
+
+        return 0
+      }
+
+      // Sort all items.
+      function sortAll() {
+        // This could be improved by getting rid of the array copying. The
+        // copy is made because items can't be sorted directly.
+        var sorted = [].slice.call(items).sort(function(itemA, itemB) {
+          return compare(mapping[itemA.id], mapping[itemB.id])
+        })
+
+        // Now, if we just append all the elements, they will be in the
+        // correct order in the table.
+        for (var i = 0, l = sorted.length; i < l; ++i) {
+          list.appendChild(sorted[i])
+        }
+      }
+
+      // Triggers when the tracker sees a device for the first time.
+      function addListener(device) {
+        var item = createItem(device)
+        filterItem(item, device)
+        insertItem(item, device)
+      }
+
+      // Triggers when the tracker notices that a device changed.
+      function changeListener(device) {
+        var id = calculateId(device)
+        var item = list.children[id]
+
+        if (item) {
+          // First, update columns
+          updateItem(item, device)
+
+          // Maybe the item is not sorted correctly anymore?
+          var diff = compareItem(item, device)
+          if (diff !== 0) {
+            // Because the item is no longer sorted correctly, we must
+            // remove it so that it doesn't confuse the binary search.
+            // Then we will simply add it back.
+            list.removeChild(item)
+            insertItem(item, device)
+          }
+        }
+      }
+
+      // Triggers when a device is removed entirely from the tracker.
+      function removeListener(device) {
+        var id = calculateId(device)
+        var item = list.children[id]
+
+        if (item) {
+          list.removeChild(item)
+        }
+
+        delete mapping[id]
+      }
+
+      tracker.on('add', addListener)
+      tracker.on('change', changeListener)
+      tracker.on('remove', removeListener)
+
+      // Maybe we're already late
+      tracker.devices.forEach(addListener)
+
+      scope.$on('$destroy', function() {
+        tracker.removeListener('add', addListener)
+        tracker.removeListener('change', changeListener)
+        tracker.removeListener('remove', removeListener)
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/device-list/icons/device-list-icons.css b/crowdstf/res/app/device-list/icons/device-list-icons.css
new file mode 100644
index 0000000..9e84eea
--- /dev/null
+++ b/crowdstf/res/app/device-list/icons/device-list-icons.css
@@ -0,0 +1,88 @@
+ul.devices-icon-view {
+  margin: 0;
+  display: inline-block;
+  list-style-type: none;
+  font-family: 'HelveticaNeue-UltraLight', Helvetica, Arial, sans-serif;
+}
+
+ul.devices-icon-view li {
+  width: 126px;
+  height: 170px;
+  background: white;
+  -webkit-border-radius: 8px;
+  -moz-border-radius: 8px;
+  border-radius: 8px;
+  border: 1px solid #e9e9e9;
+  float: left;
+  clear: none;
+  margin: 6px;
+  text-align: center;
+}
+
+ul.devices-icon-view li:active {
+  -webkit-filter: brightness(95%);
+  filter: brightness(95%);
+}
+
+
+ul.devices-icon-view li:hover {
+  background-color: #fcfcfc;
+}
+
+ul.devices-icon-view li:hover .device-photo-small img {
+  -webkit-filter: brightness(120%);
+  filter: brightness(120%);
+}
+
+ul.devices-icon-view .device-photo-small {
+  margin-top: 8px;
+  margin-bottom: 10px;
+}
+
+ul.devices-icon-view .device-photo-small img {
+  width: auto;
+  height: 90px;
+  max-width: 95%; /* TODO: Fix this with a new container */
+}
+
+ul.devices-icon-view .device-name-bigtext {
+  display: inline-block;
+  width: 80%;
+}
+
+ul.devices-icon-view .device-name {
+  color: #3FA9F5;
+  font-size: 16px;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  white-space: nowrap;
+}
+
+ul.devices-icon-view .device-product {
+  font-size: 16px;
+  color: #555;
+}
+
+ul.devices-icon-view .device-is-busy {
+  opacity: 0.6;
+  cursor: not-allowed;
+  /*-webkit-filter: grayscale(100%);*/
+  /*filter: grayscale(100%);*/
+}
+
+ul.devices-icon-view .device-is-busy .btn {
+  cursor: not-allowed;
+}
+
+ul.devices-icon-view .device-is-busy .device-photo-small img {
+  opacity: 0.5;
+}
+
+ul.devices-icon-view .device-status.state-available {
+  display: none;
+}
+
+ul.devices-icon-view .device-name.state-available {
+  white-space: normal;
+  max-height: 45px;
+}
diff --git a/crowdstf/res/app/device-list/icons/device-list-icons.jade b/crowdstf/res/app/device-list/icons/device-list-icons.jade
new file mode 100644
index 0000000..8733d1b
--- /dev/null
+++ b/crowdstf/res/app/device-list/icons/device-list-icons.jade
@@ -0,0 +1,4 @@
+ul.devices-icon-view
+device-list-empty(tracker='tracker()')
+.clear-fix
+.line
diff --git a/crowdstf/res/app/device-list/icons/index.js b/crowdstf/res/app/device-list/icons/index.js
new file mode 100644
index 0000000..e630ddb
--- /dev/null
+++ b/crowdstf/res/app/device-list/icons/index.js
@@ -0,0 +1,11 @@
+require('./device-list-icons.css')
+
+module.exports = angular.module('stf.device-list.icons', [
+  require('gettext').name,
+  require('stf/user/group').name,
+  require('stf/common-ui').name,
+  require('../column').name,
+  require('../empty').name,
+  require('stf/standalone').name
+])
+  .directive('deviceListIcons', require('./device-list-icons-directive'))
diff --git a/crowdstf/res/app/device-list/index.js b/crowdstf/res/app/device-list/index.js
new file mode 100644
index 0000000..6aa5315
--- /dev/null
+++ b/crowdstf/res/app/device-list/index.js
@@ -0,0 +1,29 @@
+require('./device-list.css')
+
+module.exports = angular.module('device-list', [
+  require('angular-xeditable').name,
+  require('stf/device').name,
+  require('stf/user/group').name,
+  require('stf/control').name,
+  require('stf/common-ui').name,
+  require('stf/settings').name,
+  require('./column').name,
+  require('./details').name,
+  require('./empty').name,
+  require('./icons').name,
+  require('./stats').name,
+  require('./customize').name,
+  require('./search').name
+])
+  .config(['$routeProvider', function($routeProvider) {
+    $routeProvider
+      .when('/devices', {
+        template: require('./device-list.jade'),
+        controller: 'DeviceListCtrl'
+      })
+  }])
+  .run(function(editableOptions) {
+    // bootstrap3 theme for xeditables
+    editableOptions.theme = 'bs3'
+  })
+  .controller('DeviceListCtrl', require('./device-list-controller'))
diff --git a/crowdstf/res/app/device-list/search/device-list-search.css b/crowdstf/res/app/device-list/search/device-list-search.css
new file mode 100644
index 0000000..98f6fb0
--- /dev/null
+++ b/crowdstf/res/app/device-list/search/device-list-search.css
@@ -0,0 +1,9 @@
+.stf-device-list .device-search {
+  width: 20em;
+  -webkit-transition: none;
+  transition: none;
+}
+
+.stf-device-list .filter-out {
+  display: none;
+}
diff --git a/crowdstf/res/app/device-list/search/index.js b/crowdstf/res/app/device-list/search/index.js
new file mode 100644
index 0000000..7921e40
--- /dev/null
+++ b/crowdstf/res/app/device-list/search/index.js
@@ -0,0 +1,4 @@
+require('./device-list-search.css')
+
+module.exports = angular.module('stf.device-list.search', [
+])
diff --git a/crowdstf/res/app/device-list/stats/device-list-stats-directive.js b/crowdstf/res/app/device-list/stats/device-list-stats-directive.js
new file mode 100644
index 0000000..94ac6c6
--- /dev/null
+++ b/crowdstf/res/app/device-list/stats/device-list-stats-directive.js
@@ -0,0 +1,97 @@
+module.exports = function DeviceListStatsDirective(
+  UserService
+) {
+  return {
+    restrict: 'E'
+  , template: require('./device-list-stats.jade')
+  , scope: {
+      tracker: '&tracker'
+    }
+  , link: function(scope, element) {
+      var tracker = scope.tracker()
+      var mapping = Object.create(null)
+      var nodes = Object.create(null)
+
+      scope.counter = {
+        total: 0
+      , usable: 0
+      , busy: 0
+      , using: 0
+      }
+
+      scope.currentUser = UserService.currentUser
+
+      function findTextNodes() {
+        var elements = element[0].getElementsByClassName('counter')
+        for (var i = 0, l = elements.length; i < l; ++i) {
+          nodes[elements[i].getAttribute('data-type')] = elements[i].firstChild
+        }
+      }
+
+      function notify() {
+        nodes.total.nodeValue = scope.counter.total
+        nodes.usable.nodeValue = scope.counter.usable
+        nodes.busy.nodeValue = scope.counter.busy
+        nodes.using.nodeValue = scope.counter.using
+      }
+
+      function updateStats(device) {
+        return (mapping[device.serial] = {
+          usable: device.usable ? 1 : 0
+        , busy: device.owner ? 1 : 0
+        , using: device.using ? 1 : 0
+        })
+      }
+
+      function addListener(device) {
+        var stats = updateStats(device)
+
+        scope.counter.total += 1
+        scope.counter.usable += stats.usable
+        scope.counter.busy += stats.busy
+        scope.counter.using += stats.using
+
+        notify()
+      }
+
+      function changeListener(device) {
+        var oldStats = mapping[device.serial]
+        var newStats = updateStats(device)
+        var diffs = Object.create(null)
+
+        scope.counter.usable += diffs.usable = newStats.usable - oldStats.usable
+        scope.counter.busy += diffs.busy = newStats.busy - oldStats.busy
+        scope.counter.using += diffs.using = newStats.using - oldStats.using
+
+        if (diffs.usable || diffs.busy || diffs.using) {
+          notify()
+        }
+      }
+
+      function removeListener(device) {
+        var oldStats = mapping[device.serial]
+        var newStats = updateStats(device)
+
+        scope.counter.total -= 1
+        scope.counter.busy += newStats.busy - oldStats.busy
+        scope.counter.using += newStats.using - oldStats.using
+
+        delete mapping[device.serial]
+
+        notify()
+      }
+
+      findTextNodes()
+
+      tracker.on('add', addListener)
+      tracker.on('change', changeListener)
+      tracker.on('remove', removeListener)
+
+      scope.$on('$destroy', function() {
+        tracker.removeListener('add', addListener)
+        tracker.removeListener('change', changeListener)
+        tracker.removeListener('remove', removeListener)
+      })
+    }
+  }
+}
diff --git a/crowdstf/res/app/device-list/stats/device-list-stats.css b/crowdstf/res/app/device-list/stats/device-list-stats.css
new file mode 100644
index 0000000..2ede24b
--- /dev/null
+++ b/crowdstf/res/app/device-list/stats/device-list-stats.css
@@ -0,0 +1,78 @@
+.device-stats {
+  min-height: 100px;
+  height: 100px;
+  text-align: center;
+}
+
+.device-stats [class^="col-"],
+.device-stats [class*="col-"] {
+  height: 100%;
+  margin-bottom: 0;
+}
+
+.device-stats [class^="col-"]:last-child,
+.device-stats [class*="col-"]:last-child {
+  border: 0;
+}
+
+.device-stats [class^="col-"] .number,
+.device-stats [class*="col-"] .number {
+  font-size: 3.4em;
+  font-weight: 100;
+  line-height: 1.5em;
+  letter-spacing: -0.06em;
+}
+
+.device-stats [class^="col-"] .number .icon,
+.device-stats [class*="col-"] .number .icon {
+  width: 50px;
+  height: 38px;
+  display: inline-block;
+  vertical-align: top;
+  margin: 20px 12px 0 0;
+}
+
+.device-stats [class^="col-"] .text,
+.device-stats [class*="col-"] .text {
+  font-weight: 300;
+  color: #aeaeae;
+  text-transform: uppercase;
+  font-size: 12px;
+}
+
+.device-stats .fa {
+  font-size: 0.8em;
+}
+
+
+@media (max-width: 600px) {
+  .device-stats {
+    min-height: 60px;
+    height: 60px;
+    text-align: center;
+  }
+
+  .device-stats .fa {
+    font-size: 0.6em;
+  }
+
+  .device-stats [class^="col-"] .number,
+  .device-stats [class*="col-"] .number {
+    font-size: 1.8em;
+    line-height: normal;
+    font-weight: 300;
+  }
+
+  .device-stats [class^="col-"] .number .icon,
+  .device-stats [class*="col-"] .number .icon {
+    width: 25px;
+    height: 19px;
+    margin: 10px 6px 0 0;
+  }
+
+  .device-stats [class^="col-"] .text,
+  .device-stats [class*="col-"] .text {
+    font-size: 0.8em;
+    font-weight: 500;
+  }
+}
diff --git a/crowdstf/res/app/device-list/stats/device-list-stats.jade b/crowdstf/res/app/device-list/stats/device-list-stats.jade
new file mode 100644
index 0000000..f3d3ea5
--- /dev/null
+++ b/crowdstf/res/app/device-list/stats/device-list-stats.jade
@@ -0,0 +1,21 @@
+.widget-container.device-stats
+  .col-xs-3
+    .number.color-blue
+      .icon.fa.fa-globe.visitors
+      span(class='counter', data-type='total') 0
+    .text(translate) Total Devices
+  .col-xs-3
+    .number.color-green
+      .icon.fa.fa-check.visitors
+      span(class='counter', data-type='usable') 0
+    .text(translate) Usable Devices
+  .col-xs-3
+    .number.color-pink
+      .icon.fa.fa-users.visitors
+      span(class='counter', data-type='busy') 0
+    .text(translate) Busy Devices
+  .col-xs-3
+    .number.color-orange
+      .icon.fa.fa-user.visitors(ng-class='{"fa-trophy": $root.adminMode, "fa-user": !$root.adminMode}')
+      span(class='counter', data-type='using') 0
+    .text(ng-bind='currentUser.name')
diff --git a/crowdstf/res/app/device-list/stats/index.js b/crowdstf/res/app/device-list/stats/index.js
new file mode 100644
index 0000000..8c80cc8
--- /dev/null
+++ b/crowdstf/res/app/device-list/stats/index.js
@@ -0,0 +1,6 @@
+require('./device-list-stats.css')
+
+module.exports = angular.module('stf.device-list.stats', [
+  require('stf/user').name
+])
+  .directive('deviceListStats', require('./device-list-stats-directive'))
diff --git a/crowdstf/res/app/device-list/util/patch-array/index.js b/crowdstf/res/app/device-list/util/patch-array/index.js
new file mode 100644
index 0000000..3f5d2c7
--- /dev/null
+++ b/crowdstf/res/app/device-list/util/patch-array/index.js
@@ -0,0 +1,68 @@
+function existenceMap(array) {
+  var map = Object.create(null)
+  for (var i = 0, l = array.length; i < l; ++i) {
+    map[array[i]] = 1
+  }
+  return map
+}
+
+// Returns a list of operations to transform array a into array b. May not
+// return the optimal set of operations.
+module.exports = function patchArray(a, b) {
+  var ops = []
+
+  var workA = [].concat(a)
+  var inA = Object.create(null)
+  var itemA, cursorA, itemB, cursorB
+
+  var inB = existenceMap(b)
+  var posB = Object.create(null)
+
+  // First, check what was removed from a.
+  for (cursorA = 0; cursorA < workA.length;) {
+    itemA = workA[cursorA]
+
+    // If b does not contain the item, surely it must have been removed.
+    if (!inB[itemA]) {
+      workA.splice(cursorA, 1)
+      ops.push(['remove', cursorA])
+    }
+    // Otherwise, the item is still alive.
+    else {
+      inA[itemA] = true
+      cursorA += 1
+    }
+  }
+
+  // Then, check what was inserted into b.
+  for (cursorB = 0; cursorB < b.length; ++cursorB) {
+    itemB = b[cursorB]
+
+    // If a does not contain the item, it must have been added.
+    if (!inA[itemB]) {
+      workA.splice(cursorB, 0, itemB)
+      ops.push(['insert', cursorB, itemB])
+    }
+
+    posB[itemB] = cursorB
+  }
+
+  // At this point, workA contains the same items as b, but swaps may
+  // be needed.
+  for (cursorA = 0; cursorA < workA.length;) {
+    itemA = workA[cursorA]
+    var posInB = posB[itemA]
+
+    if (posInB === cursorA) {
+      cursorA += 1
+    }
+    else {
+      var temp = workA[posInB]
+      workA[posInB] = itemA
+      workA[cursorA] = temp
+      ops.push(['swap', cursorA, posInB])
+    }
+  }
+
+  return ops
+}
diff --git a/crowdstf/res/app/device-list/util/patch-array/patch-array-test.js b/crowdstf/res/app/device-list/util/patch-array/patch-array-test.js
new file mode 100644
index 0000000..dad0a13
--- /dev/null
+++ b/crowdstf/res/app/device-list/util/patch-array/patch-array-test.js
@@ -0,0 +1,125 @@
+/* eslint no-console: 0 */
+
+var assert = require('assert')
+
+var patchArray = require('./patch-array')
+
+var tests = [
+  {
+    a: ['a', 'b', 'c', 'd', 'e']
+  , b: ['a', 'e', 'c', 'd', 'b']
+  , ops: [
+      ['swap', 4, 1]
+    ]
+  }
+, {
+    a: ['a', 'b', 'c', 'd', 'e']
+  , b: ['e', 'd', 'c', 'b', 'a']
+  , ops: [
+      ['swap', 4, 0]
+    , ['swap', 3, 1]
+    ]
+  }
+, {
+    a: ['a', 'b', 'c', 'd', 'e', 'f']
+  , b: ['f', 'e', 'd', 'c', 'b', 'a']
+  , ops: [
+      ['swap', 5, 0]
+    , ['swap', 4, 1]
+    , ['swap', 3, 2]
+    ]
+  }
+, {
+    a: ['a', 'b', 'c', 'd', 'e', 'f']
+  , b: ['f', 'e']
+  , ops: [
+      ['remove', 0]
+    , ['remove', 0]
+    , ['remove', 0]
+    , ['remove', 0]
+    , ['swap', 1, 0]
+    ]
+  }
+, {
+    a: ['a', 'b', 'c', 'd', 'e', 'f']
+  , b: ['f', 'a']
+  , ops: [
+      ['remove', 1]
+    , ['remove', 1]
+    , ['remove', 1]
+    , ['remove', 1]
+    , ['swap', 1, 0]
+    ]
+  }
+, {
+    a: []
+  , b: ['a', 'b', 'c', 'd', 'e', 'f']
+  , ops: [
+      ['insert', 0, 'a']
+    , ['insert', 1, 'b']
+    , ['insert', 2, 'c']
+    , ['insert', 3, 'd']
+    , ['insert', 4, 'e']
+    , ['insert', 5, 'f']
+    ]
+  }
+, {
+    a: ['a', 'd']
+  , b: ['a', 'b', 'c', 'd', 'e', 'f']
+  , ops: [
+      ['insert', 1, 'b']
+    , ['insert', 2, 'c']
+    , ['insert', 4, 'e']
+    , ['insert', 5, 'f']
+    ]
+  }
+, {
+    a: ['b', 'd', 'a']
+  , b: ['a', 'b', 'c', 'd', 'e', 'f']
+  , ops: [
+      ['swap', 2, 0]
+    , ['swap', 2, 1]
+    , ['insert', 2, 'c']
+    , ['insert', 4, 'e']
+    , ['insert', 5, 'f']
+    ]
+  }
+]
+
+function verify(a, b, ops) {
+  var c = [].concat(a)
+  ops.forEach(function(op) {
+    switch (op[0]) {
+    case 'swap':
+      var temp = c[op[1]]
+      c[op[1]] = c[op[2]]
+      c[op[2]] = temp
+      break
+    case 'move':
+      c.splice(op[2] + 1, 0, c[op[1]])
+      c.splice(op[1], 1)
+      break
+    case 'insert':
+      c.splice(op[1], 0, op[2])
+      break
+    case 'remove':
+      c.splice(op[1], 1)
+      break
+    default:
+      throw new Error('Unknown op ' + op[0])
+    }
+  })
+  assert.deepEqual(c, b)
+}
+
+tests.forEach(function(test) {
+  console.log('Running test:')
+  console.log('  <- ', test.a)
+  console.log('  -> ', test.b)
+  console.log('Verifying test expectations')
+  verify(test.a, test.b, test.ops)
+  console.log('Verifying patchArray')
+  var patch = patchArray(test.a, test.b)
+  console.log(' patch', patch)
+  verify(test.a, test.b, patch)
+})
diff --git a/crowdstf/res/app/device-list/util/query-parser/index.js b/crowdstf/res/app/device-list/util/query-parser/index.js
new file mode 100644
index 0000000..81b6bbf
--- /dev/null
+++ b/crowdstf/res/app/device-list/util/query-parser/index.js
@@ -0,0 +1,131 @@
+var State = {
+  TERM_START: 10
+, QUERY_START: 20
+, OP_LT: 30
+, OP_GT: 40
+, QUERY_VALUE_START: 50
+, QUERY_VALUE: 60
+, QUERY_VALUE_DOUBLEQUOTED: 70
+}
+
+function Term() {
+  this.field = null
+  this.op = null
+  this.query = ''
+}
+
+Term.prototype.append = function(input) {
+  this.query += input
+}
+
+Term.prototype.reset = function() {
+  this.query = ''
+}
+
+function QueryParser() {
+  this.terms = []
+  this.currentTerm = new Term()
+  this.state = State.TERM_START
+}
+
+QueryParser.parse = function(input) {
+  var parser = new QueryParser()
+  return parser.parse(input)
+}
+
+QueryParser.prototype.parse = function(input) {
+  var chars = input.split('')
+  for (var i = 0, l = chars.length; i < l; ++i) {
+    this.consume(chars[i])
+  }
+  return this.terms
+}
+
+QueryParser.prototype.consume = function(input) {
+  switch (this.state) {
+  case State.TERM_START:
+    if (this.isWhitespace(input)) {
+      // Preceding whitespace, ignore.
+      return
+    }
+    this.terms.push(this.currentTerm)
+    this.state = State.QUERY_START
+    return this.consume(input)
+  case State.QUERY_START:
+    if (this.isWhitespace(input)) {
+      // Preceding whitespace, ignore.
+      return
+    }
+    if (input === '<') {
+      this.state = State.OP_LT
+      return
+    }
+    if (input === '>') {
+      this.state = State.OP_GT
+      return
+    }
+    this.state = State.QUERY_VALUE_START
+    return this.consume(input)
+  case State.OP_LT:
+    if (input === '=') {
+      this.currentTerm.op = '<='
+      this.state = State.QUERY_VALUE_START
+      return
+    }
+    this.currentTerm.op = '<'
+    this.state = State.QUERY_VALUE_START
+    return this.consume(input)
+  case State.OP_GT:
+    if (input === '=') {
+      this.currentTerm.op = '>='
+      this.state = State.QUERY_VALUE_START
+      return
+    }
+    this.currentTerm.op = '>'
+    this.state = State.QUERY_VALUE_START
+    return this.consume(input)
+  case State.QUERY_VALUE_START:
+    if (this.isWhitespace(input)) {
+      // Preceding whitespace, ignore.
+      return
+    }
+    if (input === '"') {
+      this.state = State.QUERY_VALUE_DOUBLEQUOTED
+      return
+    }
+    this.state = State.QUERY_VALUE
+    return this.consume(input)
+  case State.QUERY_VALUE:
+    if (this.isWhitespace(input)) {
+      return this.concludeTerm()
+    }
+    if (input === ':') {
+      this.currentTerm.field = this.currentTerm.query
+      this.currentTerm.reset()
+      this.state = State.QUERY_START
+      return
+    }
+    this.currentTerm.append(input)
+    return
+  case State.QUERY_VALUE_DOUBLEQUOTED:
+    if (input === '\\') {
+      return
+    }
+    if (input === '"') {
+      return this.concludeTerm()
+    }
+    this.currentTerm.append(input)
+    return
+  }
+}
+
+QueryParser.prototype.concludeTerm = function() {
+  this.currentTerm = new Term()
+  this.state = State.TERM_START
+}
+
+QueryParser.prototype.isWhitespace = function(input) {
+  return input === ' ' || input === '\t' || input === '\n' || input === ''
+}
+
+module.exports = QueryParser
diff --git a/crowdstf/res/app/device-list/util/query-parser/query-parser-test.js b/crowdstf/res/app/device-list/util/query-parser/query-parser-test.js
new file mode 100644
index 0000000..ce9b434
--- /dev/null
+++ b/crowdstf/res/app/device-list/util/query-parser/query-parser-test.js
@@ -0,0 +1,144 @@
+var assert = require('assert')
+
+var QueryParser = require('./index')
+
+var tests = [
+  function() {
+    var parser = new QueryParser()
+    assert.deepEqual(parser.parse('a'), [
+      {
+        field: null
+      , op: null
+      , query: 'a'
+      }
+    ])
+  }
+, function() {
+    var parser = new QueryParser()
+    assert.deepEqual(parser.parse('a b c'), [
+      {
+        field: null
+      , op: null
+      , query: 'a'
+      }
+    , {
+        field: null
+      , op: null
+      , query: 'b'
+      }
+    , {
+        field: null
+      , op: null
+      , query: 'c'
+      }
+    ])
+  }
+, function() {
+    var parser = new QueryParser()
+    assert.deepEqual(parser.parse('serial:foo'), [
+      {
+        field: 'serial'
+      , op: null
+      , query: 'foo'
+      }
+    ])
+  }
+/*
+  This test is currently failing, but I'm not sure if I care enough about it.
+  Commented out for now.
+
+, function() {
+    var parser = new QueryParser()
+    assert.deepEqual(parser.parse('a:b:c'), [
+      {
+        field: 'a'
+      , query: 'b:c'
+      }
+    ])
+  }
+*/
+, function() {
+    var parser = new QueryParser()
+    assert.deepEqual(parser.parse('name:"Galaxy S2 LTE"'), [
+      {
+        field: 'name'
+      , op: null
+      , query: 'Galaxy S2 LTE'
+      }
+    ])
+  }
+, function() {
+    var parser = new QueryParser()
+    assert.deepEqual(parser.parse('name:"Galaxy S2 LTE" black'), [
+      {
+        field: 'name'
+      , op: null
+      , query: 'Galaxy S2 LTE'
+      }
+    , {
+        field: null
+      , op: null
+      , query: 'black'
+      }
+    ])
+  }
+, function() {
+    var parser = new QueryParser()
+    assert.deepEqual(parser.parse('"foo bar"'), [
+      {
+        field: null
+      , op: null
+      , query: 'foo bar'
+      }
+    ])
+  }
+, function() {
+    var parser = new QueryParser()
+    assert.deepEqual(parser.parse('version:>=4.1'), [
+      {
+        field: 'version'
+      , op: '>='
+      , query: '4.1'
+      }
+    ])
+  }
+, function() {
+    var parser = new QueryParser()
+    assert.deepEqual(parser.parse('version: >=4.1'), [
+      {
+        field: 'version'
+      , op: '>='
+      , query: '4.1'
+      }
+    ])
+  }
+, function() {
+    var parser = new QueryParser()
+    assert.deepEqual(parser.parse('version: < 4.1'), [
+      {
+        field: 'version'
+      , op: '<'
+      , query: '4.1'
+      }
+    ])
+  }
+, function() {
+    var parser = new QueryParser()
+    assert.deepEqual(parser.parse('Galaxy operator: DOCOMO'), [
+      {
+        field: null
+      , op: null
+      , query: 'Galaxy'
+      }
+    , {
+        field: 'operator'
+      , op: null
+      , query: 'DOCOMO'
+      }
+    ])
+  }
+]
+
+tests.forEach(function(test) {
+  test()
+})
diff --git a/crowdstf/res/app/docs/docs-controller.js b/crowdstf/res/app/docs/docs-controller.js
new file mode 100644
index 0000000..fd33df1
--- /dev/null
+++ b/crowdstf/res/app/docs/docs-controller.js
@@ -0,0 +1,26 @@
+module.exports =
+  function DocsCtrl($rootScope, $scope, $window, $location) {
+
+    function hasHistory() {
+      return $window.history.length > 1
+    }
+
+    $scope.hasHistory = hasHistory()
+
+    $scope.goBack = function() {
+      $window.history.back()
+    }
+
+    $scope.goHome = function() {
+      $location.path('/docs/Help')
+    }
+
+    /* eslint no-console:0 */
+    $rootScope.$on('$routeChangeError',
+      function(event, current, previous, rejection) {
+        console.error('ROUTE CHANGE ERROR: ' + rejection)
+        console.log('event', event)
+        console.log('current', current)
+        console.log('previous', previous)
+      })
+  }
diff --git a/crowdstf/res/app/docs/docs.css b/crowdstf/res/app/docs/docs.css
new file mode 100644
index 0000000..2e40da4
--- /dev/null
+++ b/crowdstf/res/app/docs/docs.css
@@ -0,0 +1,44 @@
+.stf-docs .widget-container {
+  padding: 10px;
+}
+
+.stf-docs h1 {
+  font-size: 32px;
+  color: #157afb;
+}
+
+.stf-docs h1 {
+  margin-top: 10px;
+}
+
+.stf-docs h2,
+.stf-docs h3 {
+  margin-top: 40px;
+}
+
+.stf-docs p,
+.stf-docs li,
+.stf-docs a {
+  font-size: 15px;
+}
+
+.stf-docs h1:after,
+.stf-docs h2:after,
+.stf-docs h3:after {
+  content: ' ';
+  display: block;
+  margin-top: 10px;
+  margin-bottom: 10px;
+  border: 0;
+  border-top: 1px solid #eee;
+}
+
+.stf-docs-navigation {
+  text-align: center;
+}
+
+.stf-docs .docs-back {
+  position: absolute;
+  left: 10px;
+}
+
diff --git a/crowdstf/res/app/docs/index.js b/crowdstf/res/app/docs/index.js
new file mode 100644
index 0000000..8df212f
--- /dev/null
+++ b/crowdstf/res/app/docs/index.js
@@ -0,0 +1,42 @@
+require('./docs.css')
+
+module.exports = angular.module('stf.help.docs', [
+  require('stf/language').name
+])
+  .config(function($routeProvider, languageProvider) {
+    // TODO: Solutions to the lang problem
+    //
+    // 1) Use $route inside a controller instead of $routeProvider
+    // 2) Use $routeProvider along with languageProvider
+    // In this case languageProvider depends on Settings and gettext
+    // which are not providers
+    // 3) Easiest way is to use AppState
+    // 4) It needs a fallback if the language doesn't exists, it can be made
+    // in Express side.
+
+    $routeProvider
+      .when('/docs/:document*', {
+        templateUrl: function(params) {
+          var lang = languageProvider.$get().selectedLanguage
+          lang = 'en' // Only English for now
+          var document = params.document.replace('.md', '')
+          return '/static/wiki/[' + lang + ']-' + document
+        }
+      })
+      .when('/help', {
+        templateUrl: function() {
+          var lang = languageProvider.$get().selectedLanguage
+          lang = 'en' // Only English for now
+          return '/static/wiki/[' + lang + ']-Help'
+        }
+      })
+      //.when('/docs/:lang/:document*', {
+      //  templateUrl: function (params) {
+      //    var lang = params.lang
+      //    var document = params.document.replace('.md', '')
+      //    return '/static/docs/' + lang + '/' + document
+      //  }
+      //})
+
+  })
+  .controller('DocsCtrl', require('./docs-controller'))
diff --git a/crowdstf/res/app/layout/cursor.css b/crowdstf/res/app/layout/cursor.css
new file mode 100644
index 0000000..13d8cce
--- /dev/null
+++ b/crowdstf/res/app/layout/cursor.css
@@ -0,0 +1,41 @@
+/**
+    Hand pointer
+*/
+.pointer,
+.movement-area-image {
+    cursor: pointer;
+}
+
+/**
+    Default cursor
+*/
+.cursor,
+.console-message-text,
+uib-tab-heading {
+    cursor: default;
+}
+
+/**
+    Text unselectable
+*/
+.unselectable,
+uib-tab-heading {
+    -webkit-touch-callout: none;
+    -webkit-user-select: none;
+    -khtml-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+}
+
+/**
+    Text selectable
+*/
+.selectable {
+    -webkit-touch-callout: default;
+    -webkit-user-select: auto;
+    -khtml-user-select: auto;
+    -moz-user-select: text;
+    -ms-user-select: auto;
+    user-select: auto;
+}
diff --git a/crowdstf/res/app/layout/index.js b/crowdstf/res/app/layout/index.js
new file mode 100644
index 0000000..4ec7fe7
--- /dev/null
+++ b/crowdstf/res/app/layout/index.js
@@ -0,0 +1,23 @@
+require('nine-bootstrap')
+
+require('./cursor.css')
+require('./small.css')
+require('./stf-styles.css')
+
+module.exports = angular.module('layout', [
+  require('stf/landscape').name,
+  require('stf/basic-mode').name,
+  require('ui-bootstrap').name,
+  require('angular-borderlayout').name,
+  require('stf/common-ui').name,
+  require('stf/socket/socket-state').name,
+  require('stf/common-ui/modals/socket-disconnected').name,
+  require('stf/browser-info').name
+])
+  .config(['$uibTooltipProvider', function($uibTooltipProvider) {
+    $uibTooltipProvider.options({
+      appendToBody: true,
+      animation: false
+    })
+  }])
+  .controller('LayoutCtrl', require('./layout-controller'))
diff --git a/crowdstf/res/app/layout/layout-controller.js b/crowdstf/res/app/layout/layout-controller.js
new file mode 100644
index 0000000..64557ff
--- /dev/null
+++ b/crowdstf/res/app/layout/layout-controller.js
@@ -0,0 +1,18 @@
+module.exports =
+  function LayoutCtrl(LanguageService, $rootScope, hotkeys, $filter, gettext) {
+    LanguageService.updateLanguage()
+
+    function toggleAdminMode() {
+      var enabled = $filter('translate')(gettext('Admin mode has been enabled.'))
+      var disabled = $filter('translate')(gettext('Admin mode has been disabled.'))
+
+      $rootScope.adminMode = !$rootScope.adminMode
+
+      alert($rootScope.adminMode ? enabled : disabled)
+    }
+
+    hotkeys.add({
+      combo: 'up up down down left right left right enter',
+      callback: toggleAdminMode
+    })
+  }
diff --git a/crowdstf/res/app/layout/small.css b/crowdstf/res/app/layout/small.css
new file mode 100644
index 0000000..87ed0db
--- /dev/null
+++ b/crowdstf/res/app/layout/small.css
@@ -0,0 +1,53 @@
+.row {
+    margin: 0 5px;
+}
+
+.row + .row {
+    margin-top: 5px;
+}
+
+.nav-tabs > li > a {
+    padding: 5px 10px;
+    margin-right: 0;
+}
+
+.nav-tabs {
+    margin-top: 0;
+}
+
+[class*="col-sm"],
+[class*="col-md"],
+[class*="col-lg"],
+[class*="col-xs"] {
+    margin-bottom: 10px;
+}
+
+
+.row [class^="col-"] {
+    padding: 0 5px;
+}
+
+.btn .btn-xs span {
+    font-size: 12px;
+}
+
+.btn i+span,
+.btn span+span{
+    margin-left: 5px;
+}
+
+.heading > i + span {
+    font-size: 14px;
+}
+
+a uib-tab-heading {
+    font-size: 13px;
+}
+
+a uib-tab-heading i + span {
+    margin-left: 5px;
+}
+
+table.table .btn {
+    margin: 0;
+}
diff --git a/crowdstf/res/app/layout/stf-styles.css b/crowdstf/res/app/layout/stf-styles.css
new file mode 100644
index 0000000..c2abdc2
--- /dev/null
+++ b/crowdstf/res/app/layout/stf-styles.css
@@ -0,0 +1,419 @@
+.page-container {
+  padding: 0 30px;
+  margin: 0 auto;
+}
+
+.stf-container {
+  margin-right: auto;
+  margin-left: auto;
+  padding-left: 15px;
+  padding-right: 15px;
+}
+
+.accordion-body.in:hover {
+  overflow: visible;
+}
+
+.button-spacer {
+  display: inline-block;
+  width: 24px;
+  height: 12px;
+}
+
+/* Overflow */
+
+.overflow-x {
+  overflow-x: auto;
+}
+
+.overflow-y {
+  overflow-y: auto;
+}
+
+.overflow-auto {
+  overflow: auto;
+}
+
+/* Fix btn-group */
+.btn-group.pull-right {
+  margin-right: 10px;
+}
+
+/**
+    Accelerate
+*/
+.force-gpu {
+  -webkit-transform: translateZ(0);
+  -moz-transform: translateZ(0);
+  -ms-transform: translateZ(0);
+  -o-transform: translateZ(0);
+  transform: translateZ(0);
+  -webkit-backface-visibility: hidden;
+  -moz-backface-visibility: hidden;
+  -ms-backface-visibility: hidden;
+  backface-visibility: hidden;
+  -webkit-perspective: 1000;
+  -moz-perspective: 1000;
+  -ms-perspective: 1000;
+  perspective: 1000;
+}
+
+/**
+Colors for awesome fonts
+*/
+.text-status-on {
+  color: green;
+  text-shadow: 0 0 4px rgba(8, 208, 0, 0.3);
+}
+
+.text-status-off {
+  color: lightgrey;
+}
+
+.text-status-error {
+  color: red;
+}
+
+.text-status-waiting {
+  color: #ffcc66;
+}
+
+.text-status-inuse {
+  color: blue;
+}
+
+/**
+   ACE editor
+*/
+
+.stf-ace-editor {
+  height: 150px;
+}
+
+.ace_editor_wrapper {
+  position: relative;
+  height: 180px;
+}
+
+.ace_editor {
+  top: 0;
+  bottom: 0;
+  right: 0;
+  left: 0;
+}
+
+/* Nothing to show */
+
+.nothing-to-show {
+  color: #b7b7b7;
+  min-height: 130px;
+  text-align: center;
+}
+
+.nothing-to-show p {
+  font-size: 20px;
+}
+
+/**
+    General styles
+*/
+.vertical-center {
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-box-pack: center;
+  -webkit-box-align: center;
+  display: -moz-box;
+  -moz-box-orient: vertical;
+  -moz-box-pack: center;
+  -moz-box-align: center;
+  display: box;
+  box-orient: vertical;
+  box-pack: center;
+  box-align: center;
+}
+
+/* Screenshots */
+
+ul.screenshots-icon-view {
+  list-style-type: none;
+  font-family: 'HelveticaNeue-UltraLight', Helvetica, Arial, sans-serif;
+}
+
+ul.screenshots-icon-view li {
+  float: left;
+  clear: none;
+  margin: 8px;
+}
+
+/* Progress */
+.value-next-to-progress {
+  float: left;
+  margin-right: 10px;
+}
+
+.table-progress {
+  margin-bottom: 0;
+}
+
+/* Icons */
+
+.icon-fixed {
+  width: 150px !important;
+}
+
+/* Cookies */
+
+ul.cookies-list {
+  list-style-type: none;
+}
+
+/* Login */
+
+.login-bg {
+  background: #8a6073;
+  background: -moz-linear-gradient(top, #8a6073 0%, #c68779 24%, #637476 57%, #4c7b7d 79%, #658e7d 94%, #6c8c77 97%);
+  background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #8a6073), color-stop(24%, #c68779), color-stop(57%, #637476), color-stop(79%, #4c7b7d), color-stop(94%, #658e7d), color-stop(97%, #6c8c77));
+  background: -webkit-linear-gradient(top, #8a6073 0%, #c68779 24%, #637476 57%, #4c7b7d 79%, #658e7d 94%, #6c8c77 97%);
+  background: -o-linear-gradient(top, #8a6073 0%, #c68779 24%, #637476 57%, #4c7b7d 79%, #658e7d 94%, #6c8c77 97%);
+  background: -ms-linear-gradient(top, #8a6073 0%, #c68779 24%, #637476 57%, #4c7b7d 79%, #658e7d 94%, #6c8c77 97%);
+  background: linear-gradient(to bottom, #8a6073 0%, #c68779 24%, #637476 57%, #4c7b7d 79%, #658e7d 94%, #6c8c77 97%);
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#8a6073', endColorstr='#6c8c77', GradientType=0);
+}
+
+/* d3 lines */
+svg {
+  font: 10px sans-serif;
+}
+
+.line {
+  fill: none;
+  stroke: steelblue;
+  stroke-width: 1.5px;
+}
+
+.axis path,
+.axis line {
+  fill: none;
+  stroke: #000;
+  shape-rendering: crispEdges;
+}
+
+/* Interact Control */
+
+.interact-sidebar {
+  width: 280px;
+}
+
+/* Movement Area */
+.movement-area-container {
+  width: 100%;
+  height: 100%;
+}
+
+.movement-area-image {
+  width: 100%;
+  height: 100%;
+  background-size: contain;
+  background-color: #444;
+  background-repeat: no-repeat;
+  background-position: 50% 50%;
+  position: relative;
+}
+
+.interact-control .btn-toolbar .btn {
+  min-width: 41px;
+}
+
+/* Height */
+.as-table {
+  display: table;
+}
+
+.as-row {
+  display: table-row;
+}
+
+.as-cell {
+  display: table-cell;
+}
+
+.fill-height {
+  height: 100%;
+}
+
+.fill-width {
+  width: 100%;
+}
+
+.fill-auto {
+  height: auto;
+}
+
+.special-keys-buttons button {
+  width: 40px;
+}
+
+.special-keys-buttons .btn-xs button {
+  width: 36px;
+}
+
+.special-keys-dpad-buttons button {
+  width: 40px;
+}
+
+/*
+    Drawer
+*/
+.stf-drawer {
+  background: #ddd;
+
+}
+
+.stf-drawer-bar {
+  background: #aaa;
+}
+
+.stf-drawer-docked-down .stf-drawer-bar {
+  width: 100%;
+  height: 2px;
+  cursor: ns-resize;
+}
+
+.stf-drawer-docked-right .stf-drawer-bar {
+  width: 2px;
+  height: 100%;
+  cursor: ew-resize;
+}
+
+.stf-drawer-docked {
+  opacity: 0.9;
+  z-index: 5000;
+}
+
+.stf-drawer-floating {
+
+}
+
+.stf-drawer-docked-down {
+  width: 100%;
+  height: 300px;
+  bottom: 0;
+  position: absolute;
+}
+
+.stf-drawer-docked-right {
+  width: 300px;
+  height: 100%;
+  right: 0;
+  position: absolute;
+}
+
+.stf-drawer-buttons {
+  text-align: right;
+}
+
+/* For nine-bootstrap */
+
+.btn [class^="fa"],
+.btn [class*="fa"] {
+  margin-right: 0 !important;
+}
+
+.interact-control .navbar {
+  height: auto !important;
+}
+
+.interact-control .navbar-brand {
+  padding: 8px 15px;
+}
+
+.interact-control .btn-group {
+  margin: 0;
+}
+
+/* Make text input on tables be 100% */
+.table td input[type="number"],
+.table td input[type="text"] {
+  width: 100%;
+}
+
+/* Re-reset the table alignment */
+.ng-table th {
+  text-align: left;
+}
+
+.remote-control {
+  background: #888;
+  width: 100%;
+  height: 100%;
+}
+
+.stf-feedback > li > a {
+  font-size: 14px;
+}
+
+.stf-nav-web-native-button {
+  margin-top: 8px !important;
+}
+
+a.active {
+  color: #007aff !important;
+}
+
+.weinre-window {
+  z-index: 10;
+  position: absolute;
+  top: 31px;
+  bottom: 3px;
+  left: 0;
+  right: 0;
+}
+
+/* Hide datalist for non-supporting browsers */
+datalist {
+  display: none;
+}
+
+/* Make auto-fill controls white instead of the default yellow */
+input:-webkit-autofill,
+textarea:-webkit-autofill,
+select:-webkit-autofill {
+  -webkit-box-shadow: 0 0 0 200px white inset !important;
+  box-shadow: 0 0 0 200px white inset !important;
+}
+
+/* Remove transition for input text */
+input {
+  -webkit-transition: none !important;
+  transition: none !important;
+}
+
+/* Transparent border for buttons */
+.transparent-border {
+  border: 1px solid transparent;
+}
+
+/* Bootstrap close button is misaligned for some reason */
+.alert-dismissable .close,
+.alert-dismissible .close {
+  right: auto;
+}
+
+/* Reset alert margin */
+.alert {
+  margin-bottom: 0;
+}
+
+
+/* Form */
+
+textarea.form-control[disabled],
+textarea.form-control[readonly],
+fieldset[disabled] textarea.form-control,
+input[type="text"].form-control[disabled],
+input[type="text"].form-control[readonly],
+fieldset[disabled] input[type="text"].form-control
+{
+  cursor: text;
+}
+
diff --git a/crowdstf/res/app/menu/index.js b/crowdstf/res/app/menu/index.js
new file mode 100644
index 0000000..f7667b4
--- /dev/null
+++ b/crowdstf/res/app/menu/index.js
@@ -0,0 +1,12 @@
+require('./menu.css')
+
+module.exports = angular.module('stf.menu', [
+  require('stf/nav-menu').name,
+  require('stf/settings').name,
+  require('stf/common-ui/modals/external-url-modal').name,
+  require('stf/native-url').name
+])
+  .controller('MenuCtrl', require('./menu-controller'))
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put('menu.jade', require('./menu.jade'))
+  }])
diff --git a/crowdstf/res/app/menu/menu-controller.js b/crowdstf/res/app/menu/menu-controller.js
new file mode 100644
index 0000000..b053b9f
--- /dev/null
+++ b/crowdstf/res/app/menu/menu-controller.js
@@ -0,0 +1,17 @@
+module.exports = function MenuCtrl($scope, $rootScope, SettingsService,
+  $location) {
+
+  SettingsService.bind($scope, {
+    target: 'lastUsedDevice'
+  })
+
+  SettingsService.bind($rootScope, {
+    target: 'platform',
+    defaultValue: 'native'
+  })
+
+  $scope.$on('$routeChangeSuccess', function() {
+    $scope.isControlRoute = $location.path().search('/control') !== -1
+  })
+
+}
diff --git a/crowdstf/res/app/menu/menu-spec.js b/crowdstf/res/app/menu/menu-spec.js
new file mode 100644
index 0000000..b95d71b
--- /dev/null
+++ b/crowdstf/res/app/menu/menu-spec.js
@@ -0,0 +1,17 @@
+describe('MenuCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('MenuCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/menu/menu.css b/crowdstf/res/app/menu/menu.css
new file mode 100644
index 0000000..539700a
--- /dev/null
+++ b/crowdstf/res/app/menu/menu.css
@@ -0,0 +1,67 @@
+.stf-menu .stf-logo {
+  background: url(../../common/logo/exports/STF-512.png) no-repeat 0 0;
+  width: 32px;
+  height: 32px;
+  float: left;
+  text-indent: -9999em;
+  margin: 6px 10px 0 0;
+  background-size: 100% auto;
+  -webkit-background-size: 100% auto;
+}
+
+.stf-menu .stf-top-bar {
+  height: 44px;
+  padding: 0 10px 0 20px;
+  width: 100%;
+  float: left;
+}
+
+.stf-menu .stf-nav {
+  padding-left: 15px;
+  text-align: left;
+  margin: 0;
+  float: left;
+}
+
+.stf-menu .stf-nav > li {
+  padding-right: 15px;
+  float: left;
+  display: inline-block;
+  text-align: left;
+  position: relative;
+  margin: 0;
+}
+
+.stf-menu .stf-nav > li > a {
+  display: inline-block;
+  text-align: left;
+  padding: 0 !important;
+  padding-right: 18px !important;
+  padding-left: 14px !important;
+  font-size: 15px;
+  line-height: 44px;
+  color: #777777;
+  font-weight: 400;
+  height: 44px;
+  position: relative;
+}
+
+.stf-menu .stf-nav > li > a > span.fa {
+  display: inline-block;
+  float: left;
+  margin: 8px 8px 0 0;
+  font-size: 28px;
+}
+
+.stf-menu .stf-nav > li > a > span.fa-mobile {
+  font-size: 30px;
+}
+
+.stf-menu .stf-nav > li > a.current {
+  color: #007aff;
+}
+
+.stf-menu.navbar {
+  height: 44px !important;
+  min-height: 44px !important;
+}
diff --git a/crowdstf/res/app/menu/menu.jade b/crowdstf/res/app/menu/menu.jade
new file mode 100644
index 0000000..17b83e8
--- /dev/null
+++ b/crowdstf/res/app/menu/menu.jade
@@ -0,0 +1,24 @@
+.navbar.stf-menu(ng-controller='MenuCtrl')
+  .container-fluid.stf-top-bar
+    a.stf-logo(ng-href="/#!/devices") STF
+    ul.nav.stf-nav(nav-menu='current').unselectable
+      li(ng-cloak)
+        a(ng-href='/#!/control/{{lastUsedDevice}}', ng-show='lastUsedDevice')
+          span.fa.fa-mobile
+          span(ng-if='!$root.basicMode', translate) Control
+        a(ng-href='/#!/devices', accesskey='1')
+          span.fa.fa-sitemap
+          span(ng-if='!$root.basicMode', translate) Devices
+        a(ng-href='/#!/settings')
+          span.fa.fa-gears
+          span(ng-if='!$root.basicMode', translate) Settings
+    ul.nav.stf-nav.stf-feedback.pull-right(ng-cloak, nav-menu='current').unselectable
+      li.stf-nav-web-native-button(ng-if='!$root.basicMode && isControlRoute')
+        .btn-group
+          button(type='button', ng-model='$root.platform', uib-btn-radio="'web'", translate).btn.btn-sm.btn-default-outline Web
+          button(type='button', ng-model='$root.platform', uib-btn-radio="'native'", translate).btn.btn-sm.btn-default-outline Native
+
+      li(ng-show='!$root.basicMode')
+        a(ng-href='/#!/help', accesskey='6')
+          i.fa.fa-question-circle.fa-fw
+          | {{ "Help" | translate }}
diff --git a/crowdstf/res/app/settings/general/general-controller.js b/crowdstf/res/app/settings/general/general-controller.js
new file mode 100644
index 0000000..aae72a3
--- /dev/null
+++ b/crowdstf/res/app/settings/general/general-controller.js
@@ -0,0 +1,3 @@
+module.exports = function GeneralCtrl() {
+
+}
diff --git a/crowdstf/res/app/settings/general/general-spec.js b/crowdstf/res/app/settings/general/general-spec.js
new file mode 100644
index 0000000..7235cba
--- /dev/null
+++ b/crowdstf/res/app/settings/general/general-spec.js
@@ -0,0 +1,17 @@
+describe('GeneralCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./index').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('GeneralCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/settings/general/general.css b/crowdstf/res/app/settings/general/general.css
new file mode 100644
index 0000000..59dd8ad
--- /dev/null
+++ b/crowdstf/res/app/settings/general/general.css
@@ -0,0 +1,3 @@
+.stf-general {
+
+}
\ No newline at end of file
diff --git a/crowdstf/res/app/settings/general/general.jade b/crowdstf/res/app/settings/general/general.jade
new file mode 100644
index 0000000..d2f5fc5
--- /dev/null
+++ b/crowdstf/res/app/settings/general/general.jade
@@ -0,0 +1,5 @@
+.row
+  .col-md-6
+    div(ng-include='"settings/general/local/local-settings.jade"')
+  .col-md-6
+    div(ng-include='"settings/general/language/language.jade"')
diff --git a/crowdstf/res/app/settings/general/index.js b/crowdstf/res/app/settings/general/index.js
new file mode 100644
index 0000000..5f27cf2
--- /dev/null
+++ b/crowdstf/res/app/settings/general/index.js
@@ -0,0 +1,13 @@
+require('./general.css')
+
+module.exports = angular.module('stf.settings.general', [
+  require('./language').name,
+  require('./local').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put(
+      'settings/general/general.jade'
+      , require('./general.jade')
+    )
+  }])
+  .controller('GeneralCtrl', require('./general-controller'))
diff --git a/crowdstf/res/app/settings/general/language/index.js b/crowdstf/res/app/settings/general/language/index.js
new file mode 100644
index 0000000..eb3d9f6
--- /dev/null
+++ b/crowdstf/res/app/settings/general/language/index.js
@@ -0,0 +1,10 @@
+module.exports = angular.module('stf-ui-language', [
+  require('stf/settings').name,
+  require('stf/language').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put(
+      'settings/general/language/language.jade', require('./language.jade')
+    )
+  }])
+  .controller('LanguageCtrl', require('./language-controller'))
diff --git a/crowdstf/res/app/settings/general/language/language-controller.js b/crowdstf/res/app/settings/general/language/language-controller.js
new file mode 100644
index 0000000..742937b
--- /dev/null
+++ b/crowdstf/res/app/settings/general/language/language-controller.js
@@ -0,0 +1,9 @@
+module.exports = function($scope, LanguageService, SettingsService) {
+  SettingsService.bind($scope, {
+    target: 'language'
+  , source: LanguageService.settingKey
+  , defaultValue: LanguageService.detectedLanguage
+  })
+
+  $scope.supportedLanguages = LanguageService.supportedLanguages
+}
diff --git a/crowdstf/res/app/settings/general/language/language.jade b/crowdstf/res/app/settings/general/language/language.jade
new file mode 100644
index 0000000..94f544f
--- /dev/null
+++ b/crowdstf/res/app/settings/general/language/language.jade
@@ -0,0 +1,8 @@
+.widget-container.fluid-height(ng-controller='LanguageCtrl')
+  .heading
+    i.fa.fa-flag
+    span(translate) Language
+  .widget-content.padded
+    .form-horizontal
+      .form-group
+        select.form-control(ng-model='language', ng-options='key as value for (key, value) in supportedLanguages')
diff --git a/crowdstf/res/app/settings/general/local/index.js b/crowdstf/res/app/settings/general/local/index.js
new file mode 100644
index 0000000..c6b1b78
--- /dev/null
+++ b/crowdstf/res/app/settings/general/local/index.js
@@ -0,0 +1,14 @@
+require('angular-bootstrap')
+
+module.exports = angular.module('ui-local-settings', [
+  require('stf/settings').name,
+  require('stf/common-ui/modals/common').name,
+  'ui.bootstrap'
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put(
+      'settings/general/local/local-settings.jade'
+    , require('./local-settings.jade')
+    )
+  }])
+  .controller('LocalSettingsCtrl', require('./local-settings-controller'))
diff --git a/crowdstf/res/app/settings/general/local/local-settings-controller.js b/crowdstf/res/app/settings/general/local/local-settings-controller.js
new file mode 100644
index 0000000..f42e3d9
--- /dev/null
+++ b/crowdstf/res/app/settings/general/local/local-settings-controller.js
@@ -0,0 +1,7 @@
+module.exports = function($scope, SettingsService) {
+  $scope.resetSettings = function() {
+    SettingsService.reset()
+    alert('Settings cleared')
+  }
+
+}
diff --git a/crowdstf/res/app/settings/general/local/local-settings.jade b/crowdstf/res/app/settings/general/local/local-settings.jade
new file mode 100644
index 0000000..408e66c
--- /dev/null
+++ b/crowdstf/res/app/settings/general/local/local-settings.jade
@@ -0,0 +1,8 @@
+.widget-container.fluid-height(ng-controller='LocalSettingsCtrl')
+  .heading
+    i.fa.fa-save
+    span(translate) Local Settings
+  .widget-content.padded
+    button(ng-click='resetSettings()').btn.btn-danger
+      i.fa.fa-trash-o
+      span(translate) Reset Settings
diff --git a/crowdstf/res/app/settings/index.js b/crowdstf/res/app/settings/index.js
new file mode 100644
index 0000000..a0f4827
--- /dev/null
+++ b/crowdstf/res/app/settings/index.js
@@ -0,0 +1,12 @@
+module.exports = angular.module('ui-settings', [
+  require('./general').name,
+  require('./keys').name,
+  require('stf/common-ui/nice-tabs').name
+  //require('./notifications').name
+])
+  .config(['$routeProvider', function($routeProvider) {
+    $routeProvider.when('/settings', {
+      template: require('./settings.jade')
+    })
+  }])
+  .controller('SettingsCtrl', require('./settings-controller'))
diff --git a/crowdstf/res/app/settings/keys/access-tokens/access-tokens-controller.js b/crowdstf/res/app/settings/keys/access-tokens/access-tokens-controller.js
new file mode 100644
index 0000000..81095bc
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/access-tokens/access-tokens-controller.js
@@ -0,0 +1,31 @@
+module.exports = function AccessTokensCtrl($scope, AccessTokenService) {
+
+    $scope.accessTokenTitles = []
+    $scope.newToken = null
+
+    function updateTokens() {
+      AccessTokenService.getAccessTokens()
+        .success(function(response) {
+          $scope.accessTokenTitles = response.titles || []
+        })
+    }
+
+    $scope.removeToken = function(title) {
+      AccessTokenService.removeAccessToken(title)
+    }
+
+    $scope.closeGenerated = function() {
+      $scope.showGenerated = false
+      $scope.newToken = null
+      updateTokens()
+    }
+
+    $scope.$on('user.keys.accessTokens.generated', function(event, token) {
+      $scope.newToken = token
+      $scope.showGenerated = true
+    })
+
+    $scope.$on('user.keys.accessTokens.updated', updateTokens)
+
+    updateTokens()
+}
diff --git a/crowdstf/res/app/settings/keys/access-tokens/access-tokens-spec.js b/crowdstf/res/app/settings/keys/access-tokens/access-tokens-spec.js
new file mode 100644
index 0000000..cd2a080
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/access-tokens/access-tokens-spec.js
@@ -0,0 +1,17 @@
+describe('AccessTokensCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./index').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('AccessTokensCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/settings/keys/access-tokens/access-tokens.css b/crowdstf/res/app/settings/keys/access-tokens/access-tokens.css
new file mode 100644
index 0000000..da57971
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/access-tokens/access-tokens.css
@@ -0,0 +1,11 @@
+.stf-access-tokens .access-token-generated-okay {
+  display: inline-block;
+}
+
+.stf-access-tokens .token-id-textarea {
+  resize: none;
+  cursor: text;
+  font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
+  font-size: 12px;
+  width: 85%;
+}
diff --git a/crowdstf/res/app/settings/keys/access-tokens/access-tokens.jade b/crowdstf/res/app/settings/keys/access-tokens/access-tokens.jade
new file mode 100644
index 0000000..d157163
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/access-tokens/access-tokens.jade
@@ -0,0 +1,40 @@
+.widget-container.fluid-height.stf-keys.stf-access-tokens(ng-controller='AccessTokensCtrl')
+  .heading
+    i.fa.fa-key
+    span(translate) Access Tokens
+
+    button.btn.pull-right.btn-sm(
+    ng-click='showGenerate = !showGenerate',
+    ng-class='{ "btn-primary-outline": !showGenerate, "btn-primary": showGenerate }')
+      i.fa.fa-plus.fa-fw
+
+    a(ng-href='/#!/docs/Access-Tokens').pull-right.btn.btn-sm
+      i.fa.fa-question-circle(uib-tooltip='{{"More about Access Tokens" | translate}}', tooltip-placement='left')
+
+  .widget-content.padded
+
+    nothing-to-show(icon='fa-key', message='{{"No access tokens" | translate}}',
+    ng-if='!accessTokenTitles.length && !showGenerate && !showGenerated')
+
+    generate-access-token(show-clipboard='true', show-generate='showGenerate')
+
+    div(ng-show='showGenerated')
+      .alert.alert-info.selectable
+        strong(translate) Warning:
+        span &nbsp;
+        span(translate) Make sure to copy your access token now. You won't be able to see it again.
+        br
+        button.btn.pull-right.btn-primary.btn-sm(ng-click='closeGenerated()')
+          i.fa.fa-check.fa-fw
+        textarea(readonly, rows='2', text-focus-select, ng-model='newToken.tokenId').form-control.token-id-textarea
+
+    ul.list-group.key-list
+      li.list-group-item(ng-repeat='title in accessTokenTitles track by $index').animate-repeat
+        a
+          i.fa.fa-key.fa-2x.fa-fw.key-list-icon
+          .key-list-details.selectable
+            .key-list-title(ng-bind='title')
+
+          button.btn.btn-xs.btn-danger-outline.pull-right.key-list-remove(ng-click='removeToken(title)')
+            i.fa.fa-trash-o
+            span(translate) Remove
diff --git a/crowdstf/res/app/settings/keys/access-tokens/index.js b/crowdstf/res/app/settings/keys/access-tokens/index.js
new file mode 100644
index 0000000..4b82a46
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/access-tokens/index.js
@@ -0,0 +1,13 @@
+require('./access-tokens.css')
+
+module.exports = angular.module('stf.settings.keys.access-tokens', [
+  require('stf/common-ui').name,
+  require('stf/tokens').name,
+  require('stf/tokens/generate-access-token').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put(
+      'settings/keys/access-tokens/access-tokens.jade', require('./access-tokens.jade')
+    )
+  }])
+  .controller('AccessTokensCtrl', require('./access-tokens-controller'))
diff --git a/crowdstf/res/app/settings/keys/adb-keys/adb-keys-controller.js b/crowdstf/res/app/settings/keys/adb-keys/adb-keys-controller.js
new file mode 100644
index 0000000..77e3f51
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/adb-keys/adb-keys-controller.js
@@ -0,0 +1,15 @@
+module.exports =
+  function AdbKeysCtrl($scope, $http, UserService) {
+    $scope.adbKeys = []
+
+    function updateKeys() {
+      $scope.adbKeys = UserService.getAdbKeys()
+    }
+
+    $scope.removeKey = function(key) {
+      UserService.removeAdbKey(key)
+    }
+
+    $scope.$on('user.keys.adb.updated', updateKeys)
+    updateKeys()
+  }
diff --git a/crowdstf/res/app/settings/keys/adb-keys/adb-keys-service.js b/crowdstf/res/app/settings/keys/adb-keys/adb-keys-service.js
new file mode 100644
index 0000000..bfc48cb
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/adb-keys/adb-keys-service.js
@@ -0,0 +1,12 @@
+module.exports = function AdbKeysServiceFactory() {
+  var service = {}
+
+  service.hostNameFromKey = function(key) {
+    if (key.match(/.+= (.+)/)) {
+      return key.replace(/.+= (.+)/g, '$1')
+    }
+    return ''
+  }
+
+  return service
+}
diff --git a/crowdstf/res/app/settings/keys/adb-keys/adb-keys-spec.js b/crowdstf/res/app/settings/keys/adb-keys/adb-keys-spec.js
new file mode 100644
index 0000000..720057b
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/adb-keys/adb-keys-spec.js
@@ -0,0 +1,17 @@
+describe('AdbKeysCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./index').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('AdbKeysCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/settings/keys/adb-keys/adb-keys.css b/crowdstf/res/app/settings/keys/adb-keys/adb-keys.css
new file mode 100644
index 0000000..196e568
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/adb-keys/adb-keys.css
@@ -0,0 +1,7 @@
+.stf-adb-keys .key-list-fingerprint {
+  font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
+  font-size: 10px;
+  margin-bottom: 2px;
+  color: #999999;
+  font-weight: 300;
+}
diff --git a/crowdstf/res/app/settings/keys/adb-keys/adb-keys.jade b/crowdstf/res/app/settings/keys/adb-keys/adb-keys.jade
new file mode 100644
index 0000000..1fb1ebc
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/adb-keys/adb-keys.jade
@@ -0,0 +1,31 @@
+.widget-container.fluid-height.stf-keys.stf-adb-keys(ng-controller='AdbKeysCtrl')
+  .heading
+    i.fa.fa-android
+    span(translate) ADB Keys
+
+    button.btn.pull-right.btn-sm(
+    ng-click='showAdd = !showAdd',
+    ng-class='{ "btn-primary-outline": !showAdd, "btn-primary": showAdd }')
+      i.fa.fa-plus.fa-fw
+
+    a(ng-href='/#!/docs/ADB-Keys').pull-right.btn.btn-sm
+      i.fa.fa-question-circle(uib-tooltip='{{"More about ADB Keys" | translate}}', tooltip-placement='left')
+
+  .widget-content.padded
+
+    nothing-to-show(icon='fa-android', message='{{"No ADB keys" | translate}}',
+    ng-if='!adbKeys.length && !showAdd')
+
+    add-adb-key(show-clipboard='true', show-add='showAdd')
+
+    ul.list-group.key-list
+      li.list-group-item(ng-repeat='key in adbKeys').animate-repeat
+        a
+          i.fa.fa-key.fa-2x.fa-fw.key-list-icon
+          .key-list-details.selectable
+            .key-list-title(ng-bind='key.title')
+            .key-list-fingerprint(ng-bind='key.fingerprint')
+
+          button.btn.btn-xs.btn-danger-outline.pull-right.key-list-remove(ng-click='removeKey(key)')
+            i.fa.fa-trash-o
+            span(translate) Remove
diff --git a/crowdstf/res/app/settings/keys/adb-keys/index.js b/crowdstf/res/app/settings/keys/adb-keys/index.js
new file mode 100644
index 0000000..1b904be
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/adb-keys/index.js
@@ -0,0 +1,12 @@
+require('./adb-keys.css')
+
+module.exports = angular.module('stf.settings.keys.adb-keys', [
+  require('stf/common-ui').name,
+  require('stf/keys/add-adb-key').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put(
+      'settings/keys/adb-keys/adb-keys.jade', require('./adb-keys.jade')
+    )
+  }])
+  .controller('AdbKeysCtrl', require('./adb-keys-controller'))
diff --git a/crowdstf/res/app/settings/keys/index.js b/crowdstf/res/app/settings/keys/index.js
new file mode 100644
index 0000000..066293c
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/index.js
@@ -0,0 +1,12 @@
+require('./keys.css')
+
+module.exports = angular.module('stf.settings.keys', [
+  require('./adb-keys').name,
+  require('./access-tokens').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put(
+      'settings/keys/keys.jade', require('./keys.jade')
+    )
+  }])
+  .controller('KeysCtrl', require('./keys-controller'))
diff --git a/crowdstf/res/app/settings/keys/keys-controller.js b/crowdstf/res/app/settings/keys/keys-controller.js
new file mode 100644
index 0000000..54493f0
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/keys-controller.js
@@ -0,0 +1,3 @@
+module.exports = function KeysCtrl() {
+
+}
diff --git a/crowdstf/res/app/settings/keys/keys-spec.js b/crowdstf/res/app/settings/keys/keys-spec.js
new file mode 100644
index 0000000..e9f0239
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/keys-spec.js
@@ -0,0 +1,17 @@
+describe('KeysCtrl', function() {
+
+  beforeEach(angular.mock.module(require('./index').name))
+
+  var scope, ctrl
+
+  beforeEach(inject(function($rootScope, $controller) {
+    scope = $rootScope.$new()
+    ctrl = $controller('KeysCtrl', {$scope: scope})
+  }))
+
+  it('should ...', inject(function() {
+    expect(1).toEqual(1)
+
+  }))
+
+})
diff --git a/crowdstf/res/app/settings/keys/keys.css b/crowdstf/res/app/settings/keys/keys.css
new file mode 100644
index 0000000..f643e60
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/keys.css
@@ -0,0 +1,44 @@
+.stf-keys .key-list-icon {
+
+}
+
+.stf-keys .key-list a {
+  padding: 10px;
+  border-bottom: 1px solid #dddddd;
+}
+
+.stf-keys .key-list-details {
+  display: inline-block;
+}
+
+.stf-keys .key-list-title {
+  color: #007aff;
+  font-size: 14px;
+  font-weight: 300;
+  margin: 2px 0 6px;
+}
+
+.stf-keys .key-list-remove {
+  display: inline-block;
+}
+
+.animate-repeat.ng-move,
+.animate-repeat.ng-enter,
+.animate-repeat.ng-leave {
+  -webkit-transition: all ease-out 150ms;
+  transition: all ease-out 150ms;
+}
+
+.animate-repeat.ng-leave.ng-leave-active,
+.animate-repeat.ng-move,
+.animate-repeat.ng-enter {
+  opacity: 0;
+  max-height: 0;
+}
+
+.animate-repeat.ng-leave,
+.animate-repeat.ng-move.ng-move-active,
+.animate-repeat.ng-enter.ng-enter-active {
+  opacity: 1;
+  max-height: 100px;
+}
diff --git a/crowdstf/res/app/settings/keys/keys.jade b/crowdstf/res/app/settings/keys/keys.jade
new file mode 100644
index 0000000..a5e4ac8
--- /dev/null
+++ b/crowdstf/res/app/settings/keys/keys.jade
@@ -0,0 +1,5 @@
+.row
+  .col-md-6
+    div(ng-include='"settings/keys/access-tokens/access-tokens.jade"')
+  .col-md-6
+    div(ng-include='"settings/keys/adb-keys/adb-keys.jade"')
diff --git a/crowdstf/res/app/settings/notifications/index.js b/crowdstf/res/app/settings/notifications/index.js
new file mode 100644
index 0000000..a28d4ce
--- /dev/null
+++ b/crowdstf/res/app/settings/notifications/index.js
@@ -0,0 +1,11 @@
+module.exports = angular.module('settings-notifications', [
+  require('stf/settings').name
+])
+  .run(['$templateCache', function($templateCache) {
+    $templateCache.put(
+      'settings/notifications/notifications.jade'
+    , require('./notifications.jade')
+    )
+  }])
+  .factory('NotificationsService', require('./notifications-service'))
+  .controller('NotificationsCtrl', require('./notifications-controller'))
diff --git a/crowdstf/res/app/settings/notifications/notifications-controller.js b/crowdstf/res/app/settings/notifications/notifications-controller.js
new file mode 100644
index 0000000..095c80c
--- /dev/null
+++ b/crowdstf/res/app/settings/notifications/notifications-controller.js
@@ -0,0 +1,3 @@
+module.exports = function NotificationsCtrl() {
+
+}
diff --git a/crowdstf/res/app/settings/notifications/notifications-service.js b/crowdstf/res/app/settings/notifications/notifications-service.js
new file mode 100644
index 0000000..460c560
--- /dev/null
+++ b/crowdstf/res/app/settings/notifications/notifications-service.js
@@ -0,0 +1,6 @@
+module.exports = function NotificationsServiceFactory() {
+  var NotificationsService = {}
+
+
+  return NotificationsService
+}
diff --git a/crowdstf/res/app/settings/notifications/notifications.jade b/crowdstf/res/app/settings/notifications/notifications.jade
new file mode 100644
index 0000000..f1e2c84
--- /dev/null
+++ b/crowdstf/res/app/settings/notifications/notifications.jade
@@ -0,0 +1,8 @@
+.widget-container.fluid-height
+  .heading
+    i.fa.fa-exclamation-circle
+    span(translate) Notifications
+  .widget-content.padded
+    label.checkbox
+      input(type='checkbox', ng-model='notificationsEnabled', ng-click='enableNotifications()', disabled)
+      span(translate) Enable notifications
\ No newline at end of file
diff --git a/crowdstf/res/app/settings/settings-controller.js b/crowdstf/res/app/settings/settings-controller.js
new file mode 100644
index 0000000..7dd306e
--- /dev/null
+++ b/crowdstf/res/app/settings/settings-controller.js
@@ -0,0 +1,15 @@
+module.exports = function SettingsCtrl($scope, gettext) {
+
+  $scope.settingTabs = [
+    {
+      title: gettext('General'),
+      icon: 'fa-gears fa-fw',
+      templateUrl: 'settings/general/general.jade'
+    },
+    {
+      title: gettext('Keys'),
+      icon: 'fa-key fa-fw',
+      templateUrl: 'settings/keys/keys.jade'
+    }
+  ]
+}
diff --git a/crowdstf/res/app/settings/settings.jade b/crowdstf/res/app/settings/settings.jade
new file mode 100644
index 0000000..23e8971
--- /dev/null
+++ b/crowdstf/res/app/settings/settings.jade
@@ -0,0 +1,3 @@
+div(pane='center', ng-controller='SettingsCtrl')
+  .widget-container.fluid-height
+    nice-tabs(key='SettingsTabs', tabs='settingTabs', filter='')
diff --git a/crowdstf/res/app/terminal/terminal.css b/crowdstf/res/app/terminal/terminal.css
new file mode 100644
index 0000000..54a3316
--- /dev/null
+++ b/crowdstf/res/app/terminal/terminal.css
@@ -0,0 +1,17 @@
+html, body {
+    padding: 0;
+    margin: 0;
+    cursor: text;
+    background: #000;
+}
+
+.terminal {
+    font-family: Monaco, FreeMono, monospace;
+    color: #fff;
+    font-weight: bold;
+    font-size: 14px;
+    line-height: 14px;
+    -webkit-font-smoothing: antialiased;
+    font-smoothing: antialiased;
+    cursor: text;
+}
diff --git a/crowdstf/res/app/user/index.js b/crowdstf/res/app/user/index.js
new file mode 100644
index 0000000..d8413e3
--- /dev/null
+++ b/crowdstf/res/app/user/index.js
@@ -0,0 +1,9 @@
+module.exports = angular.module('stf.user-profile', [])
+  .config(function($routeProvider) {
+
+    $routeProvider
+      .when('/user/:user*', {
+        template: require('./user.jade')
+      })
+  })
+  .controller('UserProfileCtrl', require('./user-controller'))
diff --git a/crowdstf/res/app/user/user-controller.js b/crowdstf/res/app/user/user-controller.js
new file mode 100644
index 0000000..51ce79e
--- /dev/null
+++ b/crowdstf/res/app/user/user-controller.js
@@ -0,0 +1,5 @@
+module.exports =
+  function UserCtrl() {
+
+
+  }
diff --git a/crowdstf/res/app/user/user.jade b/crowdstf/res/app/user/user.jade
new file mode 100644
index 0000000..8e0a3f5
--- /dev/null
+++ b/crowdstf/res/app/user/user.jade
@@ -0,0 +1 @@
+h1 User
diff --git a/crowdstf/res/app/views/docs.jade b/crowdstf/res/app/views/docs.jade
new file mode 100644
index 0000000..92a105e
--- /dev/null
+++ b/crowdstf/res/app/views/docs.jade
@@ -0,0 +1,15 @@
+//extends layout
+block content
+  .row.stf-docs(ng-controller='DocsCtrl')
+    .col-md-10.col-md-offset-1
+      .widget-container.fluid-height
+        .stf-docs-navigation
+          button.btn.btn-primary-outline.docs-back.transparent-border(ng-click='goBack()',
+          ng-show='hasHistory')
+            i.fa.fa-chevron-left.fa-fw
+          button.btn.btn-primary-outline.docs-home.transparent-border(ng-click='goHome()')
+            i.fa.fa-home.fa-fw
+        .widget-content.padded
+          .row
+            .col-md-10.col-md-offset-1.selectable
+              != markdownFile.parseContent()
diff --git a/crowdstf/res/app/views/index.jade b/crowdstf/res/app/views/index.jade
new file mode 100644
index 0000000..71a565d
--- /dev/null
+++ b/crowdstf/res/app/views/index.jade
@@ -0,0 +1,31 @@
+doctype html
+html(ng-app='app')
+  head
+    meta(charset='utf-8')
+    base(href='/')
+    meta(name='viewport', content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no')
+    meta(name='mobile-web-app-capable', content='yes')
+    meta(name='apple-mobile-web-app-capable', content='yes')
+    meta(name='apple-mobile-web-app-title', content='STF')
+    meta(name='format-detection', content='telephone=no')
+    meta(name='apple-mobile-web-app-status-bar-style', content='black-translucent')
+    link(href='static/logo/exports/STF-128.png', rel='apple-touch-icon')
+
+    title(ng-bind='pageTitle ? "STF - " + pageTitle : "STF"') STF
+  body(ng-cloak).bg-1.fill-height.unselectable
+    div(ng-controller='LayoutCtrl', basic-mode, admin-mode, standalone, landscape).fill-height
+      .pane-top.fill-height(fa-pane)
+        .pane-top-bar(fa-pane, pane-id='menu', pane-anchor='north',
+          pane-size='{{!$root.basicMode && !$root.standalone ? 44 : 0 }}px',
+
+          pane-handle='')
+          div(ng-include='"menu.jade"')
+
+        .pane-center(fa-pane, pane-id='main', pane-anchor='center').fill-height
+          socket-state
+          div(growl)
+          div(ng-view).fill-height
+
+    script(src='/app/api/v1/state.js')
+    script(src='static/app/build/entry/commons.entry.js')
+    script(src='static/app/build/entry/app.entry.js')
diff --git a/crowdstf/res/auth/ldap/scripts/entry.js b/crowdstf/res/auth/ldap/scripts/entry.js
new file mode 100644
index 0000000..15ad6b7
--- /dev/null
+++ b/crowdstf/res/auth/ldap/scripts/entry.js
@@ -0,0 +1,22 @@
+require.ensure([], function(require) {
+  require('nine-bootstrap')
+
+  require('angular')
+  require('angular-route')
+  require('angular-touch')
+
+  angular.module('app', [
+    'ngRoute',
+    'ngTouch',
+    require('gettext').name,
+    require('./signin').name
+  ])
+    .config(function($routeProvider, $locationProvider) {
+      $locationProvider.html5Mode(true)
+      $routeProvider
+        .otherwise({
+          redirectTo: '/auth/ldap/'
+        })
+    })
+
+})
diff --git a/crowdstf/res/auth/ldap/scripts/signin/index.js b/crowdstf/res/auth/ldap/scripts/signin/index.js
new file mode 100644
index 0000000..3f142bc
--- /dev/null
+++ b/crowdstf/res/auth/ldap/scripts/signin/index.js
@@ -0,0 +1,10 @@
+require('./signin.css')
+
+module.exports = angular.module('stf.signin', [])
+  .config(function($routeProvider) {
+    $routeProvider
+      .when('/auth/ldap/', {
+        template: require('./signin.jade')
+      })
+  })
+  .controller('SignInCtrl', require('./signin-controller'))
diff --git a/crowdstf/res/auth/ldap/scripts/signin/signin-controller.js b/crowdstf/res/auth/ldap/scripts/signin/signin-controller.js
new file mode 100644
index 0000000..25aee8d
--- /dev/null
+++ b/crowdstf/res/auth/ldap/scripts/signin/signin-controller.js
@@ -0,0 +1,36 @@
+module.exports = function SignInCtrl($scope, $http) {
+
+  $scope.error = null
+
+  $scope.submit = function() {
+    var data = {
+      username: $scope.signin.username.$modelValue
+      , password: $scope.signin.password.$modelValue
+    }
+    $scope.invalid = false
+    $http.post('/auth/api/v1/ldap', data)
+      .success(function(response) {
+        $scope.error = null
+        location.replace(response.redirect)
+      })
+      .error(function(response) {
+        switch (response.error) {
+          case 'ValidationError':
+            $scope.error = {
+              $invalid: true
+            }
+            break
+          case 'InvalidCredentialsError':
+            $scope.error = {
+              $incorrect: true
+            }
+            break
+          default:
+            $scope.error = {
+              $server: true
+            }
+            break
+        }
+      })
+  }
+}
diff --git a/crowdstf/res/auth/ldap/scripts/signin/signin.css b/crowdstf/res/auth/ldap/scripts/signin/signin.css
new file mode 100644
index 0000000..7531f4e
--- /dev/null
+++ b/crowdstf/res/auth/ldap/scripts/signin/signin.css
@@ -0,0 +1,79 @@
+body {
+    background: #eeeeee;
+}
+
+.login2 {
+    padding: 15px;
+    background: #eeeeee;
+}
+
+.login2 .login-wrapper {
+    max-width: 420px;
+    margin: 0 auto;
+    text-align: center;
+}
+
+.login2 .login-wrapper img {
+    margin: 40px auto;
+}
+
+.login2 .login-wrapper .input-group-addon {
+    padding: 8px 0;
+    background: #f4f4f4;
+    min-width: 48px;
+    text-align: center;
+}
+
+.login2 .login-wrapper .input-group-addon i.falock {
+    font-size: 18px;
+}
+
+.login2 .login-wrapper input.form-control {
+    height: 48px;
+    font-size: 15px;
+    box-shadow: none;
+}
+
+.login2 .login-wrapper .checkbox {
+    margin-bottom: 30px;
+}
+
+.login2 .login-wrapper input[type="submit"] {
+    padding: 10px 0 12px;
+    margin: 20px 0 30px;
+}
+
+.login2 .login-wrapper input[type="submit"]:hover {
+    background: transparent;
+}
+
+.login2 .login-wrapper .social-login {
+    margin-bottom: 20px;
+    padding-bottom: 25px;
+    border-bottom: 1px solid #cccccc;
+}
+
+.login2 .login-wrapper .social-login > .btn {
+    width: 49%;
+    margin: 0;
+}
+
+.login2 .login-wrapper .social-login .facebook {
+    background-color: #335397;
+    border-color: #335397;
+}
+
+.login2 .login-wrapper .social-login .facebook:hover {
+    background-color: transparent;
+    color: #335397;
+}
+
+.login2 .login-wrapper .social-login .twitter {
+    background-color: #00c7f7;
+    border-color: #00c7f7;
+}
+
+.login2 .login-wrapper .social-login .twitter:hover {
+    background-color: transparent;
+    color: #00c7f7;
+}
diff --git a/crowdstf/res/auth/ldap/scripts/signin/signin.jade b/crowdstf/res/auth/ldap/scripts/signin/signin.jade
new file mode 100644
index 0000000..b73d4d4
--- /dev/null
+++ b/crowdstf/res/auth/ldap/scripts/signin/signin.jade
@@ -0,0 +1,30 @@
+.login2(ng-controller='SignInCtrl')
+  .login-wrapper
+    a(href='./')
+      img(width='160', height='160', src='/static/logo/exports/STF-512.png', title='STF')
+
+    form(name='signin', novalidate, ng-submit='submit()')
+      .alert.alert-danger(ng-show='error')
+        span(ng-show='error.$invalid', translate) Check errors below
+        span(ng-show='error.$incorrect', translate) Incorrect login details
+        span(ng-show='error.$server', translate) Server error. Check log output.
+
+      .form-group
+        .input-group
+          span.input-group-addon
+            i.fa.fa-user
+          input.form-control(ng-model='username', name='username', required, type='text', placeholder='LDAP Username',
+          autocorrect='off', autocapitalize='off', spellcheck='false', autocomplete='section-login username')
+        .alert.alert-warning(ng-show='signin.username.$dirty && signin.username.$invalid')
+          span(ng-show='signin.username.$error.required', translate) Please enter your LDAP username
+
+      .form-group
+        .input-group
+          span.input-group-addon
+            i.fa.fa-lock
+          input.form-control(ng-model='password', name='password', required, type='password', placeholder='Password',
+          autocorrect='off', autocapitalize='off', spellcheck='false', autocomplete='section-login current-password')
+        .alert.alert-warning(ng-show='signin.password.$dirty && signin.password.$invalid')
+          span(translate) Please enter your password
+
+      input.btn.btn-lg.btn-primary.btn-block(type='submit', value='Log In')
diff --git a/crowdstf/res/auth/ldap/views/index.jade b/crowdstf/res/auth/ldap/views/index.jade
new file mode 100644
index 0000000..79e47bf
--- /dev/null
+++ b/crowdstf/res/auth/ldap/views/index.jade
@@ -0,0 +1,11 @@
+doctype html
+html(ng-app='app')
+  head
+    title STF
+    base(href='/')
+    meta(charset='utf-8')
+    meta(name='viewport', content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui')
+  body(ng-cloak)
+    div(ng-view)
+    script(src='static/app/build/entry/commons.entry.js')
+    script(src='static/app/build/entry/authldap.entry.js')
diff --git a/crowdstf/res/auth/mock/scripts/entry.js b/crowdstf/res/auth/mock/scripts/entry.js
new file mode 100644
index 0000000..a007f1b
--- /dev/null
+++ b/crowdstf/res/auth/mock/scripts/entry.js
@@ -0,0 +1,21 @@
+require.ensure([], function(require) {
+  require('nine-bootstrap')
+
+  require('angular')
+  require('angular-route')
+  require('angular-touch')
+
+  angular.module('app', [
+    'ngRoute',
+    'ngTouch',
+    require('gettext').name,
+    require('./signin').name
+  ])
+    .config(function($routeProvider, $locationProvider) {
+      $locationProvider.html5Mode(true)
+      $routeProvider
+        .otherwise({
+          redirectTo: '/auth/mock/'
+        })
+    })
+})
diff --git a/crowdstf/res/auth/mock/scripts/signin/index.js b/crowdstf/res/auth/mock/scripts/signin/index.js
new file mode 100644
index 0000000..929fc3b
--- /dev/null
+++ b/crowdstf/res/auth/mock/scripts/signin/index.js
@@ -0,0 +1,10 @@
+require('./signin.css')
+
+module.exports = angular.module('stf.signin', [])
+  .config(function($routeProvider) {
+    $routeProvider
+      .when('/auth/mock/', {
+        template: require('./signin.jade')
+      })
+  })
+  .controller('SignInCtrl', require('./signin-controller'))
diff --git a/crowdstf/res/auth/mock/scripts/signin/signin-controller.js b/crowdstf/res/auth/mock/scripts/signin/signin-controller.js
new file mode 100644
index 0000000..70ce901
--- /dev/null
+++ b/crowdstf/res/auth/mock/scripts/signin/signin-controller.js
@@ -0,0 +1,36 @@
+module.exports = function SignInCtrl($scope, $http) {
+
+  $scope.error = null
+
+  $scope.submit = function() {
+    var data = {
+      name: $scope.signin.username.$modelValue
+      , email: $scope.signin.email.$modelValue
+    }
+    $scope.invalid = false
+    $http.post('/auth/api/v1/mock', data)
+      .success(function(response) {
+        $scope.error = null
+        location.replace(response.redirect)
+      })
+      .error(function(response) {
+        switch (response.error) {
+          case 'ValidationError':
+            $scope.error = {
+              $invalid: true
+            }
+            break
+          case 'InvalidCredentialsError':
+            $scope.error = {
+              $incorrect: true
+            }
+            break
+          default:
+            $scope.error = {
+              $server: true
+            }
+            break
+        }
+      })
+  }
+}
diff --git a/crowdstf/res/auth/mock/scripts/signin/signin.css b/crowdstf/res/auth/mock/scripts/signin/signin.css
new file mode 100644
index 0000000..7531f4e
--- /dev/null
+++ b/crowdstf/res/auth/mock/scripts/signin/signin.css
@@ -0,0 +1,79 @@
+body {
+    background: #eeeeee;
+}
+
+.login2 {
+    padding: 15px;
+    background: #eeeeee;
+}
+
+.login2 .login-wrapper {
+    max-width: 420px;
+    margin: 0 auto;
+    text-align: center;
+}
+
+.login2 .login-wrapper img {
+    margin: 40px auto;
+}
+
+.login2 .login-wrapper .input-group-addon {
+    padding: 8px 0;
+    background: #f4f4f4;
+    min-width: 48px;
+    text-align: center;
+}
+
+.login2 .login-wrapper .input-group-addon i.falock {
+    font-size: 18px;
+}
+
+.login2 .login-wrapper input.form-control {
+    height: 48px;
+    font-size: 15px;
+    box-shadow: none;
+}
+
+.login2 .login-wrapper .checkbox {
+    margin-bottom: 30px;
+}
+
+.login2 .login-wrapper input[type="submit"] {
+    padding: 10px 0 12px;
+    margin: 20px 0 30px;
+}
+
+.login2 .login-wrapper input[type="submit"]:hover {
+    background: transparent;
+}
+
+.login2 .login-wrapper .social-login {
+    margin-bottom: 20px;
+    padding-bottom: 25px;
+    border-bottom: 1px solid #cccccc;
+}
+
+.login2 .login-wrapper .social-login > .btn {
+    width: 49%;
+    margin: 0;
+}
+
+.login2 .login-wrapper .social-login .facebook {
+    background-color: #335397;
+    border-color: #335397;
+}
+
+.login2 .login-wrapper .social-login .facebook:hover {
+    background-color: transparent;
+    color: #335397;
+}
+
+.login2 .login-wrapper .social-login .twitter {
+    background-color: #00c7f7;
+    border-color: #00c7f7;
+}
+
+.login2 .login-wrapper .social-login .twitter:hover {
+    background-color: transparent;
+    color: #00c7f7;
+}
diff --git a/crowdstf/res/auth/mock/scripts/signin/signin.jade b/crowdstf/res/auth/mock/scripts/signin/signin.jade
new file mode 100644
index 0000000..1baa352
--- /dev/null
+++ b/crowdstf/res/auth/mock/scripts/signin/signin.jade
@@ -0,0 +1,31 @@
+.login2(ng-controller='SignInCtrl')
+  .login-wrapper
+    a(href='./')
+      img(width='160', height='160', src='/static/logo/exports/STF-512.png', title='STF')
+
+    form(name='signin', novalidate, ng-submit='submit()')
+      .alert.alert-danger(ng-show='error')
+        span(ng-show='error.$invalid', translate) Check errors below
+        span(ng-show='error.$incorrect', translate) Incorrect login details
+        span(ng-show='error.$server', translate) Server error. Check log output.
+
+      .form-group
+        .input-group
+          span.input-group-addon
+            i.fa.fa-user
+          input.form-control(ng-model='username', name='username', required, type='text', placeholder='Name',
+          autocorrect='off', autocapitalize='off', spellcheck='false', autocomplete='section-login username')
+        .alert.alert-warning(ng-show='signin.username.$dirty && signin.username.$invalid')
+          span(translate) Please enter your name
+
+      .form-group
+        .input-group
+          span.input-group-addon
+            i.fa.fa-envelope
+          input.form-control(ng-model='email', name='email', required, type='email', placeholder='E-mail',
+          autocorrect='off', autocapitalize='off', spellcheck='false', autocomplete='section-login email')
+        .alert.alert-warning(ng-show='signin.email.$dirty && signin.email.$invalid')
+          span(ng-show='signin.email.$error.email', translate) Please enter a valid email
+          span(ng-show='signin.email.$error.required', translate) Please enter your email
+
+      input.btn.btn-lg.btn-primary.btn-block(type='submit', value='Log In')
diff --git a/crowdstf/res/auth/mock/views/index.jade b/crowdstf/res/auth/mock/views/index.jade
new file mode 100644
index 0000000..dc10113
--- /dev/null
+++ b/crowdstf/res/auth/mock/views/index.jade
@@ -0,0 +1,11 @@
+doctype html
+html(ng-app='app')
+  head
+    title STF
+    base(href='/')
+    meta(charset='utf-8')
+    meta(name='viewport', content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui')
+  body(ng-cloak)
+    div(ng-view)
+    script(src='static/app/build/entry/commons.entry.js')
+    script(src='static/app/build/entry/authmock.entry.js')
diff --git a/crowdstf/res/common/lang/index.js b/crowdstf/res/common/lang/index.js
new file mode 100644
index 0000000..a253421
--- /dev/null
+++ b/crowdstf/res/common/lang/index.js
@@ -0,0 +1,13 @@
+angular.module('gettext').run(['gettextCatalog', function(gettextCatalog) {
+  // Load all supported languages
+  angular.forEach(require('./langs'), function(value, key) {
+    if (key !== 'en') {
+      gettextCatalog.setStrings(key,
+        require('./translations/stf.' + key + '.json')[key])
+    }
+  })
+}])
+
+module.exports = angular.module('stf/lang', [
+  'gettext'
+])
diff --git a/crowdstf/res/common/lang/langs.json b/crowdstf/res/common/lang/langs.json
new file mode 100644
index 0000000..cb6429a
--- /dev/null
+++ b/crowdstf/res/common/lang/langs.json
@@ -0,0 +1,11 @@
+{
+  "en": "English",
+  "es": "Español",
+  "fr": "Français",
+  "pl": "Język polski",
+  "ja": "日本語",
+  "zh_CN": "简体中文",
+  "zh-Hant": "繁體中文",
+  "ko_KR": "한국어",
+  "ru_RU": "Русский"
+}
diff --git a/crowdstf/res/common/lang/po/stf.es.po b/crowdstf/res/common/lang/po/stf.es.po
new file mode 100644
index 0000000..bfc9224
--- /dev/null
+++ b/crowdstf/res/common/lang/po/stf.es.po
@@ -0,0 +1,1558 @@
+# 
+# Translators:
+# Gunther Brunner, 2015
+# Gunther Brunner, 2015
+# Luis Calvo <lcalvo@paradigmadigital.com>, 2016
+# takeshimiya <takeshimiya@gmail.com>, 2015
+msgid ""
+msgstr ""
+"Project-Id-Version: STF\n"
+"PO-Revision-Date: 2016-01-27 09:34+0000\n"
+"Last-Translator: Luis Calvo <lcalvo@paradigmadigital.com>\n"
+"Language-Team: Spanish (http://www.transifex.com/openstf/stf/language/es/)\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: es\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: app/components/stf/device/device-info-filter/index.js:119
+#: app/components/stf/device/device-info-filter/index.js:52
+#: app/components/stf/device/device-info-filter/index.js:61
+#: app/components/stf/device/device-info-filter/index.js:71
+msgid "-"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "A new version of STF is available"
+msgstr "Una nueva versión de STF está disponible"
+
+#: app/components/stf/install/install-error-filter.js:26
+msgid "A package is already installed with the same name."
+msgstr "Ya hay un paquete instalado con el mismo nombre"
+
+#: app/components/stf/install/install-error-filter.js:30
+msgid ""
+"A previously installed package of the same name has a different signature "
+"than the new package (and the old package's data was not removed)."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:50
+msgid "A secure container mount point couldn't be accessed on external media."
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:178
+msgid "ABI"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:58
+msgid "AC"
+msgstr ""
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Access Tokens"
+msgstr "Tokens de acceso"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Account"
+msgstr "Cuenta"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Action"
+msgstr "Acción"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Actions"
+msgstr "Acciones"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Activity"
+msgstr "Actividad"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "ADB Keys"
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Add"
+msgstr "Añadir"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add ADB Key"
+msgstr "Añadir Llave de ADB"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add Key"
+msgstr "Añadir Llave"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Add the following ADB Key to STF?"
+msgstr ""
+
+#: app/layout/layout-controller.js:7
+msgid "Admin mode has been disabled."
+msgstr "El modo administrador se ha desactivado"
+
+#: app/layout/layout-controller.js:6
+msgid "Admin mode has been enabled."
+msgstr "El modo administrador se ha activado"
+
+#: app/control-panes/control-panes-controller.js:20
+msgid "Advanced"
+msgstr "Avanzado"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Advanced Input"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Airplane Mode"
+msgstr "Modo avión"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "App Store"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:1
+msgid "App Upload"
+msgstr "Subir aplicación"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Apps"
+msgstr "Aplicaciones"
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:9
+msgid "Are you sure you want to reboot this device?"
+msgstr "¿Estás seguro de querer reiniciar este dispositivo?"
+
+#: app/control-panes/control-panes-controller.js:14
+msgid "Automation"
+msgstr "Automatización"
+
+#: app/components/stf/device/device-info-filter/index.js:28
+msgid "Available"
+msgstr "Disponible"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Back"
+msgstr "Atrás"
+
+#: app/control-panes/info/info.html:1
+msgid "Battery"
+msgstr "Batería"
+
+#: app/device-list/column/device-column-service.js:202
+msgid "Battery Health"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:226
+msgid "Battery Level"
+msgstr "Nivel de batería"
+
+#: app/device-list/column/device-column-service.js:210
+msgid "Battery Source"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:218
+msgid "Battery Status"
+msgstr "Estado de la batería"
+
+#: app/device-list/column/device-column-service.js:239
+msgid "Battery Temp"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:89
+msgid "Bluetooth"
+msgstr "Bluetooth"
+
+#: app/device-list/column/device-column-service.js:153
+msgid "Browser"
+msgstr "Navegador"
+
+#: app/components/stf/device/device-info-filter/index.js:12
+#: app/components/stf/device/device-info-filter/index.js:27
+msgid "Busy"
+msgstr "En uso"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Busy Devices"
+msgstr "Dispositivos en uso"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Camera"
+msgstr "Cámara"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Cancel"
+msgstr "Cancelar"
+
+#: app/components/stf/upload/upload-error-filter.js:6
+msgid "Cannot access specified URL"
+msgstr "No se puedo accecer a la URL especificada"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:43
+msgid "Carrier"
+msgstr ""
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Category"
+msgstr "Categoría"
+
+#: app/components/stf/device/device-info-filter/index.js:67
+msgid "Charging"
+msgstr "Cargando"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Check errors below"
+msgstr "Comprueba los siguientes errores"
+
+#: app/components/stf/common-ui/clear-button/clear-button.html:1
+#: app/control-panes/advanced/run-js/run-js.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Clear"
+msgstr "Limpiar"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Clipboard"
+msgstr "Portapapeles"
+
+#: app/components/stf/device/device-info-filter/index.js:46
+msgid "Cold"
+msgstr "Frío"
+
+#: app/components/stf/device/device-info-filter/index.js:21
+#: app/components/stf/device/device-info-filter/index.js:6
+#: app/control-panes/info/info.html:1
+msgid "Connected"
+msgstr "Conectado"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:20
+msgid "Connected successfully."
+msgstr "Conectado con éxito"
+
+#: app/menu/menu.html:1
+msgid "Control"
+msgstr "Control"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Cookies"
+msgstr "Cookies"
+
+#: app/control-panes/info/info.html:1
+msgid "Cores"
+msgstr "Núcleos"
+
+#: app/control-panes/info/info.html:1
+#: app/control-panes/performance/cpu/cpu.html:1
+msgid "CPU"
+msgstr "CPU"
+
+#: app/device-list/device-list.html:1
+msgid "Customize"
+msgstr "Personalizar"
+
+#: app/control-panes/advanced/input/input.html:12
+msgid "D-pad Center"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:20
+msgid "D-pad Down"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:9
+msgid "D-pad Left"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:15
+msgid "D-pad Right"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:4
+msgid "D-pad Up"
+msgstr ""
+
+#: app/control-panes/control-panes-controller.js:41
+msgid "Dashboard"
+msgstr "Tablero"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Data"
+msgstr "Datos"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Date"
+msgstr "Fecha"
+
+#: app/components/stf/device/device-info-filter/index.js:48
+msgid "Dead"
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Delete"
+msgstr "Borrar"
+
+#: app/control-panes/info/info.html:1
+msgid "Density"
+msgstr "Densidad"
+
+#: app/device-list/device-list.html:1
+msgid "Details"
+msgstr "Detalles"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Developer"
+msgstr "Desarrollador"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/inspect/inspect.html:1
+msgid "Device"
+msgstr "Dispositivo"
+
+#: app/device-list/details/device-list-details-directive.js:39
+#: app/device-list/icons/device-list-icons-directive.js:123
+msgid "Device cannot get kicked from the group"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:38
+msgid "Device is not present anymore for some reason."
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:39
+msgid "Device is present but offline."
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Device Photo"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Device Settings"
+msgstr "Configuración de Dispositivo"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+msgid "Device was disconnected"
+msgstr "El dispositivo se ha desconectado"
+
+#: app/components/stf/device/device-info-filter/index.js:37
+msgid "Device was kicked by automatic timeout."
+msgstr ""
+
+#: app/device-list/device-list.html:1 app/menu/menu.html:1
+msgid "Devices"
+msgstr "Dispositivos"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Disable WiFi"
+msgstr "Deshabilitar WIFI"
+
+#: app/components/stf/device/device-info-filter/index.js:68
+msgid "Discharging"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+#: app/components/stf/device/device-info-filter/index.js:20
+#: app/components/stf/device/device-info-filter/index.js:5
+msgid "Disconnected"
+msgstr "Desconectado"
+
+#: app/control-panes/info/info.html:1
+msgid "Display"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Drop file to upload"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:90
+msgid "Dummy"
+msgstr ""
+
+#: app/settings/notifications/notifications.html:1
+msgid "Enable notifications"
+msgstr "Habilitar notificaciones"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Enable WiFi"
+msgstr "Habilitar WIFI"
+
+#: app/control-panes/info/info.html:1
+msgid "Encrypted"
+msgstr "Encriptado"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:31
+msgid "Error"
+msgstr "Error"
+
+#: app/components/stf/control/control-service.js:129
+msgid "Error while getting data"
+msgstr ""
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:35
+msgid "Error while reconnecting"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:91
+msgid "Ethernet"
+msgstr "Ethernet"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Executes remote shell commands"
+msgstr ""
+
+#: app/components/stf/upload/upload-error-filter.js:5
+msgid "Failed to download file"
+msgstr "Fallo al descargar el fichero"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Fast Forward"
+msgstr ""
+
+#: app/control-panes/control-panes-controller.js:26
+msgid "File Explorer"
+msgstr "Explorador de fichero"
+
+#: app/components/stf/common-ui/filter-button/filter-button.html:1
+msgid "Filter"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Find Device"
+msgstr "Encontrar dispositivo"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Fingerprint"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "FPS"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Frequency"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:69
+msgid "Full"
+msgstr ""
+
+#: app/settings/settings-controller.js:5
+msgid "General"
+msgstr "General"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate Access Token"
+msgstr ""
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Generate Login for VNC"
+msgstr ""
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate New Token"
+msgstr "Generar nuevo token"
+
+#: app/control-panes/logs/logs.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "Get"
+msgstr ""
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Get clipboard contents"
+msgstr ""
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Back"
+msgstr ""
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Forward"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:89
+msgid "Go to Device List"
+msgstr "Ir a la lista de dispositivos"
+
+#: app/components/stf/device/device-info-filter/index.js:47
+msgid "Good"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Hardware"
+msgstr "Hardware"
+
+#: app/control-panes/info/info.html:1
+msgid "Health"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Height"
+msgstr "Ancho"
+
+#: app/menu/menu.html:1
+msgid "Help"
+msgstr "Ayuda"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Hide Screen"
+msgstr "Ocultar pantalla"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Home"
+msgstr "Home"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Host"
+msgstr ""
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Hostname"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "ICCID"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "ID"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "IMEI"
+msgstr "IMEI"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Incorrect login details"
+msgstr ""
+
+#: app/control-panes/control-panes-controller.js:32
+msgid "Info"
+msgstr "Información"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspect Device"
+msgstr "Inspeccionar dispositivo"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspecting is currently only supported in WebView"
+msgstr ""
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspector"
+msgstr "Inspector"
+
+#: app/components/stf/install/install-error-filter.js:13
+msgid "Installation canceled by user."
+msgstr "Instalación cancelada por el usuario"
+
+#: app/components/stf/install/install-error-filter.js:9
+msgid "Installation failed due to an unknown error."
+msgstr "La instalación falló debido a un error desconocido"
+
+#: app/components/stf/install/install-error-filter.js:7
+msgid "Installation succeeded."
+msgstr "Instalado con éxito"
+
+#: app/components/stf/install/install-error-filter.js:11
+msgid "Installation timed out."
+msgstr "La instalación superó el tiempo de espera"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Installing app..."
+msgstr "Instalando aplicación..."
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Key"
+msgstr ""
+
+#: app/settings/settings-controller.js:10
+msgid "Keys"
+msgstr ""
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Landscape"
+msgstr ""
+
+#: app/settings/general/language/language.html:1
+msgid "Language"
+msgstr "Idioma"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Launch Activity"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Launching activity..."
+msgstr ""
+
+#: app/control-panes/info/info.html:1 app/control-panes/logs/logs.html:1
+msgid "Level"
+msgstr "Nivel"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Local Settings"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:250
+msgid "Location"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:7
+msgid "Lock Rotation"
+msgstr "Bloquear rotación"
+
+#: app/control-panes/control-panes-controller.js:50
+msgid "Logs"
+msgstr ""
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Maintenance"
+msgstr "Mantenimiento"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid ""
+"Make sure to copy your access token now. You won't be able to see it again!"
+msgstr "Asegúrate de copiar tu token de acceso ahora. ¡No podrás volver a verlo más!"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Manage Apps"
+msgstr "Gestionar aplicaciones"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Manner Mode"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:165
+msgid "Manufacturer"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Media"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Memory"
+msgstr "Memoria"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Menu"
+msgstr "Menú"
+
+#: app/components/stf/device/device-info-filter/index.js:92
+msgid "Mobile"
+msgstr "Móvil"
+
+#: app/components/stf/device/device-info-filter/index.js:93
+msgid "Mobile DUN"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:94
+msgid "Mobile High Priority"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:95
+msgid "Mobile MMS"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:96
+msgid "Mobile SUPL"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:31
+msgid "Model"
+msgstr "Modelo"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "More about Access Tokens"
+msgstr "Más sobre Tokens de acceso"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "More about ADB Keys"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Mute"
+msgstr "Silencio"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Name"
+msgstr "Nombre"
+
+#: app/menu/menu.html:1
+msgid "Native"
+msgstr "Nativo"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Navigation"
+msgstr "Navegación"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:129
+msgid "Network"
+msgstr "Red"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Next"
+msgstr "Siguiente"
+
+#: app/components/stf/device/device-info-filter/index.js:116
+msgid "No"
+msgstr "No"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "No access tokens"
+msgstr "Sin tokens de acceso"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "No ADB keys"
+msgstr ""
+
+#: app/components/stf/control/control-service.js:126
+msgid "No clipboard data"
+msgstr "No hay datos en el portapapeles"
+
+#: app/control-panes/resources/resources.html:1
+msgid "No cookies to show"
+msgstr "No hay cookies que mostrar"
+
+#: app/components/stf/screen/screen.html:1
+msgid "No device screen"
+msgstr ""
+
+#: app/device-list/empty/device-list-empty.html:1
+msgid "No devices connected"
+msgstr "No hay dispositivos conectados"
+
+#: app/components/stf/common-ui/modals/lightbox-image/lightbox-image.html:1
+msgid "No photo available"
+msgstr "No hay imagen disponible"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "No Ports Forwarded"
+msgstr ""
+
+#: app/control-panes/screenshots/screenshots.html:5
+msgid "No screenshots taken"
+msgstr "No hay capturas de pantalla"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Normal Mode"
+msgstr "Modo normal"
+
+#: app/components/stf/device/device-info-filter/index.js:70
+msgid "Not Charging"
+msgstr "No se está cargando"
+
+#: app/device-list/column/device-column-service.js:256
+msgid "Notes"
+msgstr "Notas"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Nothing to inspect"
+msgstr "No hay nada que inspeccionar"
+
+#: app/settings/notifications/notifications.html:1
+msgid "Notifications"
+msgstr "Notificaciones"
+
+#: app/control-panes/info/info.html:1
+msgid "Number"
+msgstr "Número"
+
+#: app/components/stf/device/device-info-filter/index.js:22
+#: app/components/stf/device/device-info-filter/index.js:7
+msgid "Offline"
+msgstr "Offline"
+
+#: app/components/stf/common-ui/error-message/error-message.html:1
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Oops!"
+msgstr "¡Ups!"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Open"
+msgstr "Abrir"
+
+#: app/control-panes/info/info.html:1
+msgid "Orientation"
+msgstr "Orientación"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:55
+msgid "OS"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:49
+msgid "Over Voltage"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:50
+msgid "Overheat"
+msgstr ""
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Package"
+msgstr "Paquete"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Password"
+msgstr "Contraseña"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Permissions"
+msgstr "Permisos"
+
+#: app/device-list/column/device-column-service.js:184
+msgid "Phone"
+msgstr "Teléfono"
+
+#: app/device-list/column/device-column-service.js:196
+msgid "Phone ICCID"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:190
+msgid "Phone IMEI"
+msgstr "IMEI del teléfono"
+
+#: app/control-panes/info/info.html:1
+msgid "Physical Device"
+msgstr "Dispositivo físico"
+
+#: app/control-panes/logs/logs.html:1
+msgid "PID"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Place"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Platform"
+msgstr "Plataforma"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Play/Pause"
+msgstr "Inicio/Pausa"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter a valid email"
+msgstr "Por favor, introduce un email válido"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your email"
+msgstr "Por favor, introduce tu email"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your LDAP username"
+msgstr "Por favor, introduce tu usuario de LDAP"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your name"
+msgstr "Por favor, introduce tu nombre"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your password"
+msgstr "Por favor, introduce tu contraseña"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store password"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store username"
+msgstr ""
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Port"
+msgstr "Puerto"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Port Forwarding"
+msgstr ""
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Portrait"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Power"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Power Source"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:24
+#: app/components/stf/device/device-info-filter/index.js:9
+msgid "Preparing"
+msgstr "Preparando"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:104
+msgid "Press Back button"
+msgstr "Pulsa el botón Volver"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:103
+msgid "Press Home button"
+msgstr "Pulsa el botón Home"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:102
+msgid "Press Menu button"
+msgstr "Pulsa el botón Menú"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Previous"
+msgstr "Anterior"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Processing..."
+msgstr "Procesando..."
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:37
+msgid "Product"
+msgstr "Producto"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Pushing app..."
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "RAM"
+msgstr "RAM"
+
+#: app/components/stf/device/device-info-filter/index.js:10
+#: app/components/stf/device/device-info-filter/index.js:25
+msgid "Ready"
+msgstr "Listo"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:39
+msgid "Reconnected successfully."
+msgstr ""
+
+#: app/components/stf/common-ui/refresh-page/refresh-page.html:1
+msgid "Refresh"
+msgstr "Actualizar"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:49
+msgid "Released"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reload"
+msgstr "Recargar"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug.html:1
+msgid "Remote debug"
+msgstr "Conexión remota"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "Remove"
+msgstr "Eliminar"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+#: app/device-list/device-list.html:1
+msgid "Reset"
+msgstr "Reiniciar"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reset all browser settings"
+msgstr "Restablecer todos los ajustes del navegador"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Reset Settings"
+msgstr "Restablecer ajustes"
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Restart Device"
+msgstr "Reiniciar dispositivo"
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retrieving the device screen has timed out."
+msgstr ""
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retry"
+msgstr "Reintentar"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Rewind"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Roaming"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "ROM"
+msgstr "ROM"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:92
+msgid "Rotate Left"
+msgstr "Rotar a la izquierda"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:93
+msgid "Rotate Right"
+msgstr "Rotar a la derecha"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run"
+msgstr "Ejecutar"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run JavaScript"
+msgstr "Ejecutar JavaScript"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:31
+msgid ""
+"Run the following on your command line to debug the device from your Browser"
+msgstr ""
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:28
+msgid ""
+"Run the following on your command line to debug the device from your IDE"
+msgstr ""
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Run this command to copy the key to your clipboard"
+msgstr "Ejecuta este comando para copiar la clave al portapapeles"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+msgid "Save ScreenShot"
+msgstr "Guardar captura de pantalla"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Save..."
+msgstr "Guardar"
+
+#: app/device-list/column/device-column-service.js:135
+msgid "Screen"
+msgstr "Pantalla"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Screenshot"
+msgstr "Captura de pantalla"
+
+#: app/control-panes/control-panes-controller.js:8
+msgid "Screenshots"
+msgstr "Capturas de Pantalla"
+
+#: app/control-panes/info/info.html:1
+msgid "SD Card Mounted"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:171
+msgid "SDK"
+msgstr "SDK"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Search"
+msgstr "Buscar"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:91
+msgid "Selects Next IME"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:159
+msgid "Serial"
+msgstr "Serie"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Server"
+msgstr "Servidor"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Server error. Check log output."
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set"
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set Cookie"
+msgstr ""
+
+#: app/control-panes/dashboard/apps/apps.html:1 app/menu/menu.html:1
+msgid "Settings"
+msgstr "Configuración"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Shell"
+msgstr "Línea de Comandos"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Show Screen"
+msgstr "Mostar pantalla"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign In"
+msgstr "Acceder"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign Out"
+msgstr "Desconectar"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Silent Mode"
+msgstr "Modo silencio"
+
+#: app/control-panes/info/info.html:1
+msgid "SIM"
+msgstr "SIM"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Size"
+msgstr "Tamaño"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:26
+msgid "Socket connection was lost"
+msgstr "Se perdió la conexión con el socket"
+
+#: app/components/stf/device/device-info-filter/index.js:36
+msgid "Someone stole your device."
+msgstr "Alguien robó tu dispositivo"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Special Keys"
+msgstr "Teclas especiales"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Start/Stop Logging"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:25
+msgid "Status"
+msgstr "Estado"
+
+#: app/control-panes/advanced/input/input.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Stop"
+msgstr "Parar"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/components/stf/device/device-info-filter/index.js:11
+msgid "Stop Using"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Store Account"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Sub Type"
+msgstr "Subtipo"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Switch Charset"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "Tag"
+msgstr "Etiqueta"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Pageshot (Needs WebView running)"
+msgstr ""
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Screenshot"
+msgstr "Capturar pantalla"
+
+#: app/control-panes/info/info.html:1
+msgid "Temperature"
+msgstr "Temperatura"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Text"
+msgstr "Texto"
+
+#: app/components/stf/screen/screen.html:1
+msgid "The current view is marked secure and cannot be viewed remotely."
+msgstr "La vista actual está marcada como segura y no puede ser vista de forma remota"
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:10
+msgid "The device will be unavailable for a moment."
+msgstr "El dispositivo no estará disponible durante unos instantes"
+
+#: app/components/stf/install/install-error-filter.js:34
+msgid "The existing package could not be deleted."
+msgstr "El paquete no se pudo eliminar"
+
+#: app/components/stf/install/install-error-filter.js:58
+msgid ""
+"The new package couldn't be installed because the verification did not "
+"succeed."
+msgstr "El nuevo paquete no se pudo instalar porque no se pudo verificar"
+
+#: app/components/stf/install/install-error-filter.js:56
+msgid ""
+"The new package couldn't be installed because the verification timed out."
+msgstr "El nuevo paquete no se pudo instalar porque se excedió el tiempo de espera al verificarlo"
+
+#: app/components/stf/install/install-error-filter.js:54
+msgid ""
+"The new package couldn't be installed in the specified install location "
+"because the media is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:52
+msgid ""
+"The new package couldn't be installed in the specified install location."
+msgstr "El nuevo paquete no se pudo instalar en el sitio especificado para su instalación"
+
+#: app/components/stf/install/install-error-filter.js:40
+msgid ""
+"The new package failed because it contains a content provider with thesame "
+"authority as a provider already installed in the system."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:44
+msgid ""
+"The new package failed because it has specified that it is a test-only "
+"package and the caller has not supplied the INSTALL_ALLOW_TEST flag."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:42
+msgid ""
+"The new package failed because the current SDK version is newer than that "
+"required by the package."
+msgstr "El nuevo paquete falló porque la versión actual del SDK es más reciente que la que requiere el paquete"
+
+#: app/components/stf/install/install-error-filter.js:38
+msgid ""
+"The new package failed because the current SDK version is older than that "
+"required by the package."
+msgstr "El nuevo paquete falló porque la versión actual del SDK es más antigua que la que requiere el paquete"
+
+#: app/components/stf/install/install-error-filter.js:36
+msgid ""
+"The new package failed while optimizing and validating its dex files, either"
+" because there was not enough storage or the validation failed."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:64
+msgid ""
+"The new package has an older version code than the currently installed "
+"package."
+msgstr "El nuevo paquete tiene una versión de código más antigua que el paquete instalado actualmente."
+
+#: app/components/stf/install/install-error-filter.js:62
+msgid "The new package is assigned a different UID than it previously held."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:48
+msgid "The new package uses a feature that is not available."
+msgstr "El nuevo paquete utiliza una característica que no está disponible."
+
+#: app/components/stf/install/install-error-filter.js:32
+msgid "The new package uses a shared library that is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:20
+msgid "The package archive file is invalid."
+msgstr "El archivo del paquete no es válido"
+
+#: app/components/stf/install/install-error-filter.js:46
+msgid ""
+"The package being installed contains native code, but none that is "
+"compatible with the device's CPU_ABI."
+msgstr "El paquete que se está instalando contiene código nativo que no es compatible con el CPU_ABI del dispositivo."
+
+#: app/components/stf/install/install-error-filter.js:60
+msgid "The package changed from what the calling program expected."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:18
+msgid "The package is already installed."
+msgstr "El paquete ya está instalado"
+
+#: app/components/stf/install/install-error-filter.js:24
+msgid ""
+"The package manager service found that the device didn't have enough storage"
+" space to install the app."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:84
+msgid ""
+"The parser did not find any actionable tags (instrumentation or application)"
+" in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:72
+msgid "The parser did not find any certificates in the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:78
+msgid "The parser encountered a bad or missing package name in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:80
+msgid "The parser encountered a bad shared user id name in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:76
+msgid ""
+"The parser encountered a CertificateEncodingException in one of the files in"
+" the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:70
+msgid "The parser encountered an unexpected exception."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:82
+msgid "The parser encountered some structural problem in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:74
+msgid "The parser found inconsistent certificates on the files in the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:66
+msgid ""
+"The parser was given a path that is not a file, or does not end with the "
+"expected '.apk' extension."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:68
+msgid "The parser was unable to retrieve the AndroidManifest.xml file."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:28
+msgid "The requested shared user does not exist."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:90
+msgid ""
+"The system failed to install the package because its packaged native code "
+"did not match any of the ABIs supported by the system."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:86
+msgid "The system failed to install the package because of system issues."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:88
+msgid ""
+"The system failed to install the package because the user is restricted from"
+" installing apps."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:22
+msgid "The URI passed in is invalid."
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "TID"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "Time"
+msgstr ""
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Tip:"
+msgstr "Truco:"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Title"
+msgstr "Título"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:107
+msgid "Toggle Web/Native"
+msgstr ""
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Total Devices"
+msgstr "Dispositivos Totales"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "translate"
+msgstr "traducir"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+msgid "Try to reconnect"
+msgstr "Volver a conectar"
+
+#: app/control-panes/info/info.html:1
+msgid "Type"
+msgstr "Tipo"
+
+#: app/components/stf/device/device-info-filter/index.js:23
+#: app/components/stf/device/device-info-filter/index.js:8
+msgid "Unauthorized"
+msgstr "No autorizado"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uninstall"
+msgstr "Desinstalar"
+
+#: app/components/stf/device/device-info-filter/index.js:14
+#: app/components/stf/device/device-info-filter/index.js:29
+msgid "Unknown"
+msgstr "Desconocido"
+
+#: app/components/stf/device/device-info-filter/index.js:40
+msgid "Unknown reason."
+msgstr "Razón desconocida."
+
+#: app/control-panes/automation/device-settings/device-settings.html:6
+msgid "Unlock Rotation"
+msgstr "Desbloquear rotación"
+
+#: app/components/stf/device/device-info-filter/index.js:51
+msgid "Unspecified Failure"
+msgstr "Fallo no especificado"
+
+#: app/components/stf/upload/upload-error-filter.js:7
+msgid "Upload failed"
+msgstr "Subida fallida"
+
+#: app/control-panes/dashboard/install/install.html:5
+msgid "Upload From Link"
+msgstr "Subir desde enlace"
+
+#: app/components/stf/upload/upload-error-filter.js:8
+msgid "Upload unknown error"
+msgstr "Error de subida desconocido"
+
+#: app/components/stf/upload/upload-error-filter.js:4
+msgid "Uploaded file is not valid"
+msgstr "El fichero de subida no es válido"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uploading..."
+msgstr "Subiendo..."
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Usable Devices"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:59
+msgid "USB"
+msgstr "USB"
+
+#: app/control-panes/advanced/usb/usb.html:1
+msgid "Usb speed"
+msgstr "Velocidad de USB"
+
+#: app/components/stf/device/device-info-filter/index.js:13
+msgid "Use"
+msgstr "Uso"
+
+#: app/device-list/column/device-column-service.js:262
+msgid "User"
+msgstr "Usuario"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Username"
+msgstr "Nombre de usuario"
+
+#: app/components/stf/device/device-info-filter/index.js:26
+msgid "Using"
+msgstr "En uso"
+
+#: app/control-panes/info/info.html:1
+msgid "Using Fallback"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Version"
+msgstr "Versión"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "Version Update"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Vibrate Mode"
+msgstr "Modo vibración"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "VNC"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Voltage"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume"
+msgstr "Volumen"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Down"
+msgstr "Bajar volumen"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Up"
+msgstr "Subir volumen"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Warning:"
+msgstr "Atención:"
+
+#: app/menu/menu.html:1
+msgid "Web"
+msgstr "Web"
+
+#: app/control-panes/info/info.html:1
+msgid "Width"
+msgstr "Ancho"
+
+#: app/components/stf/device/device-info-filter/index.js:105
+#: app/components/stf/device/device-info-filter/index.js:97
+#: app/control-panes/automation/device-settings/device-settings.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "WiFi"
+msgstr "WIFI"
+
+#: app/components/stf/device/device-info-filter/index.js:98
+msgid "WiMAX"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:60
+msgid "Wireless"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "X DPI"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Y DPI"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:113
+msgid "Yes"
+msgstr "Sí"
+
+#: app/components/stf/device/device-info-filter/index.js:35
+msgid "You (or someone else) kicked the device."
+msgstr ""
diff --git a/crowdstf/res/common/lang/po/stf.fr.po b/crowdstf/res/common/lang/po/stf.fr.po
new file mode 100644
index 0000000..94e473f
--- /dev/null
+++ b/crowdstf/res/common/lang/po/stf.fr.po
@@ -0,0 +1,1560 @@
+# 
+# Translators:
+# Guillaume Chertier <gchertier.ext@orange.com>, 2016
+msgid ""
+msgstr ""
+"Project-Id-Version: STF\n"
+"PO-Revision-Date: 2016-04-04 09:12+0000\n"
+"Last-Translator: Guillaume Chertier <gchertier.ext@orange.com>\n"
+"Language-Team: French (http://www.transifex.com/openstf/stf/language/fr/)\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: fr\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+#: app/components/stf/device/device-info-filter/index.js:117
+#: app/components/stf/device/device-info-filter/index.js:52
+#: app/components/stf/device/device-info-filter/index.js:61
+#: app/components/stf/device/device-info-filter/index.js:71
+msgid "-"
+msgstr "-"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "A new version of STF is available"
+msgstr "Une nouvelle version de STF est disponible"
+
+#: app/components/stf/install/install-error-filter.js:26
+msgid "A package is already installed with the same name."
+msgstr "Un paquet est déjà installé avec le même nom"
+
+#: app/components/stf/install/install-error-filter.js:30
+msgid ""
+"A previously installed package of the same name has a different signature "
+"than the new package (and the old package's data was not removed)."
+msgstr "Un paquet précédemment installée du même nom a une signature différente de celle du nouveau paquet (et les données de l'ancien paquet n'a pas été supprimée)."
+
+#: app/components/stf/install/install-error-filter.js:50
+msgid "A secure container mount point couldn't be accessed on external media."
+msgstr "Un conteneur sécurisé équipé ne peut pas être accessible sur un support externe."
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:178
+msgid "ABI"
+msgstr "IBP"
+
+#: app/components/stf/device/device-info-filter/index.js:58
+msgid "AC"
+msgstr "AC"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Access Tokens"
+msgstr "Jetons d'Accès"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Account"
+msgstr "Compte"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Action"
+msgstr "Action"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Actions"
+msgstr "Actions"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Activity"
+msgstr "Activité"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "ADB Keys"
+msgstr "Clefs ADB"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Add"
+msgstr "Ajouter"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add ADB Key"
+msgstr "Ajouter une Clef ADB"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add Key"
+msgstr "Ajouter une Clef"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Add the following ADB Key to STF?"
+msgstr "Ajouter la Clef ADB suivante dans STF?"
+
+#: app/layout/layout-controller.js:7
+msgid "Admin mode has been disabled."
+msgstr "Le Mode Administrateur a été désactivé"
+
+#: app/layout/layout-controller.js:6
+msgid "Admin mode has been enabled."
+msgstr "Le Mode Administrateur a été activé"
+
+#: app/control-panes/control-panes-controller.js:20
+msgid "Advanced"
+msgstr "Avancé"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Advanced Input"
+msgstr "Entrée Avancé"
+
+#: app/control-panes/info/info.html:1
+msgid "Airplane Mode"
+msgstr "Mode Avion"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "App Store"
+msgstr "App Store"
+
+#: app/control-panes/dashboard/install/install.html:1
+msgid "App Upload"
+msgstr "Téléverser une Application"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Apps"
+msgstr "Applications"
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:10
+msgid "Are you sure you want to reboot this device?"
+msgstr "Est vous sûr de vouloir redémarrer ce terminal?"
+
+#: app/control-panes/control-panes-controller.js:14
+msgid "Automation"
+msgstr "Automatisation"
+
+#: app/components/stf/device/device-info-filter/index.js:28
+msgid "Available"
+msgstr "Disponible"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Back"
+msgstr "Précédent"
+
+#: app/control-panes/info/info.html:1
+msgid "Battery"
+msgstr "Batterie"
+
+#: app/device-list/column/device-column-service.js:202
+msgid "Battery Health"
+msgstr "Santé de la Batterie"
+
+#: app/device-list/column/device-column-service.js:226
+msgid "Battery Level"
+msgstr "Niveau de la Batterie"
+
+#: app/device-list/column/device-column-service.js:210
+msgid "Battery Source"
+msgstr "Source de la Batterie"
+
+#: app/device-list/column/device-column-service.js:218
+msgid "Battery Status"
+msgstr "Statut de la Batterie"
+
+#: app/device-list/column/device-column-service.js:239
+msgid "Battery Temp"
+msgstr "Température de la Batterie"
+
+#: app/components/stf/device/device-info-filter/index.js:89
+msgid "Bluetooth"
+msgstr "Bluetooth"
+
+#: app/device-list/column/device-column-service.js:153
+msgid "Browser"
+msgstr "Navigateur"
+
+#: app/components/stf/device/device-info-filter/index.js:12
+#: app/components/stf/device/device-info-filter/index.js:27
+msgid "Busy"
+msgstr "Occupé"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Busy Devices"
+msgstr "Terminaux Occupés"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Camera"
+msgstr "Caméra"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Cancel"
+msgstr "Annuler"
+
+#: app/components/stf/upload/upload-error-filter.js:6
+msgid "Cannot access specified URL"
+msgstr "Impossible d’accéder à l'URL spécifiée"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:43
+msgid "Carrier"
+msgstr "Opérateur"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Category"
+msgstr "Catégorie"
+
+#: app/components/stf/device/device-info-filter/index.js:67
+msgid "Charging"
+msgstr "Chargement"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Check errors below"
+msgstr "Vérifier les erreurs ci-dessous"
+
+#: app/components/stf/common-ui/clear-button/clear-button.html:1
+#: app/control-panes/advanced/run-js/run-js.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Clear"
+msgstr "Nettoyer"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Clipboard"
+msgstr "Presse-papier"
+
+#: app/components/stf/device/device-info-filter/index.js:46
+msgid "Cold"
+msgstr "Froid"
+
+#: app/components/stf/device/device-info-filter/index.js:21
+#: app/components/stf/device/device-info-filter/index.js:6
+#: app/control-panes/info/info.html:1
+msgid "Connected"
+msgstr "Connecté"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:20
+msgid "Connected successfully."
+msgstr "Connexion réussie"
+
+#: app/menu/menu.html:1
+msgid "Control"
+msgstr "Contrôle"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Cookies"
+msgstr "Cookies"
+
+#: app/control-panes/info/info.html:1
+msgid "Cores"
+msgstr "Coeurs"
+
+#: app/control-panes/info/info.html:1
+#: app/control-panes/performance/cpu/cpu.html:1
+msgid "CPU"
+msgstr "CPU"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Current rotation:"
+msgstr "Rotation actuelle"
+
+#: app/device-list/device-list.html:1
+msgid "Customize"
+msgstr "Personnaliser"
+
+#: app/control-panes/advanced/input/input.html:12
+msgid "D-pad Center"
+msgstr "D-pad Centre"
+
+#: app/control-panes/advanced/input/input.html:20
+msgid "D-pad Down"
+msgstr "D-pad Bas"
+
+#: app/control-panes/advanced/input/input.html:9
+msgid "D-pad Left"
+msgstr "D-pad Gauche"
+
+#: app/control-panes/advanced/input/input.html:15
+msgid "D-pad Right"
+msgstr "D-pad Droite"
+
+#: app/control-panes/advanced/input/input.html:4
+msgid "D-pad Up"
+msgstr "D-pad Haut"
+
+#: app/control-panes/control-panes-controller.js:41
+msgid "Dashboard"
+msgstr "Tableau"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Data"
+msgstr "Données"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Date"
+msgstr "Date"
+
+#: app/components/stf/device/device-info-filter/index.js:48
+msgid "Dead"
+msgstr "Mort"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Delete"
+msgstr "Supprimer"
+
+#: app/control-panes/info/info.html:1
+msgid "Density"
+msgstr "Densité"
+
+#: app/device-list/device-list.html:1
+msgid "Details"
+msgstr "Détails"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Developer"
+msgstr "Développeur"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/inspect/inspect.html:1
+msgid "Device"
+msgstr "Terminal"
+
+#: app/device-list/details/device-list-details-directive.js:38
+#: app/device-list/icons/device-list-icons-directive.js:122
+msgid "Device cannot get kicked from the group"
+msgstr "Le Terminal ne peut pas être exclu du groupe"
+
+#: app/components/stf/device/device-info-filter/index.js:38
+msgid "Device is not present anymore for some reason."
+msgstr "Le Terminal n'est plus présent pour certaines raisons"
+
+#: app/components/stf/device/device-info-filter/index.js:39
+msgid "Device is present but offline."
+msgstr "Le Terminal est présent mais Hors-Ligne"
+
+#: app/control-panes/info/info.html:1
+msgid "Device Photo"
+msgstr "Photos du Terminal"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Device Settings"
+msgstr "Paramètres du Terminal"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+msgid "Device was disconnected"
+msgstr "Le Terminal était déconnecté"
+
+#: app/components/stf/device/device-info-filter/index.js:37
+msgid "Device was kicked by automatic timeout."
+msgstr "Le Terminal a été exclu par le Timeout automatique"
+
+#: app/device-list/device-list.html:1 app/menu/menu.html:1
+msgid "Devices"
+msgstr "Terminaux"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Disable WiFi"
+msgstr "Désactiver le Wifi"
+
+#: app/components/stf/device/device-info-filter/index.js:68
+msgid "Discharging"
+msgstr "En Décharge"
+
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+#: app/components/stf/device/device-info-filter/index.js:20
+#: app/components/stf/device/device-info-filter/index.js:5
+msgid "Disconnected"
+msgstr "Déconnecté"
+
+#: app/control-panes/info/info.html:1
+msgid "Display"
+msgstr "écran"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Drop file to upload"
+msgstr "Déposer le fichier à téléverser"
+
+#: app/components/stf/device/device-info-filter/index.js:90
+msgid "Dummy"
+msgstr "Mannequin"
+
+#: app/settings/notifications/notifications.html:1
+msgid "Enable notifications"
+msgstr "Activer les notifications"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Enable WiFi"
+msgstr "Activer le Wifi"
+
+#: app/control-panes/info/info.html:1
+msgid "Encrypted"
+msgstr "Crypté"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:31
+msgid "Error"
+msgstr "Erreur"
+
+#: app/components/stf/control/control-service.js:129
+msgid "Error while getting data"
+msgstr "Erreur lors de l'obtention de données"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:35
+msgid "Error while reconnecting"
+msgstr "Erreur lors de la reconnexion"
+
+#: app/components/stf/device/device-info-filter/index.js:91
+msgid "Ethernet"
+msgstr "Ethernet"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Executes remote shell commands"
+msgstr "Exécute des commandes Shell à distance"
+
+#: app/components/stf/upload/upload-error-filter.js:5
+msgid "Failed to download file"
+msgstr "Impossible de télécharger le fichier"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Fast Forward"
+msgstr "Avance Rapide"
+
+#: app/control-panes/control-panes-controller.js:26
+msgid "File Explorer"
+msgstr "Explorateur de Fichiers"
+
+#: app/components/stf/common-ui/filter-button/filter-button.html:1
+msgid "Filter"
+msgstr "Filtrer"
+
+#: app/control-panes/info/info.html:1
+msgid "Find Device"
+msgstr "Trouver un Terminal"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Fingerprint"
+msgstr "Empreinte Digitale"
+
+#: app/control-panes/info/info.html:1
+msgid "FPS"
+msgstr "FPS"
+
+#: app/control-panes/info/info.html:1
+msgid "Frequency"
+msgstr "Fréquence"
+
+#: app/components/stf/device/device-info-filter/index.js:69
+msgid "Full"
+msgstr "Rempli"
+
+#: app/settings/settings-controller.js:5
+msgid "General"
+msgstr "Général"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate Access Token"
+msgstr "Générer un Jeton d'Accès"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Generate Login for VNC"
+msgstr "Générer un identifiant pour VNC"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate New Token"
+msgstr "Générer un Nouveau Jeton"
+
+#: app/control-panes/logs/logs.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "Get"
+msgstr "Obtenir"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Get clipboard contents"
+msgstr "Obtenir le contenu du Presse-Papier"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Back"
+msgstr "Retour"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Forward"
+msgstr "Avancer"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:89
+msgid "Go to Device List"
+msgstr "Aller à la Liste des Terminaux"
+
+#: app/components/stf/device/device-info-filter/index.js:47
+msgid "Good"
+msgstr "Bien"
+
+#: app/control-panes/info/info.html:1
+msgid "Hardware"
+msgstr "Matériel"
+
+#: app/control-panes/info/info.html:1
+msgid "Health"
+msgstr "Santé"
+
+#: app/control-panes/info/info.html:1
+msgid "Height"
+msgstr "Taille"
+
+#: app/menu/menu.html:1
+msgid "Help"
+msgstr "Aide"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Hide Screen"
+msgstr "Cacher l'écran"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Home"
+msgstr "Accueil"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Host"
+msgstr "Hôte"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Hostname"
+msgstr "Nom de l'Hôte"
+
+#: app/control-panes/info/info.html:1
+msgid "ICCID"
+msgstr "ICCID"
+
+#: app/control-panes/info/info.html:1
+msgid "ID"
+msgstr "ID"
+
+#: app/control-panes/info/info.html:1
+msgid "IMEI"
+msgstr "IMEI"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Incorrect login details"
+msgstr "Informations de connexion incorrectes"
+
+#: app/control-panes/control-panes-controller.js:32
+msgid "Info"
+msgstr "Informations"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspect Device"
+msgstr "Inspecter le Terminal"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspecting is currently only supported in WebView"
+msgstr "L'inspection est actuellement pris en charge uniquement dans WebView"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspector"
+msgstr "Inspecteur"
+
+#: app/components/stf/install/install-error-filter.js:13
+msgid "Installation canceled by user."
+msgstr "Installation annulée par l'utilisateur"
+
+#: app/components/stf/install/install-error-filter.js:9
+msgid "Installation failed due to an unknown error."
+msgstr "Installation échouée due à une erreur inconnue"
+
+#: app/components/stf/install/install-error-filter.js:7
+msgid "Installation succeeded."
+msgstr "Installation réussie"
+
+#: app/components/stf/install/install-error-filter.js:11
+msgid "Installation timed out."
+msgstr "L'installation a expirée."
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Installing app..."
+msgstr "En cours d'installation de l'application"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Key"
+msgstr "Clef"
+
+#: app/settings/settings-controller.js:10
+msgid "Keys"
+msgstr "Clefs"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Landscape"
+msgstr "Paysage"
+
+#: app/settings/general/language/language.html:1
+msgid "Language"
+msgstr "Langage"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Launch Activity"
+msgstr "Lancer l'Activité"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Launching activity..."
+msgstr "En cours de lancement de l'activité ..."
+
+#: app/control-panes/info/info.html:1 app/control-panes/logs/logs.html:1
+msgid "Level"
+msgstr "Niveau"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Local Settings"
+msgstr "Paramètres locaux"
+
+#: app/device-list/column/device-column-service.js:250
+msgid "Location"
+msgstr "Localisation"
+
+#: app/control-panes/automation/device-settings/device-settings.html:7
+msgid "Lock Rotation"
+msgstr "Bloquer la Rotation"
+
+#: app/control-panes/control-panes-controller.js:50
+msgid "Logs"
+msgstr "Logs"
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Maintenance"
+msgstr "Maintenance"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid ""
+"Make sure to copy your access token now. You won't be able to see it again."
+msgstr "Assurez-vous de copier votre jeton d'accès maintenant. Vous ne serez pas en mesure de le voir à nouveau."
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Manage Apps"
+msgstr "Gérer les Applications"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Manner Mode"
+msgstr "Mode Silencieux"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:165
+msgid "Manufacturer"
+msgstr "Fabricant"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Media"
+msgstr "Médias"
+
+#: app/control-panes/info/info.html:1
+msgid "Memory"
+msgstr "Mémoire"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Menu"
+msgstr "Menu"
+
+#: app/components/stf/device/device-info-filter/index.js:92
+msgid "Mobile"
+msgstr "Mobile"
+
+#: app/components/stf/device/device-info-filter/index.js:93
+msgid "Mobile DUN"
+msgstr "Réseau Commuté"
+
+#: app/components/stf/device/device-info-filter/index.js:94
+msgid "Mobile High Priority"
+msgstr "Mobile en Priorité Haute"
+
+#: app/components/stf/device/device-info-filter/index.js:95
+msgid "Mobile MMS"
+msgstr "MMS"
+
+#: app/components/stf/device/device-info-filter/index.js:96
+msgid "Mobile SUPL"
+msgstr "SUPL"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:31
+msgid "Model"
+msgstr "Modèle"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "More about Access Tokens"
+msgstr "En savoir plus sur les Jetons d'Accès"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "More about ADB Keys"
+msgstr "En savoir plus sur les Clefs ADB"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Mute"
+msgstr "Muet"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Name"
+msgstr "Nom"
+
+#: app/menu/menu.html:1
+msgid "Native"
+msgstr "Natif"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Navigation"
+msgstr "Navigation"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:129
+msgid "Network"
+msgstr "Réseau"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Next"
+msgstr "Suivant"
+
+#: app/components/stf/device/device-info-filter/index.js:115
+msgid "No"
+msgstr "Non"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "No access tokens"
+msgstr "Pas d'accès aux jetons"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "No ADB keys"
+msgstr "Pas de clefs ADB"
+
+#: app/components/stf/control/control-service.js:126
+msgid "No clipboard data"
+msgstr "Pas de données dans le Presse-Papier"
+
+#: app/control-panes/resources/resources.html:1
+msgid "No cookies to show"
+msgstr "Pas de cookies à afficher"
+
+#: app/components/stf/screen/screen.html:1
+msgid "No device screen"
+msgstr "Pas d'écran de terminal"
+
+#: app/device-list/empty/device-list-empty.html:1
+msgid "No devices connected"
+msgstr "Pas de terminaux connectés"
+
+#: app/components/stf/common-ui/modals/lightbox-image/lightbox-image.html:1
+msgid "No photo available"
+msgstr "Pas de photos disponibles"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "No Ports Forwarded"
+msgstr "Pas de ports redirigés"
+
+#: app/control-panes/screenshots/screenshots.html:5
+msgid "No screenshots taken"
+msgstr "Pas de captures d'écran prises"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Normal Mode"
+msgstr "Mode Normal"
+
+#: app/components/stf/device/device-info-filter/index.js:70
+msgid "Not Charging"
+msgstr "Pas en charge"
+
+#: app/device-list/column/device-column-service.js:256
+msgid "Notes"
+msgstr "Notes"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Nothing to inspect"
+msgstr "Rien à inspecter"
+
+#: app/settings/notifications/notifications.html:1
+msgid "Notifications"
+msgstr "Notifications"
+
+#: app/control-panes/info/info.html:1
+msgid "Number"
+msgstr "Nombre"
+
+#: app/components/stf/device/device-info-filter/index.js:22
+#: app/components/stf/device/device-info-filter/index.js:7
+msgid "Offline"
+msgstr "Hors Ligne"
+
+#: app/components/stf/common-ui/error-message/error-message.html:1
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Oops!"
+msgstr "Oups!"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Open"
+msgstr "Ouvrir"
+
+#: app/control-panes/info/info.html:1
+msgid "Orientation"
+msgstr "Orientation"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:55
+msgid "OS"
+msgstr "OS"
+
+#: app/components/stf/device/device-info-filter/index.js:49
+msgid "Over Voltage"
+msgstr "Surtension"
+
+#: app/components/stf/device/device-info-filter/index.js:50
+msgid "Overheat"
+msgstr "Surchauffe"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Package"
+msgstr "Paquet"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Password"
+msgstr "Mot de Passe"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Permissions"
+msgstr "Permissions"
+
+#: app/device-list/column/device-column-service.js:184
+msgid "Phone"
+msgstr "Téléphone"
+
+#: app/device-list/column/device-column-service.js:196
+msgid "Phone ICCID"
+msgstr "ICCID du Téléphone"
+
+#: app/device-list/column/device-column-service.js:190
+msgid "Phone IMEI"
+msgstr "IMEI du Téléphone"
+
+#: app/control-panes/info/info.html:1
+msgid "Physical Device"
+msgstr "Terminal Physique"
+
+#: app/control-panes/logs/logs.html:1
+msgid "PID"
+msgstr "PID"
+
+#: app/control-panes/info/info.html:1
+msgid "Place"
+msgstr "Place"
+
+#: app/control-panes/info/info.html:1
+msgid "Platform"
+msgstr "Plateforme"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Play/Pause"
+msgstr "Jouer/Pause"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter a valid email"
+msgstr "S'il vous plaît entrez un e-mail valide"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your email"
+msgstr "S'il vous plaît entrez vôtre e-mail"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your LDAP username"
+msgstr "S'il vous plaît entrez vôtre compte LDAP"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your name"
+msgstr "S'il vous plaît entrez vôtre nom"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your password"
+msgstr "S'il vous plaît entrez vôtre mot de passe"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store password"
+msgstr "S'il vous plaît entrez vôtre mot de passe du Store"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store username"
+msgstr "S'il vous plaît entrez vôtre identifiant du Store"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Port"
+msgstr "Port"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Port Forwarding"
+msgstr "Redirection de Ports"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Portrait"
+msgstr "Portrait"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Power"
+msgstr "Alimentation"
+
+#: app/control-panes/info/info.html:1
+msgid "Power Source"
+msgstr "Source d'Alimentation"
+
+#: app/components/stf/device/device-info-filter/index.js:24
+#: app/components/stf/device/device-info-filter/index.js:9
+msgid "Preparing"
+msgstr "En Préparation"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:104
+msgid "Press Back button"
+msgstr "Appuyer sur le bouton Retour"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:103
+msgid "Press Home button"
+msgstr "Appuyer sur le bouton Accueil"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:102
+msgid "Press Menu button"
+msgstr "Appuyer sur le bouton Menu"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Previous"
+msgstr "Précédent"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Processing..."
+msgstr "En Traitement ...."
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:37
+msgid "Product"
+msgstr "Produit"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Pushing app..."
+msgstr "En cours de téléversement des Applications ...."
+
+#: app/control-panes/info/info.html:1
+msgid "RAM"
+msgstr "RAM"
+
+#: app/components/stf/device/device-info-filter/index.js:10
+#: app/components/stf/device/device-info-filter/index.js:25
+msgid "Ready"
+msgstr "Prêt"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:39
+msgid "Reconnected successfully."
+msgstr "Reconnexions réussis"
+
+#: app/components/stf/common-ui/refresh-page/refresh-page.html:1
+msgid "Refresh"
+msgstr "Rafraîchir"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:49
+msgid "Released"
+msgstr "Versionée"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reload"
+msgstr "Recharger"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug.html:1
+msgid "Remote debug"
+msgstr "Débogage à distance"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "Remove"
+msgstr "Enlever"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+#: app/device-list/device-list.html:1
+msgid "Reset"
+msgstr "Réinitialiser"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reset all browser settings"
+msgstr "Réinitialiser tous les paramètres des navigateurs"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Reset Settings"
+msgstr "Réinitialiser les paramètres"
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Restart Device"
+msgstr "Redémarrer le Terminal"
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retrieving the device screen has timed out."
+msgstr "La récupération de l'écran de l'appareil a expiré."
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retry"
+msgstr "Recommencer"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Rewind"
+msgstr "Rembobiner"
+
+#: app/control-panes/info/info.html:1
+msgid "Roaming"
+msgstr "Roaming"
+
+#: app/control-panes/info/info.html:1
+msgid "ROM"
+msgstr "ROM"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:92
+msgid "Rotate Left"
+msgstr "Tourner vers la gauche"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:93
+msgid "Rotate Right"
+msgstr "Tourner vers la droite"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run"
+msgstr "Exécuter"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run JavaScript"
+msgstr "Exécuter JavaScript"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:31
+msgid ""
+"Run the following on your command line to debug the device from your Browser"
+msgstr "Exécutez la commande suivante sur la ligne de commande pour déboguer le périphérique de votre navigateur"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:28
+msgid ""
+"Run the following on your command line to debug the device from your IDE"
+msgstr "Exécutez la commande suivante sur la ligne de commande pour déboguer le périphérique de votre IDE"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Run this command to copy the key to your clipboard"
+msgstr "Exécutez cette commande pour copier la clef de votre presse-papier"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+msgid "Save ScreenShot"
+msgstr "Sauver la capture d'écran"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Save..."
+msgstr "Sauvegarde ..."
+
+#: app/device-list/column/device-column-service.js:135
+msgid "Screen"
+msgstr "écran"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Screenshot"
+msgstr "Capture d'écran"
+
+#: app/control-panes/control-panes-controller.js:8
+msgid "Screenshots"
+msgstr "Captures d'écran"
+
+#: app/control-panes/info/info.html:1
+msgid "SD Card Mounted"
+msgstr "Carte SD Monté"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:171
+msgid "SDK"
+msgstr "SDK"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Search"
+msgstr "Rechercher"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:91
+msgid "Selects Next IME"
+msgstr "Sélectionner le prochain IME"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:159
+msgid "Serial"
+msgstr "Sériel"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Server"
+msgstr "Serveur"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Server error. Check log output."
+msgstr "Erreur Serveur. Vérifier les logs de sortie."
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set"
+msgstr "Paramétrer"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set Cookie"
+msgstr "Paramétrer le Cookie"
+
+#: app/control-panes/dashboard/apps/apps.html:1 app/menu/menu.html:1
+msgid "Settings"
+msgstr "Paramètres"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Shell"
+msgstr "Shell"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Show Screen"
+msgstr "Afficher l'écran"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign In"
+msgstr "S'enregistrer"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign Out"
+msgstr "Se déconnecter"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Silent Mode"
+msgstr "Mode Silencieux"
+
+#: app/control-panes/info/info.html:1
+msgid "SIM"
+msgstr "SIM"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Size"
+msgstr "Taille"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:26
+msgid "Socket connection was lost"
+msgstr "La connexion au Socket a été perdu"
+
+#: app/components/stf/device/device-info-filter/index.js:36
+msgid "Someone stole your device."
+msgstr "Quelqu'un a volé votre terminal."
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Special Keys"
+msgstr "Clefs spéciales"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Start/Stop Logging"
+msgstr "Démarrer/Arrêter les logs"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:25
+msgid "Status"
+msgstr "Statut"
+
+#: app/control-panes/advanced/input/input.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Stop"
+msgstr "Arrêter"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/components/stf/device/device-info-filter/index.js:11
+#: app/control-panes/device-control/device-control.html:1
+msgid "Stop Using"
+msgstr "Cesser d'utiliser"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Store Account"
+msgstr "Compte du Store"
+
+#: app/control-panes/info/info.html:1
+msgid "Sub Type"
+msgstr "Sous Type"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Switch Charset"
+msgstr "Permuter le Charset"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Tag"
+msgstr "étiquette"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Pageshot (Needs WebView running)"
+msgstr "Prendre une Prise de vue de la page (besoin de WebView)"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Screenshot"
+msgstr "Prendre une Capture d'écran"
+
+#: app/control-panes/info/info.html:1
+msgid "Temperature"
+msgstr "Température"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Text"
+msgstr "Texte"
+
+#: app/components/stf/screen/screen.html:1
+msgid "The current view is marked secure and cannot be viewed remotely."
+msgstr "La vue actuelle est marqué sécurisé et ne peut être consulté à distance."
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:11
+msgid "The device will be unavailable for a moment."
+msgstr "Le terminal ne sera pas disponible pour un moment"
+
+#: app/components/stf/install/install-error-filter.js:34
+msgid "The existing package could not be deleted."
+msgstr "Le package existant ne peut pas être supprimé."
+
+#: app/components/stf/install/install-error-filter.js:58
+msgid ""
+"The new package couldn't be installed because the verification did not "
+"succeed."
+msgstr "Le nouveau paquet n'a pas pu être installé car la vérification n'a pas réussi."
+
+#: app/components/stf/install/install-error-filter.js:56
+msgid ""
+"The new package couldn't be installed because the verification timed out."
+msgstr "Le nouveau paquet n'a pas pu être installé car la vérification a expiré."
+
+#: app/components/stf/install/install-error-filter.js:54
+msgid ""
+"The new package couldn't be installed in the specified install location "
+"because the media is not available."
+msgstr "Le nouveau paquet n'a pas pu être installé à l'emplacement spécifié pour l'installation parce que les médias ne sont pas disponibles."
+
+#: app/components/stf/install/install-error-filter.js:52
+msgid ""
+"The new package couldn't be installed in the specified install location."
+msgstr "Le nouveau paquet n'a pas pu être installé à l'emplacement spécifié pour installation."
+
+#: app/components/stf/install/install-error-filter.js:40
+msgid ""
+"The new package failed because it contains a content provider with thesame "
+"authority as a provider already installed in the system."
+msgstr "Le nouveau paquet a échoué car it contient un fournisseur de contenu avec la même autorité en tant que fournisseur déjà installé dans le système."
+
+#: app/components/stf/install/install-error-filter.js:44
+msgid ""
+"The new package failed because it has specified that it is a test-only "
+"package and the caller has not supplied the INSTALL_ALLOW_TEST flag."
+msgstr "Le nouveau paquet a échoué car il a précisé qu'il est un paquet de test uniquement et l'appelant n'a pas fourni le drapeau INSTALL_ALLOW_TEST."
+
+#: app/components/stf/install/install-error-filter.js:42
+msgid ""
+"The new package failed because the current SDK version is newer than that "
+"required by the package."
+msgstr "Le nouveau paquet a échoué parce que la version actuelle du SDK est plus récente que celle requise par le paquet."
+
+#: app/components/stf/install/install-error-filter.js:38
+msgid ""
+"The new package failed because the current SDK version is older than that "
+"required by the package."
+msgstr "Le nouveau paquet a échoué parce que la version actuelle du SDK est plus ancienne que celle requise par le paquet."
+
+#: app/components/stf/install/install-error-filter.js:36
+msgid ""
+"The new package failed while optimizing and validating its dex files, either"
+" because there was not enough storage or the validation failed."
+msgstr "Le nouveau paquet a échoué lors de l'optimisation et l'évaluation de ses fichiers dex, soit parce qu'il n'y avait pas assez de stockage ou la validation a échoué."
+
+#: app/components/stf/install/install-error-filter.js:64
+msgid ""
+"The new package has an older version code than the currently installed "
+"package."
+msgstr "Le nouveau paquet a un code de version plus ancien que le paquet actuellement installé."
+
+#: app/components/stf/install/install-error-filter.js:62
+msgid "The new package is assigned a different UID than it previously held."
+msgstr "Le nouveau paquet est affecté un ID différent qu'il détenait auparavant."
+
+#: app/components/stf/install/install-error-filter.js:48
+msgid "The new package uses a feature that is not available."
+msgstr "Le nouveau paquet utilise une fonctionnalité qui n'est pas disponible."
+
+#: app/components/stf/install/install-error-filter.js:32
+msgid "The new package uses a shared library that is not available."
+msgstr "Le nouveau paquet utilise une bibliothèque partagée qui n'est pas disponible."
+
+#: app/components/stf/install/install-error-filter.js:20
+msgid "The package archive file is invalid."
+msgstr "Le fichier d'archive de paquet est invalide."
+
+#: app/components/stf/install/install-error-filter.js:46
+msgid ""
+"The package being installed contains native code, but none that is "
+"compatible with the device's CPU_ABI."
+msgstr "Le package installé contient du code natif, mais aucun qui soit compatible avec le CPU_ABI du terminal."
+
+#: app/components/stf/install/install-error-filter.js:60
+msgid "The package changed from what the calling program expected."
+msgstr "Le paquet a changé de ce que le programme appelant avait prévu."
+
+#: app/components/stf/install/install-error-filter.js:18
+msgid "The package is already installed."
+msgstr "Le paquet est déjà installé"
+
+#: app/components/stf/install/install-error-filter.js:24
+msgid ""
+"The package manager service found that the device didn't have enough storage"
+" space to install the app."
+msgstr "Le service de gestionnaire de paquets a constaté que le terminal ne dispose pas de suffisamment d'espace de stockage pour installer l'application."
+
+#: app/components/stf/install/install-error-filter.js:84
+msgid ""
+"The parser did not find any actionable tags (instrumentation or application)"
+" in the manifest."
+msgstr "L'analyseur n'a pas trouvé toutes les tags actionnables (de l'instrumentation et des applications) dans le manifest."
+
+#: app/components/stf/install/install-error-filter.js:72
+msgid "The parser did not find any certificates in the .apk."
+msgstr "L'analyseur n'a pas trouvé de certificat dans le fichier .apk."
+
+#: app/components/stf/install/install-error-filter.js:78
+msgid "The parser encountered a bad or missing package name in the manifest."
+msgstr "L'analyseur a rencontré un mauvais ou manquant nom du paquet dans le manifest."
+
+#: app/components/stf/install/install-error-filter.js:80
+msgid "The parser encountered a bad shared user id name in the manifest."
+msgstr "L'analyseur a rencontré un mauvais nom d'utilisateur partagé dans le manifest."
+
+#: app/components/stf/install/install-error-filter.js:76
+msgid ""
+"The parser encountered a CertificateEncodingException in one of the files in"
+" the .apk."
+msgstr "L'analyseur a rencontré une CertificateEncodingException dans l'un des fichiers de l'apk."
+
+#: app/components/stf/install/install-error-filter.js:70
+msgid "The parser encountered an unexpected exception."
+msgstr "L'analyseur a rencontré une exception inattendue."
+
+#: app/components/stf/install/install-error-filter.js:82
+msgid "The parser encountered some structural problem in the manifest."
+msgstr "L'analyseur a rencontré un problème structurel dans le manifest."
+
+#: app/components/stf/install/install-error-filter.js:74
+msgid "The parser found inconsistent certificates on the files in the .apk."
+msgstr "L'analyseur a trouvé des certificats contradictoires sur les fichiers de l'apk."
+
+#: app/components/stf/install/install-error-filter.js:66
+msgid ""
+"The parser was given a path that is not a file, or does not end with the "
+"expected '.apk' extension."
+msgstr "L'analyseur a donné un chemin qui n'est pas un fichier, ou ne se termine pas avec le \".apk\" extension attendue."
+
+#: app/components/stf/install/install-error-filter.js:68
+msgid "The parser was unable to retrieve the AndroidManifest.xml file."
+msgstr "L'analyseur n'a pas pu extraire le fichier AndroidManifest.xml."
+
+#: app/components/stf/install/install-error-filter.js:28
+msgid "The requested shared user does not exist."
+msgstr "L'utilisateur requêté partagé n'existe pas."
+
+#: app/components/stf/install/install-error-filter.js:90
+msgid ""
+"The system failed to install the package because its packaged native code "
+"did not match any of the ABIs supported by the system."
+msgstr "Le système n'a pas réussi à installer le paquet parce que son code natif emballé ne correspond à aucune ABI supporté par le système."
+
+#: app/components/stf/install/install-error-filter.js:86
+msgid "The system failed to install the package because of system issues."
+msgstr "Le système n'a pas réussi à installer le paquet en raison de problèmes du système."
+
+#: app/components/stf/install/install-error-filter.js:88
+msgid ""
+"The system failed to install the package because the user is restricted from"
+" installing apps."
+msgstr "Le système n'a pas réussi à installer le paquet parce que l'utilisateur est limité à partir de l'installation d'applications."
+
+#: app/components/stf/install/install-error-filter.js:22
+msgid "The URI passed in is invalid."
+msgstr "L'URI transmise n'est pas invalide."
+
+#: app/control-panes/logs/logs.html:1
+msgid "TID"
+msgstr "TID"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Time"
+msgstr "Temps"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Tip:"
+msgstr "Astuce:"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Title"
+msgstr "Titre"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:107
+msgid "Toggle Web/Native"
+msgstr "Basculer de Web/Natif"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Total Devices"
+msgstr "Nombre total de Terminaux"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "translate"
+msgstr "Traduire"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+msgid "Try to reconnect"
+msgstr "Essayer de se reconnecter"
+
+#: app/control-panes/info/info.html:1
+msgid "Type"
+msgstr "Type"
+
+#: app/components/stf/device/device-info-filter/index.js:23
+#: app/components/stf/device/device-info-filter/index.js:8
+msgid "Unauthorized"
+msgstr "Non Autorisé"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uninstall"
+msgstr "Désinstaller"
+
+#: app/components/stf/device/device-info-filter/index.js:14
+#: app/components/stf/device/device-info-filter/index.js:29
+msgid "Unknown"
+msgstr "Inconnu"
+
+#: app/components/stf/device/device-info-filter/index.js:40
+msgid "Unknown reason."
+msgstr "Raison inconnue."
+
+#: app/control-panes/automation/device-settings/device-settings.html:6
+msgid "Unlock Rotation"
+msgstr "Débloquer la Rotation"
+
+#: app/components/stf/device/device-info-filter/index.js:51
+msgid "Unspecified Failure"
+msgstr "Défaillance non spécifiée"
+
+#: app/components/stf/upload/upload-error-filter.js:7
+msgid "Upload failed"
+msgstr "Téléversement raté"
+
+#: app/control-panes/dashboard/install/install.html:5
+msgid "Upload From Link"
+msgstr "Téléverser depuis le Lien"
+
+#: app/components/stf/upload/upload-error-filter.js:8
+msgid "Upload unknown error"
+msgstr "Erreur inconnue lors du Téléversement"
+
+#: app/components/stf/upload/upload-error-filter.js:4
+msgid "Uploaded file is not valid"
+msgstr "Le fichier téléversé n'est pas valide"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uploading..."
+msgstr "En cours de téléversement ..."
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Usable Devices"
+msgstr "Terminaux utilisables"
+
+#: app/components/stf/device/device-info-filter/index.js:59
+msgid "USB"
+msgstr "USB"
+
+#: app/control-panes/advanced/usb/usb.html:1
+msgid "Usb speed"
+msgstr "Vitesse USB"
+
+#: app/components/stf/device/device-info-filter/index.js:13
+msgid "Use"
+msgstr "Utiliser"
+
+#: app/device-list/column/device-column-service.js:262
+msgid "User"
+msgstr "Utilisateur"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Username"
+msgstr "Nom de l'utilisateur"
+
+#: app/components/stf/device/device-info-filter/index.js:26
+msgid "Using"
+msgstr "En Utilisation"
+
+#: app/control-panes/info/info.html:1
+msgid "Using Fallback"
+msgstr "Reprise de l'Utilisation"
+
+#: app/control-panes/info/info.html:1
+msgid "Version"
+msgstr "Version"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "Version Update"
+msgstr "Version de la mise à jour"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Vibrate Mode"
+msgstr "Mode Vibration"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "VNC"
+msgstr "VNC"
+
+#: app/control-panes/info/info.html:1
+msgid "Voltage"
+msgstr "Tension"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume"
+msgstr "Volume"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Down"
+msgstr "Baisser le Volume"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Up"
+msgstr "Augmenter le Volume"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Warning:"
+msgstr "Avertissement:"
+
+#: app/menu/menu.html:1
+msgid "Web"
+msgstr "Web"
+
+#: app/control-panes/info/info.html:1
+msgid "Width"
+msgstr "Largeur"
+
+#: app/components/stf/device/device-info-filter/index.js:105
+#: app/components/stf/device/device-info-filter/index.js:97
+#: app/control-panes/automation/device-settings/device-settings.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "WiFi"
+msgstr "Wifi"
+
+#: app/components/stf/device/device-info-filter/index.js:98
+msgid "WiMAX"
+msgstr "WiMax"
+
+#: app/components/stf/device/device-info-filter/index.js:60
+msgid "Wireless"
+msgstr "Sans Fil"
+
+#: app/control-panes/info/info.html:1
+msgid "X DPI"
+msgstr "X DPI"
+
+#: app/control-panes/info/info.html:1
+msgid "Y DPI"
+msgstr "Y DPI"
+
+#: app/components/stf/device/device-info-filter/index.js:113
+msgid "Yes"
+msgstr "Oui"
+
+#: app/components/stf/device/device-info-filter/index.js:35
+msgid "You (or someone else) kicked the device."
+msgstr "Vous (ou quelqu'un d'autre) a exclu le Terminal."
diff --git a/crowdstf/res/common/lang/po/stf.ja.po b/crowdstf/res/common/lang/po/stf.ja.po
new file mode 100644
index 0000000..d59923b
--- /dev/null
+++ b/crowdstf/res/common/lang/po/stf.ja.po
@@ -0,0 +1,1561 @@
+# 
+# Translators:
+# Gunther Brunner, 2015
+# takeshimiya <takeshimiya@gmail.com>, 2015
+msgid ""
+msgstr ""
+"Project-Id-Version: STF\n"
+"PO-Revision-Date: 2016-03-30 08:24+0000\n"
+"Last-Translator: takeshimiya <takeshimiya@gmail.com>\n"
+"Language-Team: Japanese (http://www.transifex.com/openstf/stf/language/ja/)\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: ja\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+#: app/components/stf/device/device-info-filter/index.js:117
+#: app/components/stf/device/device-info-filter/index.js:52
+#: app/components/stf/device/device-info-filter/index.js:61
+#: app/components/stf/device/device-info-filter/index.js:71
+msgid "-"
+msgstr "-"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "A new version of STF is available"
+msgstr "STFの新しいバージョンがリリースされました"
+
+#: app/components/stf/install/install-error-filter.js:26
+msgid "A package is already installed with the same name."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:30
+msgid ""
+"A previously installed package of the same name has a different signature "
+"than the new package (and the old package's data was not removed)."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:50
+msgid "A secure container mount point couldn't be accessed on external media."
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:178
+msgid "ABI"
+msgstr "ABI"
+
+#: app/components/stf/device/device-info-filter/index.js:58
+msgid "AC"
+msgstr "AC"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Access Tokens"
+msgstr "アクセストークン"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Account"
+msgstr "アカウント"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Action"
+msgstr "アクション"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Actions"
+msgstr "アクション"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Activity"
+msgstr "アクティビティ"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "ADB Keys"
+msgstr "ADBキー"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Add"
+msgstr "追加"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add ADB Key"
+msgstr "ADBキーを追加"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add Key"
+msgstr "キー追加"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Add the following ADB Key to STF?"
+msgstr "STFに下記のADBキーを追加しますか?"
+
+#: app/layout/layout-controller.js:7
+msgid "Admin mode has been disabled."
+msgstr "管理モードは無効になりました。"
+
+#: app/layout/layout-controller.js:6
+msgid "Admin mode has been enabled."
+msgstr "管理モードは有効になりました。"
+
+#: app/control-panes/control-panes-controller.js:20
+msgid "Advanced"
+msgstr "高度機能"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Advanced Input"
+msgstr "高度な入力"
+
+#: app/control-panes/info/info.html:1
+msgid "Airplane Mode"
+msgstr "機内モード"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "App Store"
+msgstr "アプリストア"
+
+#: app/control-panes/dashboard/install/install.html:1
+msgid "App Upload"
+msgstr "アプリアップロード"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Apps"
+msgstr "アプリ"
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:10
+msgid "Are you sure you want to reboot this device?"
+msgstr "この端末を再起動しますか?"
+
+#: app/control-panes/control-panes-controller.js:14
+msgid "Automation"
+msgstr "自動化"
+
+#: app/components/stf/device/device-info-filter/index.js:28
+msgid "Available"
+msgstr "利用可能"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Back"
+msgstr "戻る"
+
+#: app/control-panes/info/info.html:1
+msgid "Battery"
+msgstr "バッテリー"
+
+#: app/device-list/column/device-column-service.js:202
+msgid "Battery Health"
+msgstr "バッテリー健康状態"
+
+#: app/device-list/column/device-column-service.js:226
+msgid "Battery Level"
+msgstr "バッテリーレベル"
+
+#: app/device-list/column/device-column-service.js:210
+msgid "Battery Source"
+msgstr "バッテリー電力源"
+
+#: app/device-list/column/device-column-service.js:218
+msgid "Battery Status"
+msgstr "バッテリー状態"
+
+#: app/device-list/column/device-column-service.js:239
+msgid "Battery Temp"
+msgstr "バッテリー温度"
+
+#: app/components/stf/device/device-info-filter/index.js:89
+msgid "Bluetooth"
+msgstr "Bluetooth"
+
+#: app/device-list/column/device-column-service.js:153
+msgid "Browser"
+msgstr "ブラウザ"
+
+#: app/components/stf/device/device-info-filter/index.js:12
+#: app/components/stf/device/device-info-filter/index.js:27
+msgid "Busy"
+msgstr "貸し出し中"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Busy Devices"
+msgstr "貸出し中"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Camera"
+msgstr "カメラ"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Cancel"
+msgstr "キャンセル"
+
+#: app/components/stf/upload/upload-error-filter.js:6
+msgid "Cannot access specified URL"
+msgstr "指定されたURLはアクセスできません"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:43
+msgid "Carrier"
+msgstr "キャリア"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Category"
+msgstr "カテゴリー"
+
+#: app/components/stf/device/device-info-filter/index.js:67
+msgid "Charging"
+msgstr "充電中"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Check errors below"
+msgstr "下記エラーがありました"
+
+#: app/components/stf/common-ui/clear-button/clear-button.html:1
+#: app/control-panes/advanced/run-js/run-js.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Clear"
+msgstr "クリア"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Clipboard"
+msgstr "クリップボード"
+
+#: app/components/stf/device/device-info-filter/index.js:46
+msgid "Cold"
+msgstr "コールド"
+
+#: app/components/stf/device/device-info-filter/index.js:21
+#: app/components/stf/device/device-info-filter/index.js:6
+#: app/control-panes/info/info.html:1
+msgid "Connected"
+msgstr "接続中"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:20
+msgid "Connected successfully."
+msgstr "接続できました。"
+
+#: app/menu/menu.html:1
+msgid "Control"
+msgstr "リモート操作"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Cookies"
+msgstr "クッキー"
+
+#: app/control-panes/info/info.html:1
+msgid "Cores"
+msgstr "コア数"
+
+#: app/control-panes/info/info.html:1
+#: app/control-panes/performance/cpu/cpu.html:1
+msgid "CPU"
+msgstr "CPU"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Current rotation:"
+msgstr ""
+
+#: app/device-list/device-list.html:1
+msgid "Customize"
+msgstr "カスタマイズ"
+
+#: app/control-panes/advanced/input/input.html:12
+msgid "D-pad Center"
+msgstr "D-padセンター"
+
+#: app/control-panes/advanced/input/input.html:20
+msgid "D-pad Down"
+msgstr "D-pad下"
+
+#: app/control-panes/advanced/input/input.html:9
+msgid "D-pad Left"
+msgstr "D-pad左"
+
+#: app/control-panes/advanced/input/input.html:15
+msgid "D-pad Right"
+msgstr "D-pad右"
+
+#: app/control-panes/advanced/input/input.html:4
+msgid "D-pad Up"
+msgstr "D-pad上"
+
+#: app/control-panes/control-panes-controller.js:41
+msgid "Dashboard"
+msgstr "ダッシュボード"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Data"
+msgstr "データ"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Date"
+msgstr "日付"
+
+#: app/components/stf/device/device-info-filter/index.js:48
+msgid "Dead"
+msgstr "残量なし"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Delete"
+msgstr "削除"
+
+#: app/control-panes/info/info.html:1
+msgid "Density"
+msgstr "表示密度"
+
+#: app/device-list/device-list.html:1
+msgid "Details"
+msgstr "詳細"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Developer"
+msgstr "開発者"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/inspect/inspect.html:1
+msgid "Device"
+msgstr "デバイス"
+
+#: app/device-list/details/device-list-details-directive.js:38
+#: app/device-list/icons/device-list-icons-directive.js:122
+msgid "Device cannot get kicked from the group"
+msgstr "このデバイスはグループからキックできません。"
+
+#: app/components/stf/device/device-info-filter/index.js:38
+msgid "Device is not present anymore for some reason."
+msgstr "実機が見えなくなりました。"
+
+#: app/components/stf/device/device-info-filter/index.js:39
+msgid "Device is present but offline."
+msgstr "デバイスは接続されているが、オフラインになっています。"
+
+#: app/control-panes/info/info.html:1
+msgid "Device Photo"
+msgstr "実機写真"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Device Settings"
+msgstr "実機設定"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+msgid "Device was disconnected"
+msgstr "デバイスへの接続が切れました"
+
+#: app/components/stf/device/device-info-filter/index.js:37
+msgid "Device was kicked by automatic timeout."
+msgstr "デバイスは自動タイムアウトにより切断されました。"
+
+#: app/device-list/device-list.html:1 app/menu/menu.html:1
+msgid "Devices"
+msgstr "端末リスト"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Disable WiFi"
+msgstr "無線LANを無効にする"
+
+#: app/components/stf/device/device-info-filter/index.js:68
+msgid "Discharging"
+msgstr "放電中"
+
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+#: app/components/stf/device/device-info-filter/index.js:20
+#: app/components/stf/device/device-info-filter/index.js:5
+msgid "Disconnected"
+msgstr "切断中"
+
+#: app/control-panes/info/info.html:1
+msgid "Display"
+msgstr "ディスプレー"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Drop file to upload"
+msgstr "ここにファイルをドロップ"
+
+#: app/components/stf/device/device-info-filter/index.js:90
+msgid "Dummy"
+msgstr "ダミー"
+
+#: app/settings/notifications/notifications.html:1
+msgid "Enable notifications"
+msgstr "通知を有効にする"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Enable WiFi"
+msgstr "無線LANを有効にする"
+
+#: app/control-panes/info/info.html:1
+msgid "Encrypted"
+msgstr "暗号化"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:31
+msgid "Error"
+msgstr "エラー"
+
+#: app/components/stf/control/control-service.js:129
+msgid "Error while getting data"
+msgstr "データ取得中にエラーが発生しました。"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:35
+msgid "Error while reconnecting"
+msgstr "再接続時にエラーが発生しました"
+
+#: app/components/stf/device/device-info-filter/index.js:91
+msgid "Ethernet"
+msgstr "イーサーネット"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Executes remote shell commands"
+msgstr "リモートシェルコマンドを実行する"
+
+#: app/components/stf/upload/upload-error-filter.js:5
+msgid "Failed to download file"
+msgstr "ファイルのダウンロードが失敗しした。"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Fast Forward"
+msgstr "早送り"
+
+#: app/control-panes/control-panes-controller.js:26
+msgid "File Explorer"
+msgstr "エクスプローラー"
+
+#: app/components/stf/common-ui/filter-button/filter-button.html:1
+msgid "Filter"
+msgstr "フィルター"
+
+#: app/control-panes/info/info.html:1
+msgid "Find Device"
+msgstr "実機を探す"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Fingerprint"
+msgstr "指紋"
+
+#: app/control-panes/info/info.html:1
+msgid "FPS"
+msgstr "FPS"
+
+#: app/control-panes/info/info.html:1
+msgid "Frequency"
+msgstr "クロック"
+
+#: app/components/stf/device/device-info-filter/index.js:69
+msgid "Full"
+msgstr "フル"
+
+#: app/settings/settings-controller.js:5
+msgid "General"
+msgstr "一般"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate Access Token"
+msgstr "アクセストークン生成"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Generate Login for VNC"
+msgstr "VNC用にログイン情報生成"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate New Token"
+msgstr "新規トークン生成"
+
+#: app/control-panes/logs/logs.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "Get"
+msgstr "取得"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Get clipboard contents"
+msgstr "クリップボードの中身を取得する"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Back"
+msgstr "戻る"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Forward"
+msgstr "進む"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:89
+msgid "Go to Device List"
+msgstr "端末リストへ"
+
+#: app/components/stf/device/device-info-filter/index.js:47
+msgid "Good"
+msgstr "良い"
+
+#: app/control-panes/info/info.html:1
+msgid "Hardware"
+msgstr "ハードウェア"
+
+#: app/control-panes/info/info.html:1
+msgid "Health"
+msgstr "健康状態"
+
+#: app/control-panes/info/info.html:1
+msgid "Height"
+msgstr "高さ"
+
+#: app/menu/menu.html:1
+msgid "Help"
+msgstr "ヘルプ"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Hide Screen"
+msgstr "画面を非表しない"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Home"
+msgstr "ホーム"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Host"
+msgstr "ホスト"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Hostname"
+msgstr "ホスト名"
+
+#: app/control-panes/info/info.html:1
+msgid "ICCID"
+msgstr "ICCID"
+
+#: app/control-panes/info/info.html:1
+msgid "ID"
+msgstr "ID"
+
+#: app/control-panes/info/info.html:1
+msgid "IMEI"
+msgstr "IMEI"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Incorrect login details"
+msgstr "不正ログイン情報"
+
+#: app/control-panes/control-panes-controller.js:32
+msgid "Info"
+msgstr "情報"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspect Device"
+msgstr "端末の要素検証"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspecting is currently only supported in WebView"
+msgstr "要素の検証機能は、現在WebViewのみ対応"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspector"
+msgstr "要素の検証"
+
+#: app/components/stf/install/install-error-filter.js:13
+msgid "Installation canceled by user."
+msgstr "インストールはユーザーによってキャンセルされました。"
+
+#: app/components/stf/install/install-error-filter.js:9
+msgid "Installation failed due to an unknown error."
+msgstr "インストールが未知のエラーで失敗しました。"
+
+#: app/components/stf/install/install-error-filter.js:7
+msgid "Installation succeeded."
+msgstr "インストールが完了しました。"
+
+#: app/components/stf/install/install-error-filter.js:11
+msgid "Installation timed out."
+msgstr "インストールがタイムアウトしました。"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Installing app..."
+msgstr "アプリをインストール中..."
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Key"
+msgstr "キー"
+
+#: app/settings/settings-controller.js:10
+msgid "Keys"
+msgstr "認証キー"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Landscape"
+msgstr "横"
+
+#: app/settings/general/language/language.html:1
+msgid "Language"
+msgstr "言語"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Launch Activity"
+msgstr "アクティビティを起動する"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Launching activity..."
+msgstr "アクティビティを起動中..."
+
+#: app/control-panes/info/info.html:1 app/control-panes/logs/logs.html:1
+msgid "Level"
+msgstr "レベル"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Local Settings"
+msgstr "ローカル設定"
+
+#: app/device-list/column/device-column-service.js:250
+msgid "Location"
+msgstr "場所"
+
+#: app/control-panes/automation/device-settings/device-settings.html:7
+msgid "Lock Rotation"
+msgstr "回転ロック"
+
+#: app/control-panes/control-panes-controller.js:50
+msgid "Logs"
+msgstr "ログ"
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Maintenance"
+msgstr "メンテナンス"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid ""
+"Make sure to copy your access token now. You won't be able to see it again."
+msgstr ""
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Manage Apps"
+msgstr "アプリ管理"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Manner Mode"
+msgstr "マナーモード"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:165
+msgid "Manufacturer"
+msgstr "メーカー"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Media"
+msgstr "メディア"
+
+#: app/control-panes/info/info.html:1
+msgid "Memory"
+msgstr "メモリー"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Menu"
+msgstr "メニュー"
+
+#: app/components/stf/device/device-info-filter/index.js:92
+msgid "Mobile"
+msgstr "モバイル"
+
+#: app/components/stf/device/device-info-filter/index.js:93
+msgid "Mobile DUN"
+msgstr "モバイルDUN"
+
+#: app/components/stf/device/device-info-filter/index.js:94
+msgid "Mobile High Priority"
+msgstr "モバイル最優先"
+
+#: app/components/stf/device/device-info-filter/index.js:95
+msgid "Mobile MMS"
+msgstr "モバイルMMS"
+
+#: app/components/stf/device/device-info-filter/index.js:96
+msgid "Mobile SUPL"
+msgstr "モバイルSUPL"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:31
+msgid "Model"
+msgstr "機種名"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "More about Access Tokens"
+msgstr "アクセストークンについて"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "More about ADB Keys"
+msgstr "ADBキーについて"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Mute"
+msgstr "音を消す"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Name"
+msgstr "名称"
+
+#: app/menu/menu.html:1
+msgid "Native"
+msgstr "Native"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Navigation"
+msgstr "ブラウジング"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:129
+msgid "Network"
+msgstr "ネットワーク"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Next"
+msgstr "次"
+
+#: app/components/stf/device/device-info-filter/index.js:115
+msgid "No"
+msgstr "いいえ"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "No access tokens"
+msgstr "アクセストークンはありません"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "No ADB keys"
+msgstr "ADBキーはありません"
+
+#: app/components/stf/control/control-service.js:126
+msgid "No clipboard data"
+msgstr "クリップボードデータはありません"
+
+#: app/control-panes/resources/resources.html:1
+msgid "No cookies to show"
+msgstr "クッキーはありません"
+
+#: app/components/stf/screen/screen.html:1
+msgid "No device screen"
+msgstr "画面が表示できません"
+
+#: app/device-list/empty/device-list-empty.html:1
+msgid "No devices connected"
+msgstr "端末が接続されていません"
+
+#: app/components/stf/common-ui/modals/lightbox-image/lightbox-image.html:1
+msgid "No photo available"
+msgstr "写真はありません"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "No Ports Forwarded"
+msgstr "フォワードされたポートはありません"
+
+#: app/control-panes/screenshots/screenshots.html:5
+msgid "No screenshots taken"
+msgstr "キャプチャはありません"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Normal Mode"
+msgstr "通常モード"
+
+#: app/components/stf/device/device-info-filter/index.js:70
+msgid "Not Charging"
+msgstr "充電されていない"
+
+#: app/device-list/column/device-column-service.js:256
+msgid "Notes"
+msgstr "注釈"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Nothing to inspect"
+msgstr "要素の検証対象はありません"
+
+#: app/settings/notifications/notifications.html:1
+msgid "Notifications"
+msgstr "通知"
+
+#: app/control-panes/info/info.html:1
+msgid "Number"
+msgstr "番号"
+
+#: app/components/stf/device/device-info-filter/index.js:22
+#: app/components/stf/device/device-info-filter/index.js:7
+msgid "Offline"
+msgstr "オフライン"
+
+#: app/components/stf/common-ui/error-message/error-message.html:1
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Oops!"
+msgstr "おっと!"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Open"
+msgstr "開く"
+
+#: app/control-panes/info/info.html:1
+msgid "Orientation"
+msgstr "方向"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:55
+msgid "OS"
+msgstr "OS"
+
+#: app/components/stf/device/device-info-filter/index.js:49
+msgid "Over Voltage"
+msgstr "過電圧"
+
+#: app/components/stf/device/device-info-filter/index.js:50
+msgid "Overheat"
+msgstr "過熱"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Package"
+msgstr "パッケージ"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Password"
+msgstr "パスワード"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Permissions"
+msgstr "権限"
+
+#: app/device-list/column/device-column-service.js:184
+msgid "Phone"
+msgstr "電話番号"
+
+#: app/device-list/column/device-column-service.js:196
+msgid "Phone ICCID"
+msgstr "携帯ICCID"
+
+#: app/device-list/column/device-column-service.js:190
+msgid "Phone IMEI"
+msgstr "携帯IMEI"
+
+#: app/control-panes/info/info.html:1
+msgid "Physical Device"
+msgstr "物理デバイス"
+
+#: app/control-panes/logs/logs.html:1
+msgid "PID"
+msgstr "PID"
+
+#: app/control-panes/info/info.html:1
+msgid "Place"
+msgstr "場所"
+
+#: app/control-panes/info/info.html:1
+msgid "Platform"
+msgstr "プラットホーム"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Play/Pause"
+msgstr "再生/停止"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter a valid email"
+msgstr "有効なメールアドレスを入力してください"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your email"
+msgstr "メールアドレスを入力してください"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your LDAP username"
+msgstr "LDAPユーザー名を入力してください"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your name"
+msgstr "お名前を入力してください"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your password"
+msgstr "パスワードを入力してください"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store password"
+msgstr "ストアのパスワードを入力してください"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store username"
+msgstr "ストアのユーザ名を入力してください"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Port"
+msgstr "ポート"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Port Forwarding"
+msgstr "ポートフォワーディング"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Portrait"
+msgstr "縦"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Power"
+msgstr "電源"
+
+#: app/control-panes/info/info.html:1
+msgid "Power Source"
+msgstr "電力源"
+
+#: app/components/stf/device/device-info-filter/index.js:24
+#: app/components/stf/device/device-info-filter/index.js:9
+msgid "Preparing"
+msgstr "準備中"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:104
+msgid "Press Back button"
+msgstr "戻るボタンを押す"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:103
+msgid "Press Home button"
+msgstr "ホームボタンを押す"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:102
+msgid "Press Menu button"
+msgstr "メニューボタンを押す"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Previous"
+msgstr "前"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Processing..."
+msgstr "処理中..."
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:37
+msgid "Product"
+msgstr "型番"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Pushing app..."
+msgstr "アプリをプッシュ中..."
+
+#: app/control-panes/info/info.html:1
+msgid "RAM"
+msgstr "RAM"
+
+#: app/components/stf/device/device-info-filter/index.js:10
+#: app/components/stf/device/device-info-filter/index.js:25
+msgid "Ready"
+msgstr "利用可能"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:39
+msgid "Reconnected successfully."
+msgstr "正常に再接続しました。"
+
+#: app/components/stf/common-ui/refresh-page/refresh-page.html:1
+msgid "Refresh"
+msgstr "更新"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:49
+msgid "Released"
+msgstr "発売日"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reload"
+msgstr "再読込"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug.html:1
+msgid "Remote debug"
+msgstr "リモートデバッグ"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "Remove"
+msgstr "削除"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+#: app/device-list/device-list.html:1
+msgid "Reset"
+msgstr "初期化"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reset all browser settings"
+msgstr "ブラウザの設定をリセットする"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Reset Settings"
+msgstr "すべての設定をリセット"
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Restart Device"
+msgstr "端末を再起動"
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retrieving the device screen has timed out."
+msgstr "実機画面の取得はタイムアウトになりました。"
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retry"
+msgstr "再試行"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Rewind"
+msgstr "巻き戻す"
+
+#: app/control-panes/info/info.html:1
+msgid "Roaming"
+msgstr "ローミング"
+
+#: app/control-panes/info/info.html:1
+msgid "ROM"
+msgstr "ROM"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:92
+msgid "Rotate Left"
+msgstr "左回りに回転"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:93
+msgid "Rotate Right"
+msgstr "右回りに回転"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run"
+msgstr "実行"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run JavaScript"
+msgstr "JavaScript注入"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:31
+msgid ""
+"Run the following on your command line to debug the device from your Browser"
+msgstr "次のコマンドをコマンドラインで実行しますと、お使いのブラウザより端末のデバッグができます。"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:28
+msgid ""
+"Run the following on your command line to debug the device from your IDE"
+msgstr "次のコマンドをコマンドラインで実行しますと、お使いのIDEより端末のデバッグができます。"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Run this command to copy the key to your clipboard"
+msgstr "次のコマンドを実行しますと、キーがコピーされます"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+msgid "Save ScreenShot"
+msgstr "スクリーンショットを保存する"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Save..."
+msgstr "保存する..."
+
+#: app/device-list/column/device-column-service.js:135
+msgid "Screen"
+msgstr "解像度"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Screenshot"
+msgstr "キャプチャ"
+
+#: app/control-panes/control-panes-controller.js:8
+msgid "Screenshots"
+msgstr "キャプチャ"
+
+#: app/control-panes/info/info.html:1
+msgid "SD Card Mounted"
+msgstr "SDカード"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:171
+msgid "SDK"
+msgstr "SDK"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Search"
+msgstr "検索"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:91
+msgid "Selects Next IME"
+msgstr "入力モードの切り替え"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:159
+msgid "Serial"
+msgstr "シリアル"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Server"
+msgstr "サーバー"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Server error. Check log output."
+msgstr "サーバーエラー。ログを確認してください。"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set"
+msgstr "設定"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set Cookie"
+msgstr "クッキー設定"
+
+#: app/control-panes/dashboard/apps/apps.html:1 app/menu/menu.html:1
+msgid "Settings"
+msgstr "設定"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Shell"
+msgstr "シェル"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Show Screen"
+msgstr "画面を表示する"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign In"
+msgstr "サインイン"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign Out"
+msgstr "サインアウト"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Silent Mode"
+msgstr "マナーモード"
+
+#: app/control-panes/info/info.html:1
+msgid "SIM"
+msgstr "SIM"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Size"
+msgstr "サイズ"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:26
+msgid "Socket connection was lost"
+msgstr "ソケットへの接続が切れました"
+
+#: app/components/stf/device/device-info-filter/index.js:36
+msgid "Someone stole your device."
+msgstr "誰かはデバイスを盗みました。"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Special Keys"
+msgstr "特別なキー"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Start/Stop Logging"
+msgstr "ログ取得の開始/停止"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:25
+msgid "Status"
+msgstr "ステータス"
+
+#: app/control-panes/advanced/input/input.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Stop"
+msgstr "停止"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/components/stf/device/device-info-filter/index.js:11
+#: app/control-panes/device-control/device-control.html:1
+msgid "Stop Using"
+msgstr "停止する"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Store Account"
+msgstr "ストアアカウント"
+
+#: app/control-panes/info/info.html:1
+msgid "Sub Type"
+msgstr "サブタイプ"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Switch Charset"
+msgstr "文字入力の切り替え"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Tag"
+msgstr "タグ"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Pageshot (Needs WebView running)"
+msgstr "ページ全体ショットを撮る(現在はWebViewのみ対応)"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Screenshot"
+msgstr "スクリーンショットを撮る"
+
+#: app/control-panes/info/info.html:1
+msgid "Temperature"
+msgstr "温度"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Text"
+msgstr "テキスト"
+
+#: app/components/stf/screen/screen.html:1
+msgid "The current view is marked secure and cannot be viewed remotely."
+msgstr ""
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:11
+msgid "The device will be unavailable for a moment."
+msgstr "しばらく端末が利用できなくなります。"
+
+#: app/components/stf/install/install-error-filter.js:34
+msgid "The existing package could not be deleted."
+msgstr "既存のパッケージは削除できませんでした。"
+
+#: app/components/stf/install/install-error-filter.js:58
+msgid ""
+"The new package couldn't be installed because the verification did not "
+"succeed."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:56
+msgid ""
+"The new package couldn't be installed because the verification timed out."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:54
+msgid ""
+"The new package couldn't be installed in the specified install location "
+"because the media is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:52
+msgid ""
+"The new package couldn't be installed in the specified install location."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:40
+msgid ""
+"The new package failed because it contains a content provider with thesame "
+"authority as a provider already installed in the system."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:44
+msgid ""
+"The new package failed because it has specified that it is a test-only "
+"package and the caller has not supplied the INSTALL_ALLOW_TEST flag."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:42
+msgid ""
+"The new package failed because the current SDK version is newer than that "
+"required by the package."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:38
+msgid ""
+"The new package failed because the current SDK version is older than that "
+"required by the package."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:36
+msgid ""
+"The new package failed while optimizing and validating its dex files, either"
+" because there was not enough storage or the validation failed."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:64
+msgid ""
+"The new package has an older version code than the currently installed "
+"package."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:62
+msgid "The new package is assigned a different UID than it previously held."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:48
+msgid "The new package uses a feature that is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:32
+msgid "The new package uses a shared library that is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:20
+msgid "The package archive file is invalid."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:46
+msgid ""
+"The package being installed contains native code, but none that is "
+"compatible with the device's CPU_ABI."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:60
+msgid "The package changed from what the calling program expected."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:18
+msgid "The package is already installed."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:24
+msgid ""
+"The package manager service found that the device didn't have enough storage"
+" space to install the app."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:84
+msgid ""
+"The parser did not find any actionable tags (instrumentation or application)"
+" in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:72
+msgid "The parser did not find any certificates in the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:78
+msgid "The parser encountered a bad or missing package name in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:80
+msgid "The parser encountered a bad shared user id name in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:76
+msgid ""
+"The parser encountered a CertificateEncodingException in one of the files in"
+" the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:70
+msgid "The parser encountered an unexpected exception."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:82
+msgid "The parser encountered some structural problem in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:74
+msgid "The parser found inconsistent certificates on the files in the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:66
+msgid ""
+"The parser was given a path that is not a file, or does not end with the "
+"expected '.apk' extension."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:68
+msgid "The parser was unable to retrieve the AndroidManifest.xml file."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:28
+msgid "The requested shared user does not exist."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:90
+msgid ""
+"The system failed to install the package because its packaged native code "
+"did not match any of the ABIs supported by the system."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:86
+msgid "The system failed to install the package because of system issues."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:88
+msgid ""
+"The system failed to install the package because the user is restricted from"
+" installing apps."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:22
+msgid "The URI passed in is invalid."
+msgstr "渡されたURIは無効です。"
+
+#: app/control-panes/logs/logs.html:1
+msgid "TID"
+msgstr "TID"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Time"
+msgstr "時刻"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Tip:"
+msgstr "ヒント:"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Title"
+msgstr "タイトル"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:107
+msgid "Toggle Web/Native"
+msgstr "ウェブ/ネイティブを選択"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Total Devices"
+msgstr "全機種"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "translate"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+msgid "Try to reconnect"
+msgstr "再接続する"
+
+#: app/control-panes/info/info.html:1
+msgid "Type"
+msgstr "タイプ"
+
+#: app/components/stf/device/device-info-filter/index.js:23
+#: app/components/stf/device/device-info-filter/index.js:8
+msgid "Unauthorized"
+msgstr "権限外"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uninstall"
+msgstr "削除"
+
+#: app/components/stf/device/device-info-filter/index.js:14
+#: app/components/stf/device/device-info-filter/index.js:29
+msgid "Unknown"
+msgstr "未知"
+
+#: app/components/stf/device/device-info-filter/index.js:40
+msgid "Unknown reason."
+msgstr "未知。"
+
+#: app/control-panes/automation/device-settings/device-settings.html:6
+msgid "Unlock Rotation"
+msgstr "回転アンロック"
+
+#: app/components/stf/device/device-info-filter/index.js:51
+msgid "Unspecified Failure"
+msgstr "未定義の失敗"
+
+#: app/components/stf/upload/upload-error-filter.js:7
+msgid "Upload failed"
+msgstr "アップロードが失敗しました"
+
+#: app/control-panes/dashboard/install/install.html:5
+msgid "Upload From Link"
+msgstr "リンク先よりアップロードする"
+
+#: app/components/stf/upload/upload-error-filter.js:8
+msgid "Upload unknown error"
+msgstr "アップロード未知エラー"
+
+#: app/components/stf/upload/upload-error-filter.js:4
+msgid "Uploaded file is not valid"
+msgstr "アップロードされたファイル"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uploading..."
+msgstr "アップロード中..."
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Usable Devices"
+msgstr "利用可能"
+
+#: app/components/stf/device/device-info-filter/index.js:59
+msgid "USB"
+msgstr "USB"
+
+#: app/control-panes/advanced/usb/usb.html:1
+msgid "Usb speed"
+msgstr "USB速度"
+
+#: app/components/stf/device/device-info-filter/index.js:13
+msgid "Use"
+msgstr "利用する"
+
+#: app/device-list/column/device-column-service.js:262
+msgid "User"
+msgstr "ユーザ"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Username"
+msgstr "ユーザ名"
+
+#: app/components/stf/device/device-info-filter/index.js:26
+msgid "Using"
+msgstr "利用中"
+
+#: app/control-panes/info/info.html:1
+msgid "Using Fallback"
+msgstr "フォールバックを使用中"
+
+#: app/control-panes/info/info.html:1
+msgid "Version"
+msgstr "バージョン"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "Version Update"
+msgstr "バージョンアップ"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Vibrate Mode"
+msgstr "マナーモード(バイブON)"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "VNC"
+msgstr "VNC"
+
+#: app/control-panes/info/info.html:1
+msgid "Voltage"
+msgstr "電圧"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume"
+msgstr "音量"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Down"
+msgstr "音量↓"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Up"
+msgstr "音量↑"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Warning:"
+msgstr "注意:"
+
+#: app/menu/menu.html:1
+msgid "Web"
+msgstr "Web"
+
+#: app/control-panes/info/info.html:1
+msgid "Width"
+msgstr "幅"
+
+#: app/components/stf/device/device-info-filter/index.js:105
+#: app/components/stf/device/device-info-filter/index.js:97
+#: app/control-panes/automation/device-settings/device-settings.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "WiFi"
+msgstr "無線LAN"
+
+#: app/components/stf/device/device-info-filter/index.js:98
+msgid "WiMAX"
+msgstr "WiMAX"
+
+#: app/components/stf/device/device-info-filter/index.js:60
+msgid "Wireless"
+msgstr "無線"
+
+#: app/control-panes/info/info.html:1
+msgid "X DPI"
+msgstr "X DPI"
+
+#: app/control-panes/info/info.html:1
+msgid "Y DPI"
+msgstr "Y DPI"
+
+#: app/components/stf/device/device-info-filter/index.js:113
+msgid "Yes"
+msgstr "はい"
+
+#: app/components/stf/device/device-info-filter/index.js:35
+msgid "You (or someone else) kicked the device."
+msgstr "この実機はキックされました。"
diff --git a/crowdstf/res/common/lang/po/stf.ko_KR.po b/crowdstf/res/common/lang/po/stf.ko_KR.po
new file mode 100644
index 0000000..df2a864
--- /dev/null
+++ b/crowdstf/res/common/lang/po/stf.ko_KR.po
@@ -0,0 +1,1555 @@
+# 
+# Translators:
+# JINYOUNGYOO <miss0110@naver.com>, 2015-2016
+msgid ""
+msgstr ""
+"Project-Id-Version: STF\n"
+"PO-Revision-Date: 2016-01-13 14:56+0000\n"
+"Last-Translator: JINYOUNGYOO <miss0110@naver.com>\n"
+"Language-Team: Korean (Korea) (http://www.transifex.com/openstf/stf/language/ko_KR/)\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: ko_KR\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+#: app/components/stf/device/device-info-filter/index.js:119
+#: app/components/stf/device/device-info-filter/index.js:52
+#: app/components/stf/device/device-info-filter/index.js:61
+#: app/components/stf/device/device-info-filter/index.js:71
+msgid "-"
+msgstr "-"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "A new version of STF is available"
+msgstr "새 버전의 STF를 사용 가능 합니다"
+
+#: app/components/stf/install/install-error-filter.js:26
+msgid "A package is already installed with the same name."
+msgstr "동일한 패키지가 설치되어 있습니다."
+
+#: app/components/stf/install/install-error-filter.js:30
+msgid ""
+"A previously installed package of the same name has a different signature "
+"than the new package (and the old package's data was not removed)."
+msgstr "이전에 설치된 패키지와 새로운 패키지의 서명이 다릅니다(또한 이전에 설치된 패키지 데이터가 삭제되지 않았습니다)."
+
+#: app/components/stf/install/install-error-filter.js:50
+msgid "A secure container mount point couldn't be accessed on external media."
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:178
+msgid "ABI"
+msgstr "ABI"
+
+#: app/components/stf/device/device-info-filter/index.js:58
+msgid "AC"
+msgstr "AC"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Access Tokens"
+msgstr "액세스 토큰"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Account"
+msgstr "계정"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Action"
+msgstr "동작"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Actions"
+msgstr "동작"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Activity"
+msgstr "액티비티"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "ADB Keys"
+msgstr "ADB 키"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Add"
+msgstr "추가"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add ADB Key"
+msgstr "ADB 키 추가"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add Key"
+msgstr "키 추가"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Add the following ADB Key to STF?"
+msgstr "다음 ADB 키를 STF에 추가 하시겠습니까?"
+
+#: app/layout/layout-controller.js:7
+msgid "Admin mode has been disabled."
+msgstr "관리자 모드가 비활성화 되었습니다."
+
+#: app/layout/layout-controller.js:6
+msgid "Admin mode has been enabled."
+msgstr "관리자 모드가 활성화 되었습니다."
+
+#: app/control-panes/control-panes-controller.js:20
+msgid "Advanced"
+msgstr "고급"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Advanced Input"
+msgstr "고급 입력"
+
+#: app/control-panes/info/info.html:1
+msgid "Airplane Mode"
+msgstr "비행기 모드"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "App Store"
+msgstr "앱 스토어"
+
+#: app/control-panes/dashboard/install/install.html:1
+msgid "App Upload"
+msgstr "앱 업로드"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Apps"
+msgstr "앱"
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:9
+msgid "Are you sure you want to reboot this device?"
+msgstr "해당 단말기를 재시작 하시겠습니까?"
+
+#: app/control-panes/control-panes-controller.js:14
+msgid "Automation"
+msgstr "자동화"
+
+#: app/components/stf/device/device-info-filter/index.js:28
+msgid "Available"
+msgstr "사용 가능"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Back"
+msgstr "뒤로 가기"
+
+#: app/control-panes/info/info.html:1
+msgid "Battery"
+msgstr "배터리"
+
+#: app/device-list/column/device-column-service.js:202
+msgid "Battery Health"
+msgstr "베터리 상태"
+
+#: app/device-list/column/device-column-service.js:226
+msgid "Battery Level"
+msgstr "베터리 수준"
+
+#: app/device-list/column/device-column-service.js:210
+msgid "Battery Source"
+msgstr "베터리 종류"
+
+#: app/device-list/column/device-column-service.js:218
+msgid "Battery Status"
+msgstr "배터리 충전 상태"
+
+#: app/device-list/column/device-column-service.js:239
+msgid "Battery Temp"
+msgstr "배터리 온도"
+
+#: app/components/stf/device/device-info-filter/index.js:89
+msgid "Bluetooth"
+msgstr "블루투스"
+
+#: app/device-list/column/device-column-service.js:153
+msgid "Browser"
+msgstr "브라우저"
+
+#: app/components/stf/device/device-info-filter/index.js:12
+#: app/components/stf/device/device-info-filter/index.js:27
+msgid "Busy"
+msgstr "점유중"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Busy Devices"
+msgstr "사용 중인 단말기"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Camera"
+msgstr "카메라"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Cancel"
+msgstr "취소"
+
+#: app/components/stf/upload/upload-error-filter.js:6
+msgid "Cannot access specified URL"
+msgstr "지정한 URL에 접근 할 수 없습니다"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:43
+msgid "Carrier"
+msgstr "통신사"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Category"
+msgstr "범주"
+
+#: app/components/stf/device/device-info-filter/index.js:67
+msgid "Charging"
+msgstr "충전중"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Check errors below"
+msgstr "아래 오류를 확인 하세요"
+
+#: app/components/stf/common-ui/clear-button/clear-button.html:1
+#: app/control-panes/advanced/run-js/run-js.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Clear"
+msgstr "지우기"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Clipboard"
+msgstr "클립보드"
+
+#: app/components/stf/device/device-info-filter/index.js:46
+msgid "Cold"
+msgstr "양호"
+
+#: app/components/stf/device/device-info-filter/index.js:21
+#: app/components/stf/device/device-info-filter/index.js:6
+#: app/control-panes/info/info.html:1
+msgid "Connected"
+msgstr "연결"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:20
+msgid "Connected successfully."
+msgstr "연결했습니다"
+
+#: app/menu/menu.html:1
+msgid "Control"
+msgstr "컨트롤 화면"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Cookies"
+msgstr "쿠키"
+
+#: app/control-panes/info/info.html:1
+msgid "Cores"
+msgstr "코어 종류"
+
+#: app/control-panes/info/info.html:1
+#: app/control-panes/performance/cpu/cpu.html:1
+msgid "CPU"
+msgstr "CPU"
+
+#: app/device-list/device-list.html:1
+msgid "Customize"
+msgstr "사용자 지정"
+
+#: app/control-panes/advanced/input/input.html:12
+msgid "D-pad Center"
+msgstr "D-pad 가운데"
+
+#: app/control-panes/advanced/input/input.html:20
+msgid "D-pad Down"
+msgstr "D-pad 아래쪽"
+
+#: app/control-panes/advanced/input/input.html:9
+msgid "D-pad Left"
+msgstr "D-pad 왼쪽"
+
+#: app/control-panes/advanced/input/input.html:15
+msgid "D-pad Right"
+msgstr "D-pad 오른쪽"
+
+#: app/control-panes/advanced/input/input.html:4
+msgid "D-pad Up"
+msgstr "D-pad 위쪽"
+
+#: app/control-panes/control-panes-controller.js:41
+msgid "Dashboard"
+msgstr "대시보드"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Data"
+msgstr "데이터"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Date"
+msgstr "날짜"
+
+#: app/components/stf/device/device-info-filter/index.js:48
+msgid "Dead"
+msgstr "정지"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Delete"
+msgstr "삭제"
+
+#: app/control-panes/info/info.html:1
+msgid "Density"
+msgstr "해상도"
+
+#: app/device-list/device-list.html:1
+msgid "Details"
+msgstr "세부 정보"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Developer"
+msgstr "개발자 옵션"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/inspect/inspect.html:1
+msgid "Device"
+msgstr "단말기"
+
+#: app/device-list/details/device-list-details-directive.js:39
+#: app/device-list/icons/device-list-icons-directive.js:123
+msgid "Device cannot get kicked from the group"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:38
+msgid "Device is not present anymore for some reason."
+msgstr "더이상 단말기가 존재하지 않습니다."
+
+#: app/components/stf/device/device-info-filter/index.js:39
+msgid "Device is present but offline."
+msgstr "장치가 연결되어 있지만 오프라인 상태입니다."
+
+#: app/control-panes/info/info.html:1
+msgid "Device Photo"
+msgstr "단말기 사진"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Device Settings"
+msgstr "단말기 설정"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+msgid "Device was disconnected"
+msgstr "연결이 끊어졌습니다"
+
+#: app/components/stf/device/device-info-filter/index.js:37
+msgid "Device was kicked by automatic timeout."
+msgstr "시간초과로 인해 단말기 사용이 종료되었습니다."
+
+#: app/device-list/device-list.html:1 app/menu/menu.html:1
+msgid "Devices"
+msgstr "단말기 리스트"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Disable WiFi"
+msgstr "WiFi 비활성화"
+
+#: app/components/stf/device/device-info-filter/index.js:68
+msgid "Discharging"
+msgstr "충전중이 아님"
+
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+#: app/components/stf/device/device-info-filter/index.js:20
+#: app/components/stf/device/device-info-filter/index.js:5
+msgid "Disconnected"
+msgstr "연결 끊김"
+
+#: app/control-panes/info/info.html:1
+msgid "Display"
+msgstr "화면"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Drop file to upload"
+msgstr "업로드 할 파일을 올려놓으세요"
+
+#: app/components/stf/device/device-info-filter/index.js:90
+msgid "Dummy"
+msgstr "더미"
+
+#: app/settings/notifications/notifications.html:1
+msgid "Enable notifications"
+msgstr "알림 사용"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Enable WiFi"
+msgstr "WiFi 활성화"
+
+#: app/control-panes/info/info.html:1
+msgid "Encrypted"
+msgstr "암호화"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:31
+msgid "Error"
+msgstr "오류"
+
+#: app/components/stf/control/control-service.js:129
+msgid "Error while getting data"
+msgstr "데이터를 얻어오는데 실패했습니다"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:35
+msgid "Error while reconnecting"
+msgstr "재연결이 실패 했습니다"
+
+#: app/components/stf/device/device-info-filter/index.js:91
+msgid "Ethernet"
+msgstr "이더넷"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Executes remote shell commands"
+msgstr "원격 쉘 명령을 실행합니다"
+
+#: app/components/stf/upload/upload-error-filter.js:5
+msgid "Failed to download file"
+msgstr "파일을 다운로드 할 수 없습니다"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Fast Forward"
+msgstr "빨리 감기"
+
+#: app/control-panes/control-panes-controller.js:26
+msgid "File Explorer"
+msgstr "파일 탐색기"
+
+#: app/components/stf/common-ui/filter-button/filter-button.html:1
+msgid "Filter"
+msgstr "필터"
+
+#: app/control-panes/info/info.html:1
+msgid "Find Device"
+msgstr "장치 찾기"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Fingerprint"
+msgstr "지문"
+
+#: app/control-panes/info/info.html:1
+msgid "FPS"
+msgstr "FPS"
+
+#: app/control-panes/info/info.html:1
+msgid "Frequency"
+msgstr "속도"
+
+#: app/components/stf/device/device-info-filter/index.js:69
+msgid "Full"
+msgstr "전체"
+
+#: app/settings/settings-controller.js:5
+msgid "General"
+msgstr "일반"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate Access Token"
+msgstr "액세스 토큰 생성"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Generate Login for VNC"
+msgstr "VNC 로그인 생성"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate New Token"
+msgstr "새로운 토큰 생성"
+
+#: app/control-panes/logs/logs.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "Get"
+msgstr "시작"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Get clipboard contents"
+msgstr "클립보드 내용을 가져옵니다"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Back"
+msgstr "뒤로 이동"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Forward"
+msgstr "앞으로 이동"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:89
+msgid "Go to Device List"
+msgstr "단말기 목록으로 이동"
+
+#: app/components/stf/device/device-info-filter/index.js:47
+msgid "Good"
+msgstr "양호"
+
+#: app/control-panes/info/info.html:1
+msgid "Hardware"
+msgstr "하드웨어"
+
+#: app/control-panes/info/info.html:1
+msgid "Health"
+msgstr "상태"
+
+#: app/control-panes/info/info.html:1
+msgid "Height"
+msgstr "높이"
+
+#: app/menu/menu.html:1
+msgid "Help"
+msgstr "도움말"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Hide Screen"
+msgstr "화면 숨김"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Home"
+msgstr "홈"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Host"
+msgstr "호스트"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Hostname"
+msgstr "호스트이름"
+
+#: app/control-panes/info/info.html:1
+msgid "ICCID"
+msgstr "ICCID"
+
+#: app/control-panes/info/info.html:1
+msgid "ID"
+msgstr "ID"
+
+#: app/control-panes/info/info.html:1
+msgid "IMEI"
+msgstr "IMEI"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Incorrect login details"
+msgstr "잘못된 로그인 정보"
+
+#: app/control-panes/control-panes-controller.js:32
+msgid "Info"
+msgstr "단말기 정보"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspect Device"
+msgstr "단말기 검사"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspecting is currently only supported in WebView"
+msgstr "검사는 웹뷰에서만 지원합니다"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspector"
+msgstr "검사기"
+
+#: app/components/stf/install/install-error-filter.js:13
+msgid "Installation canceled by user."
+msgstr "사용자가 설치를 취소했습니다"
+
+#: app/components/stf/install/install-error-filter.js:9
+msgid "Installation failed due to an unknown error."
+msgstr "알 수 없는 오류로 설치가 실패했습니다"
+
+#: app/components/stf/install/install-error-filter.js:7
+msgid "Installation succeeded."
+msgstr "설치가 성공했습니다"
+
+#: app/components/stf/install/install-error-filter.js:11
+msgid "Installation timed out."
+msgstr "설치 시간 초과"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Installing app..."
+msgstr "앱 설치중..."
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Key"
+msgstr "키"
+
+#: app/settings/settings-controller.js:10
+msgid "Keys"
+msgstr "키"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Landscape"
+msgstr "가로"
+
+#: app/settings/general/language/language.html:1
+msgid "Language"
+msgstr "언어"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Launch Activity"
+msgstr "액티비티 실행"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Launching activity..."
+msgstr "액티비티 실행중..."
+
+#: app/control-panes/info/info.html:1 app/control-panes/logs/logs.html:1
+msgid "Level"
+msgstr "수준"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Local Settings"
+msgstr "로컬 설정"
+
+#: app/device-list/column/device-column-service.js:250
+msgid "Location"
+msgstr "위치"
+
+#: app/control-panes/automation/device-settings/device-settings.html:7
+msgid "Lock Rotation"
+msgstr "화면 잠금"
+
+#: app/control-panes/control-panes-controller.js:50
+msgid "Logs"
+msgstr "로그"
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Maintenance"
+msgstr "유지 관리"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid ""
+"Make sure to copy your access token now. You won't be able to see it again!"
+msgstr "엑세스 토큰을 복사하세요. 다시 확인할 수 없습니다!"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Manage Apps"
+msgstr "앱 관리"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Manner Mode"
+msgstr "매너 모드"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:165
+msgid "Manufacturer"
+msgstr "제조사"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Media"
+msgstr "미디어"
+
+#: app/control-panes/info/info.html:1
+msgid "Memory"
+msgstr "메모리"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Menu"
+msgstr "메뉴"
+
+#: app/components/stf/device/device-info-filter/index.js:92
+msgid "Mobile"
+msgstr "모바일"
+
+#: app/components/stf/device/device-info-filter/index.js:93
+msgid "Mobile DUN"
+msgstr "모바일 DUN"
+
+#: app/components/stf/device/device-info-filter/index.js:94
+msgid "Mobile High Priority"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:95
+msgid "Mobile MMS"
+msgstr "모바일 MMS"
+
+#: app/components/stf/device/device-info-filter/index.js:96
+msgid "Mobile SUPL"
+msgstr "모바일 SUPL"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:31
+msgid "Model"
+msgstr "모델"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "More about Access Tokens"
+msgstr "좀 더 자세한 엑세스 토큰에 대해서 확인하기"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "More about ADB Keys"
+msgstr "좀 더 자세하게 ADB 키에 대해서 확인하기"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Mute"
+msgstr "음소거"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Name"
+msgstr "이름"
+
+#: app/menu/menu.html:1
+msgid "Native"
+msgstr "네이티브"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Navigation"
+msgstr "탐색"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:129
+msgid "Network"
+msgstr "네트워크"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Next"
+msgstr "다음"
+
+#: app/components/stf/device/device-info-filter/index.js:116
+msgid "No"
+msgstr "아니오"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "No access tokens"
+msgstr "등록된 엑세스 토큰이 없습니다"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "No ADB keys"
+msgstr "등록된 ADB 키가 없습니다"
+
+#: app/components/stf/control/control-service.js:126
+msgid "No clipboard data"
+msgstr "클립보드 데이터가 없습니다"
+
+#: app/control-panes/resources/resources.html:1
+msgid "No cookies to show"
+msgstr "어떤 쿠키도 없습니다"
+
+#: app/components/stf/screen/screen.html:1
+msgid "No device screen"
+msgstr ""
+
+#: app/device-list/empty/device-list-empty.html:1
+msgid "No devices connected"
+msgstr "연결된 단말기가 없습니다"
+
+#: app/components/stf/common-ui/modals/lightbox-image/lightbox-image.html:1
+msgid "No photo available"
+msgstr "이용 가능한 사진이 없습니다"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "No Ports Forwarded"
+msgstr "포트 포워딩 설정이 없습니다"
+
+#: app/control-panes/screenshots/screenshots.html:5
+msgid "No screenshots taken"
+msgstr "저장된 스크린샷이 없습니다"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Normal Mode"
+msgstr "표준 모드"
+
+#: app/components/stf/device/device-info-filter/index.js:70
+msgid "Not Charging"
+msgstr "충전 안함"
+
+#: app/device-list/column/device-column-service.js:256
+msgid "Notes"
+msgstr "메모"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Nothing to inspect"
+msgstr ""
+
+#: app/settings/notifications/notifications.html:1
+msgid "Notifications"
+msgstr "알림"
+
+#: app/control-panes/info/info.html:1
+msgid "Number"
+msgstr "전화번호"
+
+#: app/components/stf/device/device-info-filter/index.js:22
+#: app/components/stf/device/device-info-filter/index.js:7
+msgid "Offline"
+msgstr "오프라인"
+
+#: app/components/stf/common-ui/error-message/error-message.html:1
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Oops!"
+msgstr "웁스!"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Open"
+msgstr "열기"
+
+#: app/control-panes/info/info.html:1
+msgid "Orientation"
+msgstr "화면 방향"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:55
+msgid "OS"
+msgstr "운영체제"
+
+#: app/components/stf/device/device-info-filter/index.js:49
+msgid "Over Voltage"
+msgstr "과전압"
+
+#: app/components/stf/device/device-info-filter/index.js:50
+msgid "Overheat"
+msgstr "과열"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Package"
+msgstr "패키지"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Password"
+msgstr "비밀번호"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Permissions"
+msgstr "권한"
+
+#: app/device-list/column/device-column-service.js:184
+msgid "Phone"
+msgstr "휴대폰"
+
+#: app/device-list/column/device-column-service.js:196
+msgid "Phone ICCID"
+msgstr "휴대폰 ICCID"
+
+#: app/device-list/column/device-column-service.js:190
+msgid "Phone IMEI"
+msgstr "휴대폰 IMEI"
+
+#: app/control-panes/info/info.html:1
+msgid "Physical Device"
+msgstr "물리 단말기"
+
+#: app/control-panes/logs/logs.html:1
+msgid "PID"
+msgstr "PID"
+
+#: app/control-panes/info/info.html:1
+msgid "Place"
+msgstr "위치"
+
+#: app/control-panes/info/info.html:1
+msgid "Platform"
+msgstr "플랫폼"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Play/Pause"
+msgstr "재생/일시 중지"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter a valid email"
+msgstr "유효한 이메일 주소를 입력하세요"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your email"
+msgstr "이메일 주소를 입력하세요"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your LDAP username"
+msgstr "LDAP 사용자 이름을 입력하세요"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your name"
+msgstr "이름을 입력하세요"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your password"
+msgstr "비밀번호를 입력하세요"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store password"
+msgstr "앱 스토어 비밀번호를 입력하세요"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store username"
+msgstr "앱 스토어 아이디를 입력하세요"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Port"
+msgstr "포트"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Port Forwarding"
+msgstr "포트 포워딩"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Portrait"
+msgstr "세로"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Power"
+msgstr "전력"
+
+#: app/control-panes/info/info.html:1
+msgid "Power Source"
+msgstr "전력원"
+
+#: app/components/stf/device/device-info-filter/index.js:24
+#: app/components/stf/device/device-info-filter/index.js:9
+msgid "Preparing"
+msgstr "준비중"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:104
+msgid "Press Back button"
+msgstr "뒤로가기 버튼을 누르세요"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:103
+msgid "Press Home button"
+msgstr "홈 버튼을 누르세요"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:102
+msgid "Press Menu button"
+msgstr "메뉴 버튼을 누르세요"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Previous"
+msgstr "이전"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Processing..."
+msgstr "처리중..."
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:37
+msgid "Product"
+msgstr "제품명"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Pushing app..."
+msgstr "앱 전송중..."
+
+#: app/control-panes/info/info.html:1
+msgid "RAM"
+msgstr "RAM"
+
+#: app/components/stf/device/device-info-filter/index.js:10
+#: app/components/stf/device/device-info-filter/index.js:25
+msgid "Ready"
+msgstr ""
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:39
+msgid "Reconnected successfully."
+msgstr "재연결이 성공했습니다"
+
+#: app/components/stf/common-ui/refresh-page/refresh-page.html:1
+msgid "Refresh"
+msgstr "새로고침"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:49
+msgid "Released"
+msgstr "릴리즈"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reload"
+msgstr "다시 로드"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug.html:1
+msgid "Remote debug"
+msgstr "원격 디버그"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "Remove"
+msgstr "제거"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+#: app/device-list/device-list.html:1
+msgid "Reset"
+msgstr "초기화"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reset all browser settings"
+msgstr "모든 브라우저 설정 초기화"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Reset Settings"
+msgstr "설정 초기화"
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Restart Device"
+msgstr "단말기 재시작"
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retrieving the device screen has timed out."
+msgstr "단말기 화면을 가져 오는 시간이 초과 되었습니다."
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retry"
+msgstr "재시도"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Rewind"
+msgstr "되감기"
+
+#: app/control-panes/info/info.html:1
+msgid "Roaming"
+msgstr "로밍"
+
+#: app/control-panes/info/info.html:1
+msgid "ROM"
+msgstr "ROM"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:92
+msgid "Rotate Left"
+msgstr "왼쪽으로 회전"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:93
+msgid "Rotate Right"
+msgstr "오른쪽으로 회전"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run"
+msgstr "실행"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run JavaScript"
+msgstr "자바스크립트 실행"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:31
+msgid ""
+"Run the following on your command line to debug the device from your Browser"
+msgstr ""
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:28
+msgid ""
+"Run the following on your command line to debug the device from your IDE"
+msgstr "아래의 명령줄을 실행하여 IDE에서 디버그를 실행하세요"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Run this command to copy the key to your clipboard"
+msgstr ""
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+msgid "Save ScreenShot"
+msgstr "스크린 샷 저장"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Save..."
+msgstr "저장..."
+
+#: app/device-list/column/device-column-service.js:135
+msgid "Screen"
+msgstr "화면"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Screenshot"
+msgstr "스크린 샷"
+
+#: app/control-panes/control-panes-controller.js:8
+msgid "Screenshots"
+msgstr "스크린 샷"
+
+#: app/control-panes/info/info.html:1
+msgid "SD Card Mounted"
+msgstr "SD카드 마운트"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:171
+msgid "SDK"
+msgstr "SDK"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Search"
+msgstr "검색"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:91
+msgid "Selects Next IME"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:159
+msgid "Serial"
+msgstr "일련 번호"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Server"
+msgstr "서버"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Server error. Check log output."
+msgstr "서버 에러. 로그를 확인하세요"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set"
+msgstr "Set"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set Cookie"
+msgstr "쿠키 설정"
+
+#: app/control-panes/dashboard/apps/apps.html:1 app/menu/menu.html:1
+msgid "Settings"
+msgstr "설정"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Shell"
+msgstr "셸"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Show Screen"
+msgstr "화면 표시"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign In"
+msgstr "로그인"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign Out"
+msgstr "로그아웃"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Silent Mode"
+msgstr "음소거"
+
+#: app/control-panes/info/info.html:1
+msgid "SIM"
+msgstr "SIM"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Size"
+msgstr "크기"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:26
+msgid "Socket connection was lost"
+msgstr "소켓 연결이 끊겼습니다"
+
+#: app/components/stf/device/device-info-filter/index.js:36
+msgid "Someone stole your device."
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Special Keys"
+msgstr "특수 키"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Start/Stop Logging"
+msgstr "시작/종료 로깅"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:25
+msgid "Status"
+msgstr "상태"
+
+#: app/control-panes/advanced/input/input.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Stop"
+msgstr "정지"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/components/stf/device/device-info-filter/index.js:11
+msgid "Stop Using"
+msgstr "사용 종료"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Store Account"
+msgstr "저장소 계정"
+
+#: app/control-panes/info/info.html:1
+msgid "Sub Type"
+msgstr "하위 유형"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Switch Charset"
+msgstr "문자 집합 변경"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Tag"
+msgstr "태그"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Pageshot (Needs WebView running)"
+msgstr ""
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Screenshot"
+msgstr "스크린샷 캡처"
+
+#: app/control-panes/info/info.html:1
+msgid "Temperature"
+msgstr "온도"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Text"
+msgstr "텍스트"
+
+#: app/components/stf/screen/screen.html:1
+msgid "The current view is marked secure and cannot be viewed remotely."
+msgstr ""
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:10
+msgid "The device will be unavailable for a moment."
+msgstr "이 단말기는 잠시동안 사용 할 수 없습니다."
+
+#: app/components/stf/install/install-error-filter.js:34
+msgid "The existing package could not be deleted."
+msgstr "기존 패키지를 삭제 할 수 없습니다."
+
+#: app/components/stf/install/install-error-filter.js:58
+msgid ""
+"The new package couldn't be installed because the verification did not "
+"succeed."
+msgstr "검증되지 않은 새로운 패키지는 설치 할 수 없습니다."
+
+#: app/components/stf/install/install-error-filter.js:56
+msgid ""
+"The new package couldn't be installed because the verification timed out."
+msgstr "검증 시간이 초과하여 새로운 패키지를 설치 할 수 없습니다."
+
+#: app/components/stf/install/install-error-filter.js:54
+msgid ""
+"The new package couldn't be installed in the specified install location "
+"because the media is not available."
+msgstr "미디어를 사용할 수 없어 지정한 위치에 새로운 패키지를 설치 할 수 없습니다."
+
+#: app/components/stf/install/install-error-filter.js:52
+msgid ""
+"The new package couldn't be installed in the specified install location."
+msgstr "지정한 위치에 새로운 패키지를 설치 할 수 없습니다."
+
+#: app/components/stf/install/install-error-filter.js:40
+msgid ""
+"The new package failed because it contains a content provider with thesame "
+"authority as a provider already installed in the system."
+msgstr "이미 동일한 패키지가 설치되어 있어 설치 할 수 없습니다."
+
+#: app/components/stf/install/install-error-filter.js:44
+msgid ""
+"The new package failed because it has specified that it is a test-only "
+"package and the caller has not supplied the INSTALL_ALLOW_TEST flag."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:42
+msgid ""
+"The new package failed because the current SDK version is newer than that "
+"required by the package."
+msgstr "SDK 버전이 높아 새로운 패키지를 설치 할 수 없습니다."
+
+#: app/components/stf/install/install-error-filter.js:38
+msgid ""
+"The new package failed because the current SDK version is older than that "
+"required by the package."
+msgstr "SDK 버전이 낮아 새로운 패키지를 설치 할 수 없습니다."
+
+#: app/components/stf/install/install-error-filter.js:36
+msgid ""
+"The new package failed while optimizing and validating its dex files, either"
+" because there was not enough storage or the validation failed."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:64
+msgid ""
+"The new package has an older version code than the currently installed "
+"package."
+msgstr "새로운 패키지에 오래된 버전의 코드가 존재하여 설치 할 수 없습니다."
+
+#: app/components/stf/install/install-error-filter.js:62
+msgid "The new package is assigned a different UID than it previously held."
+msgstr "새로운 패키지는 이전 패키지와 다른 UID가 할당 됐습니다."
+
+#: app/components/stf/install/install-error-filter.js:48
+msgid "The new package uses a feature that is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:32
+msgid "The new package uses a shared library that is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:20
+msgid "The package archive file is invalid."
+msgstr "패키지 아카이브 파일이 잘못 되었습니다."
+
+#: app/components/stf/install/install-error-filter.js:46
+msgid ""
+"The package being installed contains native code, but none that is "
+"compatible with the device's CPU_ABI."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:60
+msgid "The package changed from what the calling program expected."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:18
+msgid "The package is already installed."
+msgstr "패키지가 이미 설치 되어 있습니다."
+
+#: app/components/stf/install/install-error-filter.js:24
+msgid ""
+"The package manager service found that the device didn't have enough storage"
+" space to install the app."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:84
+msgid ""
+"The parser did not find any actionable tags (instrumentation or application)"
+" in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:72
+msgid "The parser did not find any certificates in the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:78
+msgid "The parser encountered a bad or missing package name in the manifest."
+msgstr "매니페스트에 잘못되거나 누락된 패키지 이름을 발견했습니다."
+
+#: app/components/stf/install/install-error-filter.js:80
+msgid "The parser encountered a bad shared user id name in the manifest."
+msgstr "매니페스트에 잘못된 아이디나 이름을 발견했습니다."
+
+#: app/components/stf/install/install-error-filter.js:76
+msgid ""
+"The parser encountered a CertificateEncodingException in one of the files in"
+" the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:70
+msgid "The parser encountered an unexpected exception."
+msgstr "예상하지 못한 예외가 발생하였습니다."
+
+#: app/components/stf/install/install-error-filter.js:82
+msgid "The parser encountered some structural problem in the manifest."
+msgstr "매니페스트에 몇 가지 구조적인 문제가 발생 했습니다."
+
+#: app/components/stf/install/install-error-filter.js:74
+msgid "The parser found inconsistent certificates on the files in the .apk."
+msgstr ".apk 파일에서 일치하지 않은 인증서를 발견 했습니다."
+
+#: app/components/stf/install/install-error-filter.js:66
+msgid ""
+"The parser was given a path that is not a file, or does not end with the "
+"expected '.apk' extension."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:68
+msgid "The parser was unable to retrieve the AndroidManifest.xml file."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:28
+msgid "The requested shared user does not exist."
+msgstr "요청된 공용 사용자가 존재하지 않습니다."
+
+#: app/components/stf/install/install-error-filter.js:90
+msgid ""
+"The system failed to install the package because its packaged native code "
+"did not match any of the ABIs supported by the system."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:86
+msgid "The system failed to install the package because of system issues."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:88
+msgid ""
+"The system failed to install the package because the user is restricted from"
+" installing apps."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:22
+msgid "The URI passed in is invalid."
+msgstr "URL이 잘못 전달 됐습니다."
+
+#: app/control-panes/logs/logs.html:1
+msgid "TID"
+msgstr "TID"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Time"
+msgstr "시간"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Tip:"
+msgstr "팁"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Title"
+msgstr "제목"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:107
+msgid "Toggle Web/Native"
+msgstr "웹/네이티브 전환"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Total Devices"
+msgstr "총 단말기 수"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "translate"
+msgstr "번역"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+msgid "Try to reconnect"
+msgstr "다시 연결"
+
+#: app/control-panes/info/info.html:1
+msgid "Type"
+msgstr "유형"
+
+#: app/components/stf/device/device-info-filter/index.js:23
+#: app/components/stf/device/device-info-filter/index.js:8
+msgid "Unauthorized"
+msgstr "미인증"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uninstall"
+msgstr "설치 제거"
+
+#: app/components/stf/device/device-info-filter/index.js:14
+#: app/components/stf/device/device-info-filter/index.js:29
+msgid "Unknown"
+msgstr "알 수 없음"
+
+#: app/components/stf/device/device-info-filter/index.js:40
+msgid "Unknown reason."
+msgstr "알 수 없는 이유"
+
+#: app/control-panes/automation/device-settings/device-settings.html:6
+msgid "Unlock Rotation"
+msgstr "회전 잠금 해제"
+
+#: app/components/stf/device/device-info-filter/index.js:51
+msgid "Unspecified Failure"
+msgstr "지정되지 않은 오류"
+
+#: app/components/stf/upload/upload-error-filter.js:7
+msgid "Upload failed"
+msgstr "업로드 실패"
+
+#: app/control-panes/dashboard/install/install.html:5
+msgid "Upload From Link"
+msgstr "링크로 업로드"
+
+#: app/components/stf/upload/upload-error-filter.js:8
+msgid "Upload unknown error"
+msgstr "업로드시 알수 없는 에러가 발생하였습니다"
+
+#: app/components/stf/upload/upload-error-filter.js:4
+msgid "Uploaded file is not valid"
+msgstr "업로드된 파일이 유효하지 않습니다"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uploading..."
+msgstr "업로드중..."
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Usable Devices"
+msgstr "사용 가능한 단말기"
+
+#: app/components/stf/device/device-info-filter/index.js:59
+msgid "USB"
+msgstr "USB"
+
+#: app/control-panes/advanced/usb/usb.html:1
+msgid "Usb speed"
+msgstr "Usb 속도"
+
+#: app/components/stf/device/device-info-filter/index.js:13
+msgid "Use"
+msgstr "사용"
+
+#: app/device-list/column/device-column-service.js:262
+msgid "User"
+msgstr "사용자"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Username"
+msgstr "사용자 이름"
+
+#: app/components/stf/device/device-info-filter/index.js:26
+msgid "Using"
+msgstr "사용중"
+
+#: app/control-panes/info/info.html:1
+msgid "Using Fallback"
+msgstr "대체"
+
+#: app/control-panes/info/info.html:1
+msgid "Version"
+msgstr "버전"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "Version Update"
+msgstr "버전 업데이트"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Vibrate Mode"
+msgstr "진동"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "VNC"
+msgstr "VNC"
+
+#: app/control-panes/info/info.html:1
+msgid "Voltage"
+msgstr "전압"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume"
+msgstr "음량"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Down"
+msgstr "음량 줄이기"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Up"
+msgstr "음량 올리기"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Warning:"
+msgstr "경고"
+
+#: app/menu/menu.html:1
+msgid "Web"
+msgstr "웹"
+
+#: app/control-panes/info/info.html:1
+msgid "Width"
+msgstr "너비"
+
+#: app/components/stf/device/device-info-filter/index.js:105
+#: app/components/stf/device/device-info-filter/index.js:97
+#: app/control-panes/automation/device-settings/device-settings.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "WiFi"
+msgstr "WiFi"
+
+#: app/components/stf/device/device-info-filter/index.js:98
+msgid "WiMAX"
+msgstr "WiMAX"
+
+#: app/components/stf/device/device-info-filter/index.js:60
+msgid "Wireless"
+msgstr "무선"
+
+#: app/control-panes/info/info.html:1
+msgid "X DPI"
+msgstr "X DPI"
+
+#: app/control-panes/info/info.html:1
+msgid "Y DPI"
+msgstr "Y DPI"
+
+#: app/components/stf/device/device-info-filter/index.js:113
+msgid "Yes"
+msgstr "네"
+
+#: app/components/stf/device/device-info-filter/index.js:35
+msgid "You (or someone else) kicked the device."
+msgstr "당신(혹은 다른 누군가)이 단말기를 사용 종료 하였습니다."
diff --git a/crowdstf/res/common/lang/po/stf.pl.po b/crowdstf/res/common/lang/po/stf.pl.po
new file mode 100644
index 0000000..011f8e2
--- /dev/null
+++ b/crowdstf/res/common/lang/po/stf.pl.po
@@ -0,0 +1,1561 @@
+# 
+# Translators:
+# Jakub Mucha <biuro@muchastudio.com.pl>, 2016
+# Mateusz Bartos <mbartos@wikia-inc.com>, 2015
+msgid ""
+msgstr ""
+"Project-Id-Version: STF\n"
+"PO-Revision-Date: 2016-03-30 08:24+0000\n"
+"Last-Translator: takeshimiya <takeshimiya@gmail.com>\n"
+"Language-Team: Polish (http://www.transifex.com/openstf/stf/language/pl/)\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: pl\n"
+"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+
+#: app/components/stf/device/device-info-filter/index.js:117
+#: app/components/stf/device/device-info-filter/index.js:52
+#: app/components/stf/device/device-info-filter/index.js:61
+#: app/components/stf/device/device-info-filter/index.js:71
+msgid "-"
+msgstr "-"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "A new version of STF is available"
+msgstr "Nowa wersja STF jest dostępna"
+
+#: app/components/stf/install/install-error-filter.js:26
+msgid "A package is already installed with the same name."
+msgstr "Pakiet o tej samej nazwie jest już zainstalowany"
+
+#: app/components/stf/install/install-error-filter.js:30
+msgid ""
+"A previously installed package of the same name has a different signature "
+"than the new package (and the old package's data was not removed)."
+msgstr "Poprzednio zainstalowana paczka o tej samej nazwie ma inną sygnaturę od nowej (oraz dane starej paczki nie zostały usunięte)."
+
+#: app/components/stf/install/install-error-filter.js:50
+msgid "A secure container mount point couldn't be accessed on external media."
+msgstr "Zewnętrzne media nie mogą uzyskać dostępu do punktu montowania zabezpieczonego kontenera."
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:178
+msgid "ABI"
+msgstr "ABI"
+
+#: app/components/stf/device/device-info-filter/index.js:58
+msgid "AC"
+msgstr "AC"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Access Tokens"
+msgstr "Tokeny dostępu"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Account"
+msgstr "Konto"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Action"
+msgstr "Akcja"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Actions"
+msgstr "Akcje"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Activity"
+msgstr "Aktywność"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "ADB Keys"
+msgstr "Klucze ADB"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Add"
+msgstr "Dodaj"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add ADB Key"
+msgstr "Dodaj klucz ADB"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add Key"
+msgstr "Dodaj klucz"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Add the following ADB Key to STF?"
+msgstr "Dodać następujący klucz ADB do STF?"
+
+#: app/layout/layout-controller.js:7
+msgid "Admin mode has been disabled."
+msgstr "Tryb admina został wyłączony."
+
+#: app/layout/layout-controller.js:6
+msgid "Admin mode has been enabled."
+msgstr "Tryb admina został włączony"
+
+#: app/control-panes/control-panes-controller.js:20
+msgid "Advanced"
+msgstr "Zaawansowany"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Advanced Input"
+msgstr "Wprowadzanie zaawansowane"
+
+#: app/control-panes/info/info.html:1
+msgid "Airplane Mode"
+msgstr "Tryb samolotowy"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "App Store"
+msgstr "Sklep"
+
+#: app/control-panes/dashboard/install/install.html:1
+msgid "App Upload"
+msgstr "Wysyłanie aplikacji"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Apps"
+msgstr "Aplikacje"
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:10
+msgid "Are you sure you want to reboot this device?"
+msgstr "Czy na pewno chcesz zrestartować to urządzenie?"
+
+#: app/control-panes/control-panes-controller.js:14
+msgid "Automation"
+msgstr "Automatyzacja"
+
+#: app/components/stf/device/device-info-filter/index.js:28
+msgid "Available"
+msgstr "Dostępny"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Back"
+msgstr "Wróć"
+
+#: app/control-panes/info/info.html:1
+msgid "Battery"
+msgstr "Bateria"
+
+#: app/device-list/column/device-column-service.js:202
+msgid "Battery Health"
+msgstr "Kondycja baterii"
+
+#: app/device-list/column/device-column-service.js:226
+msgid "Battery Level"
+msgstr "Poziom baterii"
+
+#: app/device-list/column/device-column-service.js:210
+msgid "Battery Source"
+msgstr "Źródło baterii"
+
+#: app/device-list/column/device-column-service.js:218
+msgid "Battery Status"
+msgstr "Status baterii"
+
+#: app/device-list/column/device-column-service.js:239
+msgid "Battery Temp"
+msgstr "Temperatura baterii"
+
+#: app/components/stf/device/device-info-filter/index.js:89
+msgid "Bluetooth"
+msgstr "Bluetooth"
+
+#: app/device-list/column/device-column-service.js:153
+msgid "Browser"
+msgstr "Przeglądarka"
+
+#: app/components/stf/device/device-info-filter/index.js:12
+#: app/components/stf/device/device-info-filter/index.js:27
+msgid "Busy"
+msgstr "Zajęty"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Busy Devices"
+msgstr "Zajęte urządzenia"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Camera"
+msgstr "Aparat"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Cancel"
+msgstr "Anuluj"
+
+#: app/components/stf/upload/upload-error-filter.js:6
+msgid "Cannot access specified URL"
+msgstr "Nie można uzyskać dostępu do podanego adresu URL"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:43
+msgid "Carrier"
+msgstr "Kariera"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Category"
+msgstr "Kategoria"
+
+#: app/components/stf/device/device-info-filter/index.js:67
+msgid "Charging"
+msgstr "Ładowanie"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Check errors below"
+msgstr "Zwróć uwagę na błędy"
+
+#: app/components/stf/common-ui/clear-button/clear-button.html:1
+#: app/control-panes/advanced/run-js/run-js.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Clear"
+msgstr "Czysty"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Clipboard"
+msgstr "Schowek"
+
+#: app/components/stf/device/device-info-filter/index.js:46
+msgid "Cold"
+msgstr "Chłodny"
+
+#: app/components/stf/device/device-info-filter/index.js:21
+#: app/components/stf/device/device-info-filter/index.js:6
+#: app/control-panes/info/info.html:1
+msgid "Connected"
+msgstr "Połączony"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:20
+msgid "Connected successfully."
+msgstr "Połączono pomyślnie."
+
+#: app/menu/menu.html:1
+msgid "Control"
+msgstr "Kontrola"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Cookies"
+msgstr "Ciasteczka"
+
+#: app/control-panes/info/info.html:1
+msgid "Cores"
+msgstr "Rdzenia"
+
+#: app/control-panes/info/info.html:1
+#: app/control-panes/performance/cpu/cpu.html:1
+msgid "CPU"
+msgstr "CPU"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Current rotation:"
+msgstr ""
+
+#: app/device-list/device-list.html:1
+msgid "Customize"
+msgstr "Personalizuj"
+
+#: app/control-panes/advanced/input/input.html:12
+msgid "D-pad Center"
+msgstr "D-Pad Środek"
+
+#: app/control-panes/advanced/input/input.html:20
+msgid "D-pad Down"
+msgstr "D-pad Dół"
+
+#: app/control-panes/advanced/input/input.html:9
+msgid "D-pad Left"
+msgstr "D-pad Lewo"
+
+#: app/control-panes/advanced/input/input.html:15
+msgid "D-pad Right"
+msgstr "D-pad Prawo"
+
+#: app/control-panes/advanced/input/input.html:4
+msgid "D-pad Up"
+msgstr "D-pad Góra"
+
+#: app/control-panes/control-panes-controller.js:41
+msgid "Dashboard"
+msgstr "Kokpit"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Data"
+msgstr "Dane"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Date"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:48
+msgid "Dead"
+msgstr "Martwy"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Delete"
+msgstr "Usuń"
+
+#: app/control-panes/info/info.html:1
+msgid "Density"
+msgstr "Gęstość"
+
+#: app/device-list/device-list.html:1
+msgid "Details"
+msgstr "Szczegóły"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Developer"
+msgstr "Developer"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/inspect/inspect.html:1
+msgid "Device"
+msgstr "Urządzenie"
+
+#: app/device-list/details/device-list-details-directive.js:38
+#: app/device-list/icons/device-list-icons-directive.js:122
+msgid "Device cannot get kicked from the group"
+msgstr "Urządzenie nie może zostać wyrzucone z grupy"
+
+#: app/components/stf/device/device-info-filter/index.js:38
+msgid "Device is not present anymore for some reason."
+msgstr "Urządzenie z jakiegoś powodu nie jest już dostępne"
+
+#: app/components/stf/device/device-info-filter/index.js:39
+msgid "Device is present but offline."
+msgstr "Urządzenie jest dostępne, lecz jest offline."
+
+#: app/control-panes/info/info.html:1
+msgid "Device Photo"
+msgstr "Zdjęcie urządzenia"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Device Settings"
+msgstr "Ustawienia urządzenia"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+msgid "Device was disconnected"
+msgstr "Urządzenie zostało odłączone"
+
+#: app/components/stf/device/device-info-filter/index.js:37
+msgid "Device was kicked by automatic timeout."
+msgstr "Urządzenie zostało wyrzucone z powodu braku aktywności."
+
+#: app/device-list/device-list.html:1 app/menu/menu.html:1
+msgid "Devices"
+msgstr "Urządzenia"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Disable WiFi"
+msgstr "Wyłącz WiFi"
+
+#: app/components/stf/device/device-info-filter/index.js:68
+msgid "Discharging"
+msgstr "Rozładowywanie"
+
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+#: app/components/stf/device/device-info-filter/index.js:20
+#: app/components/stf/device/device-info-filter/index.js:5
+msgid "Disconnected"
+msgstr "Odłączone"
+
+#: app/control-panes/info/info.html:1
+msgid "Display"
+msgstr "Wyświetlacz"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Drop file to upload"
+msgstr "Upuść plik aby go wysłać"
+
+#: app/components/stf/device/device-info-filter/index.js:90
+msgid "Dummy"
+msgstr "Głupie"
+
+#: app/settings/notifications/notifications.html:1
+msgid "Enable notifications"
+msgstr "Włącz powiadomienia"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Enable WiFi"
+msgstr "Włącz WiFi"
+
+#: app/control-panes/info/info.html:1
+msgid "Encrypted"
+msgstr "Zaszyfrowane"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:31
+msgid "Error"
+msgstr "Błąd"
+
+#: app/components/stf/control/control-service.js:129
+msgid "Error while getting data"
+msgstr "Zapytanie o dane zakończone błędem"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:35
+msgid "Error while reconnecting"
+msgstr "Ponowne połączenie nie powiodło się"
+
+#: app/components/stf/device/device-info-filter/index.js:91
+msgid "Ethernet"
+msgstr "Ethernet"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Executes remote shell commands"
+msgstr "Wykonuje zdalne polecenia powłoki"
+
+#: app/components/stf/upload/upload-error-filter.js:5
+msgid "Failed to download file"
+msgstr "Pobieranie zakończone niepowodzeniem"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Fast Forward"
+msgstr "Przekaż do"
+
+#: app/control-panes/control-panes-controller.js:26
+msgid "File Explorer"
+msgstr "Przeglądaj pliki"
+
+#: app/components/stf/common-ui/filter-button/filter-button.html:1
+msgid "Filter"
+msgstr "Filtr"
+
+#: app/control-panes/info/info.html:1
+msgid "Find Device"
+msgstr "Znajdź urządzenie"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Fingerprint"
+msgstr "Odcisk palca"
+
+#: app/control-panes/info/info.html:1
+msgid "FPS"
+msgstr "FPS"
+
+#: app/control-panes/info/info.html:1
+msgid "Frequency"
+msgstr "Częstotliwość"
+
+#: app/components/stf/device/device-info-filter/index.js:69
+msgid "Full"
+msgstr "Pełny"
+
+#: app/settings/settings-controller.js:5
+msgid "General"
+msgstr "Ogólne"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate Access Token"
+msgstr "Generuj token dostępu"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Generate Login for VNC"
+msgstr "Generuj login do VNC"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate New Token"
+msgstr "Wygeneruj nowy token"
+
+#: app/control-panes/logs/logs.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "Get"
+msgstr "Weź"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Get clipboard contents"
+msgstr "Kopiuj zawartość schowka"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Back"
+msgstr "Wróć"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Forward"
+msgstr "Przejdź do"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:89
+msgid "Go to Device List"
+msgstr "Przejdź do listy urządzeń"
+
+#: app/components/stf/device/device-info-filter/index.js:47
+msgid "Good"
+msgstr "Dobrze"
+
+#: app/control-panes/info/info.html:1
+msgid "Hardware"
+msgstr "Hardware"
+
+#: app/control-panes/info/info.html:1
+msgid "Health"
+msgstr "Kondycja"
+
+#: app/control-panes/info/info.html:1
+msgid "Height"
+msgstr "Wysokość"
+
+#: app/menu/menu.html:1
+msgid "Help"
+msgstr "Pomoc"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Hide Screen"
+msgstr "Ukryj ekran"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Home"
+msgstr "Dom"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Host"
+msgstr "Gospodarz"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Hostname"
+msgstr "Nazwa gospodarza"
+
+#: app/control-panes/info/info.html:1
+msgid "ICCID"
+msgstr "ICCID"
+
+#: app/control-panes/info/info.html:1
+msgid "ID"
+msgstr "ID"
+
+#: app/control-panes/info/info.html:1
+msgid "IMEI"
+msgstr "IMEI"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Incorrect login details"
+msgstr "Niepoprawne dane logowania"
+
+#: app/control-panes/control-panes-controller.js:32
+msgid "Info"
+msgstr "Info"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspect Device"
+msgstr "Zbadaj urządzenie"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspecting is currently only supported in WebView"
+msgstr ""
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspector"
+msgstr "Inspektor"
+
+#: app/components/stf/install/install-error-filter.js:13
+msgid "Installation canceled by user."
+msgstr "Instalacja anulowana przez użytkownika."
+
+#: app/components/stf/install/install-error-filter.js:9
+msgid "Installation failed due to an unknown error."
+msgstr "Instalacja nie powiodła się z powodu nieznanego błedu."
+
+#: app/components/stf/install/install-error-filter.js:7
+msgid "Installation succeeded."
+msgstr "Instalacja powiodła się."
+
+#: app/components/stf/install/install-error-filter.js:11
+msgid "Installation timed out."
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Installing app..."
+msgstr "Instalowanie aplikacji.."
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Key"
+msgstr "Klucz"
+
+#: app/settings/settings-controller.js:10
+msgid "Keys"
+msgstr "Klucze"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Landscape"
+msgstr ""
+
+#: app/settings/general/language/language.html:1
+msgid "Language"
+msgstr "Język"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Launch Activity"
+msgstr "Uruchom aktywność"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Launching activity..."
+msgstr "Uruchamianie aktywności.."
+
+#: app/control-panes/info/info.html:1 app/control-panes/logs/logs.html:1
+msgid "Level"
+msgstr "Poziom"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Local Settings"
+msgstr "Ustawienia lokalne"
+
+#: app/device-list/column/device-column-service.js:250
+msgid "Location"
+msgstr "Lokacja"
+
+#: app/control-panes/automation/device-settings/device-settings.html:7
+msgid "Lock Rotation"
+msgstr "Zablokuj rotację"
+
+#: app/control-panes/control-panes-controller.js:50
+msgid "Logs"
+msgstr "Logi"
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Maintenance"
+msgstr ""
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid ""
+"Make sure to copy your access token now. You won't be able to see it again."
+msgstr ""
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Manage Apps"
+msgstr "Zarządzaj aplikacjami"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Manner Mode"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:165
+msgid "Manufacturer"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Media"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Memory"
+msgstr "Pamięć"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Menu"
+msgstr "Menu"
+
+#: app/components/stf/device/device-info-filter/index.js:92
+msgid "Mobile"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:93
+msgid "Mobile DUN"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:94
+msgid "Mobile High Priority"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:95
+msgid "Mobile MMS"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:96
+msgid "Mobile SUPL"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:31
+msgid "Model"
+msgstr "Model"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "More about Access Tokens"
+msgstr ""
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "More about ADB Keys"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Mute"
+msgstr "Wycisz"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Name"
+msgstr "Nazwa"
+
+#: app/menu/menu.html:1
+msgid "Native"
+msgstr ""
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Navigation"
+msgstr "Nawigacja"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:129
+msgid "Network"
+msgstr "Sieć"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Next"
+msgstr "Dalej"
+
+#: app/components/stf/device/device-info-filter/index.js:115
+msgid "No"
+msgstr "Nie"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "No access tokens"
+msgstr "Brak kluczy dostępu"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "No ADB keys"
+msgstr "Brak kluczy ADB"
+
+#: app/components/stf/control/control-service.js:126
+msgid "No clipboard data"
+msgstr "Brak danych w schowku"
+
+#: app/control-panes/resources/resources.html:1
+msgid "No cookies to show"
+msgstr "Brak ciastek do pokazania"
+
+#: app/components/stf/screen/screen.html:1
+msgid "No device screen"
+msgstr "Brak obrazu urządzenia"
+
+#: app/device-list/empty/device-list-empty.html:1
+msgid "No devices connected"
+msgstr "Brak podłączonych urządzeń"
+
+#: app/components/stf/common-ui/modals/lightbox-image/lightbox-image.html:1
+msgid "No photo available"
+msgstr "Brak zdjęć"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "No Ports Forwarded"
+msgstr "Brak przekierowań portów"
+
+#: app/control-panes/screenshots/screenshots.html:5
+msgid "No screenshots taken"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Normal Mode"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:70
+msgid "Not Charging"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:256
+msgid "Notes"
+msgstr ""
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Nothing to inspect"
+msgstr ""
+
+#: app/settings/notifications/notifications.html:1
+msgid "Notifications"
+msgstr "Notyfikacje"
+
+#: app/control-panes/info/info.html:1
+msgid "Number"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:22
+#: app/components/stf/device/device-info-filter/index.js:7
+msgid "Offline"
+msgstr "Offline"
+
+#: app/components/stf/common-ui/error-message/error-message.html:1
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Oops!"
+msgstr "Ups!"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Open"
+msgstr "Otwórz"
+
+#: app/control-panes/info/info.html:1
+msgid "Orientation"
+msgstr "Orientacja"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:55
+msgid "OS"
+msgstr "OS"
+
+#: app/components/stf/device/device-info-filter/index.js:49
+msgid "Over Voltage"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:50
+msgid "Overheat"
+msgstr ""
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Package"
+msgstr "Paczka"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Password"
+msgstr "Hasło"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Permissions"
+msgstr "Pozwolenia"
+
+#: app/device-list/column/device-column-service.js:184
+msgid "Phone"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:196
+msgid "Phone ICCID"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:190
+msgid "Phone IMEI"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Physical Device"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "PID"
+msgstr "PID"
+
+#: app/control-panes/info/info.html:1
+msgid "Place"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Platform"
+msgstr "Platforma"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Play/Pause"
+msgstr ""
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter a valid email"
+msgstr "Proszę wpisać poprawny adres email"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your email"
+msgstr "Proszę wpisać adres email"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your LDAP username"
+msgstr ""
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your name"
+msgstr ""
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your password"
+msgstr "Proszę wpisać swoje hasło"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store password"
+msgstr "Proszę wpisać swoje hasło do sklepu"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store username"
+msgstr ""
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Port"
+msgstr "Port"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Port Forwarding"
+msgstr "Przekierowanie portów"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Portrait"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Power"
+msgstr "Zasilanie"
+
+#: app/control-panes/info/info.html:1
+msgid "Power Source"
+msgstr "Źródło zasilania"
+
+#: app/components/stf/device/device-info-filter/index.js:24
+#: app/components/stf/device/device-info-filter/index.js:9
+msgid "Preparing"
+msgstr "Przygotowywanie"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:104
+msgid "Press Back button"
+msgstr "Naciśnij klawisz \"Wstecz\""
+
+#: app/control-panes/control-panes-hotkeys-controller.js:103
+msgid "Press Home button"
+msgstr "Naciśnij klawicz \"Home\""
+
+#: app/control-panes/control-panes-hotkeys-controller.js:102
+msgid "Press Menu button"
+msgstr "Naciśnij klawicz \"Menu\""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Previous"
+msgstr "Poprzednie"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Processing..."
+msgstr "Przetwarzanie.."
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:37
+msgid "Product"
+msgstr "Produkt"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Pushing app..."
+msgstr "Pushowanie aplikacji.."
+
+#: app/control-panes/info/info.html:1
+msgid "RAM"
+msgstr "RAM"
+
+#: app/components/stf/device/device-info-filter/index.js:10
+#: app/components/stf/device/device-info-filter/index.js:25
+msgid "Ready"
+msgstr "Gotowe"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:39
+msgid "Reconnected successfully."
+msgstr ""
+
+#: app/components/stf/common-ui/refresh-page/refresh-page.html:1
+msgid "Refresh"
+msgstr "Odśwież"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:49
+msgid "Released"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reload"
+msgstr ""
+
+#: app/control-panes/dashboard/remote-debug/remote-debug.html:1
+msgid "Remote debug"
+msgstr ""
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "Remove"
+msgstr "Usuń"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+#: app/device-list/device-list.html:1
+msgid "Reset"
+msgstr ""
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reset all browser settings"
+msgstr ""
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Reset Settings"
+msgstr ""
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Restart Device"
+msgstr ""
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retrieving the device screen has timed out."
+msgstr ""
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retry"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Rewind"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Roaming"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "ROM"
+msgstr ""
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:92
+msgid "Rotate Left"
+msgstr ""
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:93
+msgid "Rotate Right"
+msgstr ""
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run"
+msgstr ""
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run JavaScript"
+msgstr ""
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:31
+msgid ""
+"Run the following on your command line to debug the device from your Browser"
+msgstr ""
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:28
+msgid ""
+"Run the following on your command line to debug the device from your IDE"
+msgstr ""
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Run this command to copy the key to your clipboard"
+msgstr ""
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+msgid "Save ScreenShot"
+msgstr ""
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Save..."
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:135
+msgid "Screen"
+msgstr ""
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Screenshot"
+msgstr ""
+
+#: app/control-panes/control-panes-controller.js:8
+msgid "Screenshots"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "SD Card Mounted"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:171
+msgid "SDK"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Search"
+msgstr ""
+
+#: app/control-panes/control-panes-hotkeys-controller.js:91
+msgid "Selects Next IME"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:159
+msgid "Serial"
+msgstr ""
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Server"
+msgstr ""
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Server error. Check log output."
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set"
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set Cookie"
+msgstr ""
+
+#: app/control-panes/dashboard/apps/apps.html:1 app/menu/menu.html:1
+msgid "Settings"
+msgstr ""
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Shell"
+msgstr ""
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Show Screen"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign In"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign Out"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Silent Mode"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "SIM"
+msgstr ""
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Size"
+msgstr ""
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:26
+msgid "Socket connection was lost"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:36
+msgid "Someone stole your device."
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Special Keys"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "Start/Stop Logging"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:25
+msgid "Status"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Stop"
+msgstr ""
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/components/stf/device/device-info-filter/index.js:11
+#: app/control-panes/device-control/device-control.html:1
+msgid "Stop Using"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Store Account"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Sub Type"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Switch Charset"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "Tag"
+msgstr ""
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Pageshot (Needs WebView running)"
+msgstr ""
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Screenshot"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Temperature"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "Text"
+msgstr ""
+
+#: app/components/stf/screen/screen.html:1
+msgid "The current view is marked secure and cannot be viewed remotely."
+msgstr ""
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:11
+msgid "The device will be unavailable for a moment."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:34
+msgid "The existing package could not be deleted."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:58
+msgid ""
+"The new package couldn't be installed because the verification did not "
+"succeed."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:56
+msgid ""
+"The new package couldn't be installed because the verification timed out."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:54
+msgid ""
+"The new package couldn't be installed in the specified install location "
+"because the media is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:52
+msgid ""
+"The new package couldn't be installed in the specified install location."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:40
+msgid ""
+"The new package failed because it contains a content provider with thesame "
+"authority as a provider already installed in the system."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:44
+msgid ""
+"The new package failed because it has specified that it is a test-only "
+"package and the caller has not supplied the INSTALL_ALLOW_TEST flag."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:42
+msgid ""
+"The new package failed because the current SDK version is newer than that "
+"required by the package."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:38
+msgid ""
+"The new package failed because the current SDK version is older than that "
+"required by the package."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:36
+msgid ""
+"The new package failed while optimizing and validating its dex files, either"
+" because there was not enough storage or the validation failed."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:64
+msgid ""
+"The new package has an older version code than the currently installed "
+"package."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:62
+msgid "The new package is assigned a different UID than it previously held."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:48
+msgid "The new package uses a feature that is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:32
+msgid "The new package uses a shared library that is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:20
+msgid "The package archive file is invalid."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:46
+msgid ""
+"The package being installed contains native code, but none that is "
+"compatible with the device's CPU_ABI."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:60
+msgid "The package changed from what the calling program expected."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:18
+msgid "The package is already installed."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:24
+msgid ""
+"The package manager service found that the device didn't have enough storage"
+" space to install the app."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:84
+msgid ""
+"The parser did not find any actionable tags (instrumentation or application)"
+" in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:72
+msgid "The parser did not find any certificates in the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:78
+msgid "The parser encountered a bad or missing package name in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:80
+msgid "The parser encountered a bad shared user id name in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:76
+msgid ""
+"The parser encountered a CertificateEncodingException in one of the files in"
+" the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:70
+msgid "The parser encountered an unexpected exception."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:82
+msgid "The parser encountered some structural problem in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:74
+msgid "The parser found inconsistent certificates on the files in the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:66
+msgid ""
+"The parser was given a path that is not a file, or does not end with the "
+"expected '.apk' extension."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:68
+msgid "The parser was unable to retrieve the AndroidManifest.xml file."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:28
+msgid "The requested shared user does not exist."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:90
+msgid ""
+"The system failed to install the package because its packaged native code "
+"did not match any of the ABIs supported by the system."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:86
+msgid "The system failed to install the package because of system issues."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:88
+msgid ""
+"The system failed to install the package because the user is restricted from"
+" installing apps."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:22
+msgid "The URI passed in is invalid."
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "TID"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "Time"
+msgstr ""
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Tip:"
+msgstr ""
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Title"
+msgstr ""
+
+#: app/control-panes/control-panes-hotkeys-controller.js:107
+msgid "Toggle Web/Native"
+msgstr ""
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Total Devices"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "translate"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+msgid "Try to reconnect"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Type"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:23
+#: app/components/stf/device/device-info-filter/index.js:8
+msgid "Unauthorized"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uninstall"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:14
+#: app/components/stf/device/device-info-filter/index.js:29
+msgid "Unknown"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:40
+msgid "Unknown reason."
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:6
+msgid "Unlock Rotation"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:51
+msgid "Unspecified Failure"
+msgstr ""
+
+#: app/components/stf/upload/upload-error-filter.js:7
+msgid "Upload failed"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:5
+msgid "Upload From Link"
+msgstr ""
+
+#: app/components/stf/upload/upload-error-filter.js:8
+msgid "Upload unknown error"
+msgstr ""
+
+#: app/components/stf/upload/upload-error-filter.js:4
+msgid "Uploaded file is not valid"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uploading..."
+msgstr ""
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Usable Devices"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:59
+msgid "USB"
+msgstr ""
+
+#: app/control-panes/advanced/usb/usb.html:1
+msgid "Usb speed"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:13
+msgid "Use"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:262
+msgid "User"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Username"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:26
+msgid "Using"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Using Fallback"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Version"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "Version Update"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Vibrate Mode"
+msgstr ""
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "VNC"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Voltage"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Down"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Up"
+msgstr ""
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Warning:"
+msgstr ""
+
+#: app/menu/menu.html:1
+msgid "Web"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Width"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:105
+#: app/components/stf/device/device-info-filter/index.js:97
+#: app/control-panes/automation/device-settings/device-settings.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "WiFi"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:98
+msgid "WiMAX"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:60
+msgid "Wireless"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "X DPI"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Y DPI"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:113
+msgid "Yes"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:35
+msgid "You (or someone else) kicked the device."
+msgstr ""
diff --git a/crowdstf/res/common/lang/po/stf.pot b/crowdstf/res/common/lang/po/stf.pot
new file mode 100644
index 0000000..ab17d87
--- /dev/null
+++ b/crowdstf/res/common/lang/po/stf.pot
@@ -0,0 +1,1518 @@
+msgid ""
+msgstr ""
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Project-Id-Version: \n"
+
+#: app/components/stf/device/device-info-filter/index.js:117
+#: app/components/stf/device/device-info-filter/index.js:52
+#: app/components/stf/device/device-info-filter/index.js:61
+#: app/components/stf/device/device-info-filter/index.js:71
+msgid "-"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "A new version of STF is available"
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:26
+msgid "A package is already installed with the same name."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:30
+msgid "A previously installed package of the same name has a different signature than the new package (and the old package's data was not removed)."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:50
+msgid "A secure container mount point couldn't be accessed on external media."
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:178
+msgid "ABI"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:58
+msgid "AC"
+msgstr ""
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Access Tokens"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Account"
+msgstr ""
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Action"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Actions"
+msgstr ""
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Activity"
+msgstr ""
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "ADB Keys"
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Add"
+msgstr ""
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add ADB Key"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add Key"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Add the following ADB Key to STF?"
+msgstr ""
+
+#: app/layout/layout-controller.js:7
+msgid "Admin mode has been disabled."
+msgstr ""
+
+#: app/layout/layout-controller.js:6
+msgid "Admin mode has been enabled."
+msgstr ""
+
+#: app/control-panes/control-panes-controller.js:20
+msgid "Advanced"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Advanced Input"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Airplane Mode"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "App Store"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:1
+msgid "App Upload"
+msgstr ""
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Apps"
+msgstr ""
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:10
+msgid "Are you sure you want to reboot this device?"
+msgstr ""
+
+#: app/control-panes/control-panes-controller.js:14
+msgid "Automation"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:28
+msgid "Available"
+msgstr ""
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Back"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Battery"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:202
+msgid "Battery Health"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:226
+msgid "Battery Level"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:210
+msgid "Battery Source"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:218
+msgid "Battery Status"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:239
+msgid "Battery Temp"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:89
+msgid "Bluetooth"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:153
+msgid "Browser"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:12
+#: app/components/stf/device/device-info-filter/index.js:27
+msgid "Busy"
+msgstr ""
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Busy Devices"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Camera"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Cancel"
+msgstr ""
+
+#: app/components/stf/upload/upload-error-filter.js:6
+msgid "Cannot access specified URL"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:43
+msgid "Carrier"
+msgstr ""
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Category"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:67
+msgid "Charging"
+msgstr ""
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Check errors below"
+msgstr ""
+
+#: app/components/stf/common-ui/clear-button/clear-button.html:1
+#: app/control-panes/advanced/run-js/run-js.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Clear"
+msgstr ""
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Clipboard"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:46
+msgid "Cold"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:21
+#: app/components/stf/device/device-info-filter/index.js:6
+#: app/control-panes/info/info.html:1
+msgid "Connected"
+msgstr ""
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:20
+msgid "Connected successfully."
+msgstr ""
+
+#: app/menu/menu.html:1
+msgid "Control"
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Cookies"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Cores"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/control-panes/performance/cpu/cpu.html:1
+msgid "CPU"
+msgstr ""
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Current rotation:"
+msgstr ""
+
+#: app/device-list/device-list.html:1
+msgid "Customize"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:12
+msgid "D-pad Center"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:20
+msgid "D-pad Down"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:9
+msgid "D-pad Left"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:15
+msgid "D-pad Right"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:4
+msgid "D-pad Up"
+msgstr ""
+
+#: app/control-panes/control-panes-controller.js:41
+msgid "Dashboard"
+msgstr ""
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Data"
+msgstr ""
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Date"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:48
+msgid "Dead"
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Delete"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Density"
+msgstr ""
+
+#: app/device-list/device-list.html:1
+msgid "Details"
+msgstr ""
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Developer"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/inspect/inspect.html:1
+msgid "Device"
+msgstr ""
+
+#: app/device-list/details/device-list-details-directive.js:38
+#: app/device-list/icons/device-list-icons-directive.js:122
+msgid "Device cannot get kicked from the group"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:38
+msgid "Device is not present anymore for some reason."
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:39
+msgid "Device is present but offline."
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Device Photo"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Device Settings"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+msgid "Device was disconnected"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:37
+msgid "Device was kicked by automatic timeout."
+msgstr ""
+
+#: app/device-list/device-list.html:1
+#: app/menu/menu.html:1
+msgid "Devices"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Disable WiFi"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:68
+msgid "Discharging"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+#: app/components/stf/device/device-info-filter/index.js:20
+#: app/components/stf/device/device-info-filter/index.js:5
+msgid "Disconnected"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Display"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Drop file to upload"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:90
+msgid "Dummy"
+msgstr ""
+
+#: app/settings/notifications/notifications.html:1
+msgid "Enable notifications"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Enable WiFi"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Encrypted"
+msgstr ""
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:31
+msgid "Error"
+msgstr ""
+
+#: app/components/stf/control/control-service.js:129
+msgid "Error while getting data"
+msgstr ""
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:35
+msgid "Error while reconnecting"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:91
+msgid "Ethernet"
+msgstr ""
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Executes remote shell commands"
+msgstr ""
+
+#: app/components/stf/upload/upload-error-filter.js:5
+msgid "Failed to download file"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Fast Forward"
+msgstr ""
+
+#: app/control-panes/control-panes-controller.js:26
+msgid "File Explorer"
+msgstr ""
+
+#: app/components/stf/common-ui/filter-button/filter-button.html:1
+msgid "Filter"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Find Device"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Fingerprint"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "FPS"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Frequency"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:69
+msgid "Full"
+msgstr ""
+
+#: app/settings/settings-controller.js:5
+msgid "General"
+msgstr ""
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate Access Token"
+msgstr ""
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Generate Login for VNC"
+msgstr ""
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate New Token"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "Get"
+msgstr ""
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Get clipboard contents"
+msgstr ""
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Back"
+msgstr ""
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Forward"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:89
+msgid "Go to Device List"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:47
+msgid "Good"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Hardware"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Health"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Height"
+msgstr ""
+
+#: app/menu/menu.html:1
+msgid "Help"
+msgstr ""
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Hide Screen"
+msgstr ""
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Home"
+msgstr ""
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Host"
+msgstr ""
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Hostname"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "ICCID"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "ID"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "IMEI"
+msgstr ""
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Incorrect login details"
+msgstr ""
+
+#: app/control-panes/control-panes-controller.js:32
+msgid "Info"
+msgstr ""
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspect Device"
+msgstr ""
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspecting is currently only supported in WebView"
+msgstr ""
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspector"
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:13
+msgid "Installation canceled by user."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:9
+msgid "Installation failed due to an unknown error."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:7
+msgid "Installation succeeded."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:11
+msgid "Installation timed out."
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Installing app..."
+msgstr ""
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Key"
+msgstr ""
+
+#: app/settings/settings-controller.js:10
+msgid "Keys"
+msgstr ""
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Landscape"
+msgstr ""
+
+#: app/settings/general/language/language.html:1
+msgid "Language"
+msgstr ""
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Launch Activity"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Launching activity..."
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Level"
+msgstr ""
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Local Settings"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:250
+msgid "Location"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:7
+msgid "Lock Rotation"
+msgstr ""
+
+#: app/control-panes/control-panes-controller.js:50
+msgid "Logs"
+msgstr ""
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Maintenance"
+msgstr ""
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Make sure to copy your access token now. You won't be able to see it again."
+msgstr ""
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Manage Apps"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Manner Mode"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:165
+msgid "Manufacturer"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Media"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Memory"
+msgstr ""
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Menu"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:92
+msgid "Mobile"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:93
+msgid "Mobile DUN"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:94
+msgid "Mobile High Priority"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:95
+msgid "Mobile MMS"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:96
+msgid "Mobile SUPL"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:31
+msgid "Model"
+msgstr ""
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "More about Access Tokens"
+msgstr ""
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "More about ADB Keys"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Mute"
+msgstr ""
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Name"
+msgstr ""
+
+#: app/menu/menu.html:1
+msgid "Native"
+msgstr ""
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Navigation"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:129
+msgid "Network"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Next"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:115
+msgid "No"
+msgstr ""
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "No access tokens"
+msgstr ""
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "No ADB keys"
+msgstr ""
+
+#: app/components/stf/control/control-service.js:126
+msgid "No clipboard data"
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "No cookies to show"
+msgstr ""
+
+#: app/components/stf/screen/screen.html:1
+msgid "No device screen"
+msgstr ""
+
+#: app/device-list/empty/device-list-empty.html:1
+msgid "No devices connected"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/lightbox-image/lightbox-image.html:1
+msgid "No photo available"
+msgstr ""
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "No Ports Forwarded"
+msgstr ""
+
+#: app/control-panes/screenshots/screenshots.html:5
+msgid "No screenshots taken"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Normal Mode"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:70
+msgid "Not Charging"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:256
+msgid "Notes"
+msgstr ""
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Nothing to inspect"
+msgstr ""
+
+#: app/settings/notifications/notifications.html:1
+msgid "Notifications"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Number"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:22
+#: app/components/stf/device/device-info-filter/index.js:7
+msgid "Offline"
+msgstr ""
+
+#: app/components/stf/common-ui/error-message/error-message.html:1
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Oops!"
+msgstr ""
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Open"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Orientation"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:55
+msgid "OS"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:49
+msgid "Over Voltage"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:50
+msgid "Overheat"
+msgstr ""
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Package"
+msgstr ""
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Password"
+msgstr ""
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Permissions"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:184
+msgid "Phone"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:196
+msgid "Phone ICCID"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:190
+msgid "Phone IMEI"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Physical Device"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "PID"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Place"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Platform"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Play/Pause"
+msgstr ""
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter a valid email"
+msgstr ""
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your email"
+msgstr ""
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your LDAP username"
+msgstr ""
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your name"
+msgstr ""
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your password"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store password"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store username"
+msgstr ""
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Port"
+msgstr ""
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Port Forwarding"
+msgstr ""
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Portrait"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Power"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Power Source"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:24
+#: app/components/stf/device/device-info-filter/index.js:9
+msgid "Preparing"
+msgstr ""
+
+#: app/control-panes/control-panes-hotkeys-controller.js:104
+msgid "Press Back button"
+msgstr ""
+
+#: app/control-panes/control-panes-hotkeys-controller.js:103
+msgid "Press Home button"
+msgstr ""
+
+#: app/control-panes/control-panes-hotkeys-controller.js:102
+msgid "Press Menu button"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Previous"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Processing..."
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:37
+msgid "Product"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Pushing app..."
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "RAM"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:10
+#: app/components/stf/device/device-info-filter/index.js:25
+msgid "Ready"
+msgstr ""
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:39
+msgid "Reconnected successfully."
+msgstr ""
+
+#: app/components/stf/common-ui/refresh-page/refresh-page.html:1
+msgid "Refresh"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:49
+msgid "Released"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reload"
+msgstr ""
+
+#: app/control-panes/dashboard/remote-debug/remote-debug.html:1
+msgid "Remote debug"
+msgstr ""
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "Remove"
+msgstr ""
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+#: app/device-list/device-list.html:1
+msgid "Reset"
+msgstr ""
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reset all browser settings"
+msgstr ""
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Reset Settings"
+msgstr ""
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Restart Device"
+msgstr ""
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retrieving the device screen has timed out."
+msgstr ""
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retry"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Rewind"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Roaming"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "ROM"
+msgstr ""
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:92
+msgid "Rotate Left"
+msgstr ""
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:93
+msgid "Rotate Right"
+msgstr ""
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run"
+msgstr ""
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run JavaScript"
+msgstr ""
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:31
+msgid "Run the following on your command line to debug the device from your Browser"
+msgstr ""
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:28
+msgid "Run the following on your command line to debug the device from your IDE"
+msgstr ""
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Run this command to copy the key to your clipboard"
+msgstr ""
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+msgid "Save ScreenShot"
+msgstr ""
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Save..."
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:135
+msgid "Screen"
+msgstr ""
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Screenshot"
+msgstr ""
+
+#: app/control-panes/control-panes-controller.js:8
+msgid "Screenshots"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "SD Card Mounted"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:171
+msgid "SDK"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Search"
+msgstr ""
+
+#: app/control-panes/control-panes-hotkeys-controller.js:91
+msgid "Selects Next IME"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:159
+msgid "Serial"
+msgstr ""
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Server"
+msgstr ""
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Server error. Check log output."
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set"
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set Cookie"
+msgstr ""
+
+#: app/control-panes/dashboard/apps/apps.html:1
+#: app/menu/menu.html:1
+msgid "Settings"
+msgstr ""
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Shell"
+msgstr ""
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Show Screen"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign In"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign Out"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Silent Mode"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "SIM"
+msgstr ""
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Size"
+msgstr ""
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:26
+msgid "Socket connection was lost"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:36
+msgid "Someone stole your device."
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Special Keys"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "Start/Stop Logging"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:25
+msgid "Status"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Stop"
+msgstr ""
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/components/stf/device/device-info-filter/index.js:11
+#: app/control-panes/device-control/device-control.html:1
+msgid "Stop Using"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Store Account"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Sub Type"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Switch Charset"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "Tag"
+msgstr ""
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Pageshot (Needs WebView running)"
+msgstr ""
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Screenshot"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Temperature"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "Text"
+msgstr ""
+
+#: app/components/stf/screen/screen.html:1
+msgid "The current view is marked secure and cannot be viewed remotely."
+msgstr ""
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:11
+msgid "The device will be unavailable for a moment."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:34
+msgid "The existing package could not be deleted."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:58
+msgid "The new package couldn't be installed because the verification did not succeed."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:56
+msgid "The new package couldn't be installed because the verification timed out."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:54
+msgid "The new package couldn't be installed in the specified install location because the media is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:52
+msgid "The new package couldn't be installed in the specified install location."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:40
+msgid "The new package failed because it contains a content provider with thesame authority as a provider already installed in the system."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:44
+msgid "The new package failed because it has specified that it is a test-only package and the caller has not supplied the INSTALL_ALLOW_TEST flag."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:42
+msgid "The new package failed because the current SDK version is newer than that required by the package."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:38
+msgid "The new package failed because the current SDK version is older than that required by the package."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:36
+msgid "The new package failed while optimizing and validating its dex files, either because there was not enough storage or the validation failed."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:64
+msgid "The new package has an older version code than the currently installed package."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:62
+msgid "The new package is assigned a different UID than it previously held."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:48
+msgid "The new package uses a feature that is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:32
+msgid "The new package uses a shared library that is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:20
+msgid "The package archive file is invalid."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:46
+msgid "The package being installed contains native code, but none that is compatible with the device's CPU_ABI."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:60
+msgid "The package changed from what the calling program expected."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:18
+msgid "The package is already installed."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:24
+msgid "The package manager service found that the device didn't have enough storage space to install the app."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:84
+msgid "The parser did not find any actionable tags (instrumentation or application) in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:72
+msgid "The parser did not find any certificates in the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:78
+msgid "The parser encountered a bad or missing package name in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:80
+msgid "The parser encountered a bad shared user id name in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:76
+msgid "The parser encountered a CertificateEncodingException in one of the files in the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:70
+msgid "The parser encountered an unexpected exception."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:82
+msgid "The parser encountered some structural problem in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:74
+msgid "The parser found inconsistent certificates on the files in the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:66
+msgid "The parser was given a path that is not a file, or does not end with the expected '.apk' extension."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:68
+msgid "The parser was unable to retrieve the AndroidManifest.xml file."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:28
+msgid "The requested shared user does not exist."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:90
+msgid "The system failed to install the package because its packaged native code did not match any of the ABIs supported by the system."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:86
+msgid "The system failed to install the package because of system issues."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:88
+msgid "The system failed to install the package because the user is restricted from installing apps."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:22
+msgid "The URI passed in is invalid."
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "TID"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "Time"
+msgstr ""
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Tip:"
+msgstr ""
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Title"
+msgstr ""
+
+#: app/control-panes/control-panes-hotkeys-controller.js:107
+msgid "Toggle Web/Native"
+msgstr ""
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Total Devices"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "translate"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+msgid "Try to reconnect"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Type"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:23
+#: app/components/stf/device/device-info-filter/index.js:8
+msgid "Unauthorized"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uninstall"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:14
+#: app/components/stf/device/device-info-filter/index.js:29
+msgid "Unknown"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:40
+msgid "Unknown reason."
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:6
+msgid "Unlock Rotation"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:51
+msgid "Unspecified Failure"
+msgstr ""
+
+#: app/components/stf/upload/upload-error-filter.js:7
+msgid "Upload failed"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:5
+msgid "Upload From Link"
+msgstr ""
+
+#: app/components/stf/upload/upload-error-filter.js:8
+msgid "Upload unknown error"
+msgstr ""
+
+#: app/components/stf/upload/upload-error-filter.js:4
+msgid "Uploaded file is not valid"
+msgstr ""
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uploading..."
+msgstr ""
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Usable Devices"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:59
+msgid "USB"
+msgstr ""
+
+#: app/control-panes/advanced/usb/usb.html:1
+msgid "Usb speed"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:13
+msgid "Use"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:262
+msgid "User"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Username"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:26
+msgid "Using"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Using Fallback"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Version"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "Version Update"
+msgstr ""
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Vibrate Mode"
+msgstr ""
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "VNC"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Voltage"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Down"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Up"
+msgstr ""
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Warning:"
+msgstr ""
+
+#: app/menu/menu.html:1
+msgid "Web"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Width"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:105
+#: app/components/stf/device/device-info-filter/index.js:97
+#: app/control-panes/automation/device-settings/device-settings.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "WiFi"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:98
+msgid "WiMAX"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:60
+msgid "Wireless"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "X DPI"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Y DPI"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:113
+msgid "Yes"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:35
+msgid "You (or someone else) kicked the device."
+msgstr ""
diff --git a/crowdstf/res/common/lang/po/stf.ru_RU.po b/crowdstf/res/common/lang/po/stf.ru_RU.po
new file mode 100644
index 0000000..9f1b54d
--- /dev/null
+++ b/crowdstf/res/common/lang/po/stf.ru_RU.po
@@ -0,0 +1,1560 @@
+# 
+# Translators:
+# Vyacheslav Frolov <frolov78@gmail.com>, 2015
+msgid ""
+msgstr ""
+"Project-Id-Version: STF\n"
+"PO-Revision-Date: 2016-03-30 08:24+0000\n"
+"Last-Translator: takeshimiya <takeshimiya@gmail.com>\n"
+"Language-Team: Russian (Russia) (http://www.transifex.com/openstf/stf/language/ru_RU/)\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: ru_RU\n"
+"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n"
+
+#: app/components/stf/device/device-info-filter/index.js:117
+#: app/components/stf/device/device-info-filter/index.js:52
+#: app/components/stf/device/device-info-filter/index.js:61
+#: app/components/stf/device/device-info-filter/index.js:71
+msgid "-"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "A new version of STF is available"
+msgstr "Доступна новая версия STF"
+
+#: app/components/stf/install/install-error-filter.js:26
+msgid "A package is already installed with the same name."
+msgstr "Пакет уже установлен с таким же названием."
+
+#: app/components/stf/install/install-error-filter.js:30
+msgid ""
+"A previously installed package of the same name has a different signature "
+"than the new package (and the old package's data was not removed)."
+msgstr "Ранее установленный пакет с таким же названием имеет отличающуюся цифровую подпись (также не удалены данные старого пакета)"
+
+#: app/components/stf/install/install-error-filter.js:50
+msgid "A secure container mount point couldn't be accessed on external media."
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:178
+msgid "ABI"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:58
+msgid "AC"
+msgstr ""
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Access Tokens"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Account"
+msgstr "Учетная запись"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Action"
+msgstr "Действие"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Actions"
+msgstr "Действия"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Activity"
+msgstr ""
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "ADB Keys"
+msgstr "Ключи ADB"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Add"
+msgstr "Добавить"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add ADB Key"
+msgstr "Добавить ADB ключ"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add Key"
+msgstr "Добавить ключ"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Add the following ADB Key to STF?"
+msgstr "Добавить ADB ключ к STF?"
+
+#: app/layout/layout-controller.js:7
+msgid "Admin mode has been disabled."
+msgstr "Режим администратора отключен."
+
+#: app/layout/layout-controller.js:6
+msgid "Admin mode has been enabled."
+msgstr "Режим администратора включен."
+
+#: app/control-panes/control-panes-controller.js:20
+msgid "Advanced"
+msgstr "Расширенный"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Advanced Input"
+msgstr "Расширенный ввод"
+
+#: app/control-panes/info/info.html:1
+msgid "Airplane Mode"
+msgstr "Режим В самолёте"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "App Store"
+msgstr "Play Маркет"
+
+#: app/control-panes/dashboard/install/install.html:1
+msgid "App Upload"
+msgstr "Загрузка приложения"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Apps"
+msgstr "Приложения"
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:10
+msgid "Are you sure you want to reboot this device?"
+msgstr "Вы уверены, что хотите перезагрузить устройство?"
+
+#: app/control-panes/control-panes-controller.js:14
+msgid "Automation"
+msgstr "Автоматизация"
+
+#: app/components/stf/device/device-info-filter/index.js:28
+msgid "Available"
+msgstr "Доступен"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Back"
+msgstr "Назад"
+
+#: app/control-panes/info/info.html:1
+msgid "Battery"
+msgstr "Аккумулятор"
+
+#: app/device-list/column/device-column-service.js:202
+msgid "Battery Health"
+msgstr "Состояние батареи"
+
+#: app/device-list/column/device-column-service.js:226
+msgid "Battery Level"
+msgstr "Уровень зарядки аккумулятора"
+
+#: app/device-list/column/device-column-service.js:210
+msgid "Battery Source"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:218
+msgid "Battery Status"
+msgstr "Статус аккумулятора"
+
+#: app/device-list/column/device-column-service.js:239
+msgid "Battery Temp"
+msgstr "Температура аккумулятора"
+
+#: app/components/stf/device/device-info-filter/index.js:89
+msgid "Bluetooth"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:153
+msgid "Browser"
+msgstr "Браузер"
+
+#: app/components/stf/device/device-info-filter/index.js:12
+#: app/components/stf/device/device-info-filter/index.js:27
+msgid "Busy"
+msgstr "Занято"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Busy Devices"
+msgstr "Используемые устройства"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Camera"
+msgstr "Камера"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Cancel"
+msgstr "Отменить"
+
+#: app/components/stf/upload/upload-error-filter.js:6
+msgid "Cannot access specified URL"
+msgstr "Невозможно отрыть заданный URL"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:43
+msgid "Carrier"
+msgstr "Оператор"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Category"
+msgstr "Категория"
+
+#: app/components/stf/device/device-info-filter/index.js:67
+msgid "Charging"
+msgstr "Заряжается"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Check errors below"
+msgstr "Проверьте сообщения об ошибках ниже"
+
+#: app/components/stf/common-ui/clear-button/clear-button.html:1
+#: app/control-panes/advanced/run-js/run-js.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Clear"
+msgstr "Очистить"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Clipboard"
+msgstr "Буфер обмена"
+
+#: app/components/stf/device/device-info-filter/index.js:46
+msgid "Cold"
+msgstr "Холодно"
+
+#: app/components/stf/device/device-info-filter/index.js:21
+#: app/components/stf/device/device-info-filter/index.js:6
+#: app/control-panes/info/info.html:1
+msgid "Connected"
+msgstr "Подключено"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:20
+msgid "Connected successfully."
+msgstr "Подключено успешно."
+
+#: app/menu/menu.html:1
+msgid "Control"
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Cookies"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Cores"
+msgstr "Ядер"
+
+#: app/control-panes/info/info.html:1
+#: app/control-panes/performance/cpu/cpu.html:1
+msgid "CPU"
+msgstr "Процессор"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Current rotation:"
+msgstr ""
+
+#: app/device-list/device-list.html:1
+msgid "Customize"
+msgstr "Настроить"
+
+#: app/control-panes/advanced/input/input.html:12
+msgid "D-pad Center"
+msgstr "Центральная кнопка D-pad"
+
+#: app/control-panes/advanced/input/input.html:20
+msgid "D-pad Down"
+msgstr "Вниз"
+
+#: app/control-panes/advanced/input/input.html:9
+msgid "D-pad Left"
+msgstr "Влево"
+
+#: app/control-panes/advanced/input/input.html:15
+msgid "D-pad Right"
+msgstr "Вправо"
+
+#: app/control-panes/advanced/input/input.html:4
+msgid "D-pad Up"
+msgstr "Вверх"
+
+#: app/control-panes/control-panes-controller.js:41
+msgid "Dashboard"
+msgstr "Приборная панель"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Data"
+msgstr "Данные"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Date"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:48
+msgid "Dead"
+msgstr "Не отвечает"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Delete"
+msgstr "Удалить"
+
+#: app/control-panes/info/info.html:1
+msgid "Density"
+msgstr "Плотность"
+
+#: app/device-list/device-list.html:1
+msgid "Details"
+msgstr "Детали"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Developer"
+msgstr "Разработчик"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/inspect/inspect.html:1
+msgid "Device"
+msgstr "Устройство"
+
+#: app/device-list/details/device-list-details-directive.js:38
+#: app/device-list/icons/device-list-icons-directive.js:122
+msgid "Device cannot get kicked from the group"
+msgstr "Устройство не может быть исключено из группы"
+
+#: app/components/stf/device/device-info-filter/index.js:38
+msgid "Device is not present anymore for some reason."
+msgstr "Подключение к устройству отсутствует по неизвестной причине."
+
+#: app/components/stf/device/device-info-filter/index.js:39
+msgid "Device is present but offline."
+msgstr "Устройство подключено, но не активно."
+
+#: app/control-panes/info/info.html:1
+msgid "Device Photo"
+msgstr "Фото устройства"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Device Settings"
+msgstr "Настройки устройства"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+msgid "Device was disconnected"
+msgstr "Устройство было отключено"
+
+#: app/components/stf/device/device-info-filter/index.js:37
+msgid "Device was kicked by automatic timeout."
+msgstr "Устройство было отключено по таймауту."
+
+#: app/device-list/device-list.html:1 app/menu/menu.html:1
+msgid "Devices"
+msgstr "Устройства"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Disable WiFi"
+msgstr "Отключить WiFi"
+
+#: app/components/stf/device/device-info-filter/index.js:68
+msgid "Discharging"
+msgstr "Разряжается"
+
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+#: app/components/stf/device/device-info-filter/index.js:20
+#: app/components/stf/device/device-info-filter/index.js:5
+msgid "Disconnected"
+msgstr "Отключено"
+
+#: app/control-panes/info/info.html:1
+msgid "Display"
+msgstr "Экран"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Drop file to upload"
+msgstr "Перетащите файл для загрузки"
+
+#: app/components/stf/device/device-info-filter/index.js:90
+msgid "Dummy"
+msgstr "Макет"
+
+#: app/settings/notifications/notifications.html:1
+msgid "Enable notifications"
+msgstr "Включить уведомления"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Enable WiFi"
+msgstr "Включить WiFi"
+
+#: app/control-panes/info/info.html:1
+msgid "Encrypted"
+msgstr "Зашифровано"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:31
+msgid "Error"
+msgstr "Ошибка"
+
+#: app/components/stf/control/control-service.js:129
+msgid "Error while getting data"
+msgstr "Ошибка во время получения данных"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:35
+msgid "Error while reconnecting"
+msgstr "Ошибка переподключения"
+
+#: app/components/stf/device/device-info-filter/index.js:91
+msgid "Ethernet"
+msgstr "Проводная сеть"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Executes remote shell commands"
+msgstr "Выполняет удалённые команды shell"
+
+#: app/components/stf/upload/upload-error-filter.js:5
+msgid "Failed to download file"
+msgstr "Не удалось загрузить файл"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Fast Forward"
+msgstr "Перемотка вперёд"
+
+#: app/control-panes/control-panes-controller.js:26
+msgid "File Explorer"
+msgstr ""
+
+#: app/components/stf/common-ui/filter-button/filter-button.html:1
+msgid "Filter"
+msgstr "Фильтр"
+
+#: app/control-panes/info/info.html:1
+msgid "Find Device"
+msgstr "Обнаружить устройство"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Fingerprint"
+msgstr "Отпечаток пальца"
+
+#: app/control-panes/info/info.html:1
+msgid "FPS"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Frequency"
+msgstr "Частота"
+
+#: app/components/stf/device/device-info-filter/index.js:69
+msgid "Full"
+msgstr "Полный"
+
+#: app/settings/settings-controller.js:5
+msgid "General"
+msgstr "Общие"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate Access Token"
+msgstr ""
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Generate Login for VNC"
+msgstr ""
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate New Token"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "Get"
+msgstr "Получить"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Get clipboard contents"
+msgstr "Получить содержимое буфера обмена"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Back"
+msgstr "Назад"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Forward"
+msgstr "Вперёд"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:89
+msgid "Go to Device List"
+msgstr "Открыть список устройств"
+
+#: app/components/stf/device/device-info-filter/index.js:47
+msgid "Good"
+msgstr "Хорошее"
+
+#: app/control-panes/info/info.html:1
+msgid "Hardware"
+msgstr "Железо"
+
+#: app/control-panes/info/info.html:1
+msgid "Health"
+msgstr "Состояние"
+
+#: app/control-panes/info/info.html:1
+msgid "Height"
+msgstr "Высота"
+
+#: app/menu/menu.html:1
+msgid "Help"
+msgstr "Помощь"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Hide Screen"
+msgstr "Спрятать экран"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Home"
+msgstr "Домашний экран"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Host"
+msgstr ""
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Hostname"
+msgstr "Имя хоста"
+
+#: app/control-panes/info/info.html:1
+msgid "ICCID"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "ID"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "IMEI"
+msgstr ""
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Incorrect login details"
+msgstr "Некорректные логин или пароль"
+
+#: app/control-panes/control-panes-controller.js:32
+msgid "Info"
+msgstr "Инфо"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspect Device"
+msgstr "Инспектировать устройство"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspecting is currently only supported in WebView"
+msgstr "Инспектирование пока поддерживается в WebView"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspector"
+msgstr "Инспекто"
+
+#: app/components/stf/install/install-error-filter.js:13
+msgid "Installation canceled by user."
+msgstr "Установка отменена пользователем."
+
+#: app/components/stf/install/install-error-filter.js:9
+msgid "Installation failed due to an unknown error."
+msgstr "Установка не удалась по неизвестной причине."
+
+#: app/components/stf/install/install-error-filter.js:7
+msgid "Installation succeeded."
+msgstr "Установка прошла успешно."
+
+#: app/components/stf/install/install-error-filter.js:11
+msgid "Installation timed out."
+msgstr "Время установки истекло."
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Installing app..."
+msgstr "Устанавливаем приложение..."
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Key"
+msgstr "Ключ"
+
+#: app/settings/settings-controller.js:10
+msgid "Keys"
+msgstr "Ключи"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Landscape"
+msgstr "Ландшафт"
+
+#: app/settings/general/language/language.html:1
+msgid "Language"
+msgstr "Язык"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Launch Activity"
+msgstr "Запустить приложение"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Launching activity..."
+msgstr "Приложение запускается..."
+
+#: app/control-panes/info/info.html:1 app/control-panes/logs/logs.html:1
+msgid "Level"
+msgstr "Уровень"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Local Settings"
+msgstr "Локальные настройки"
+
+#: app/device-list/column/device-column-service.js:250
+msgid "Location"
+msgstr "Местоположение"
+
+#: app/control-panes/automation/device-settings/device-settings.html:7
+msgid "Lock Rotation"
+msgstr ""
+
+#: app/control-panes/control-panes-controller.js:50
+msgid "Logs"
+msgstr "Журнал"
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Maintenance"
+msgstr "Обслуживание"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid ""
+"Make sure to copy your access token now. You won't be able to see it again."
+msgstr ""
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Manage Apps"
+msgstr "Управление приложениями"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Manner Mode"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:165
+msgid "Manufacturer"
+msgstr "Производитель"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Media"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Memory"
+msgstr "Память"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Menu"
+msgstr "Меню"
+
+#: app/components/stf/device/device-info-filter/index.js:92
+msgid "Mobile"
+msgstr "Мобильный"
+
+#: app/components/stf/device/device-info-filter/index.js:93
+msgid "Mobile DUN"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:94
+msgid "Mobile High Priority"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:95
+msgid "Mobile MMS"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:96
+msgid "Mobile SUPL"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:31
+msgid "Model"
+msgstr "Модель"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "More about Access Tokens"
+msgstr "Подробнее о ключах доступа"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "More about ADB Keys"
+msgstr "Подробнее о ADB ключах"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Mute"
+msgstr "Выключить звук"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Name"
+msgstr "Имя"
+
+#: app/menu/menu.html:1
+msgid "Native"
+msgstr "Нативный"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Navigation"
+msgstr "Навигация"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:129
+msgid "Network"
+msgstr "Сеть"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Next"
+msgstr "Следующий"
+
+#: app/components/stf/device/device-info-filter/index.js:115
+msgid "No"
+msgstr "Нет"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "No access tokens"
+msgstr "Ключи доступа отсутствуют"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "No ADB keys"
+msgstr "ADB ключи отсутствуют"
+
+#: app/components/stf/control/control-service.js:126
+msgid "No clipboard data"
+msgstr "В буфере обмена нет данных"
+
+#: app/control-panes/resources/resources.html:1
+msgid "No cookies to show"
+msgstr "Отсутствуют cookies"
+
+#: app/components/stf/screen/screen.html:1
+msgid "No device screen"
+msgstr ""
+
+#: app/device-list/empty/device-list-empty.html:1
+msgid "No devices connected"
+msgstr "Нет подключенных устройств"
+
+#: app/components/stf/common-ui/modals/lightbox-image/lightbox-image.html:1
+msgid "No photo available"
+msgstr "Отсутствует фото"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "No Ports Forwarded"
+msgstr "Отсутствуют перенаправленные порты"
+
+#: app/control-panes/screenshots/screenshots.html:5
+msgid "No screenshots taken"
+msgstr "Снимки экрана отсутствуют"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Normal Mode"
+msgstr "Нормальный режим"
+
+#: app/components/stf/device/device-info-filter/index.js:70
+msgid "Not Charging"
+msgstr "Не заряжается"
+
+#: app/device-list/column/device-column-service.js:256
+msgid "Notes"
+msgstr "Записи"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Nothing to inspect"
+msgstr ""
+
+#: app/settings/notifications/notifications.html:1
+msgid "Notifications"
+msgstr "Уведомления"
+
+#: app/control-panes/info/info.html:1
+msgid "Number"
+msgstr "Число"
+
+#: app/components/stf/device/device-info-filter/index.js:22
+#: app/components/stf/device/device-info-filter/index.js:7
+msgid "Offline"
+msgstr ""
+
+#: app/components/stf/common-ui/error-message/error-message.html:1
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Oops!"
+msgstr "Ой"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Open"
+msgstr "Открыть"
+
+#: app/control-panes/info/info.html:1
+msgid "Orientation"
+msgstr "Ориентация"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:55
+msgid "OS"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:49
+msgid "Over Voltage"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:50
+msgid "Overheat"
+msgstr ""
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Package"
+msgstr "Пакет"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Password"
+msgstr "Пароль"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Permissions"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:184
+msgid "Phone"
+msgstr "Телефон"
+
+#: app/device-list/column/device-column-service.js:196
+msgid "Phone ICCID"
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:190
+msgid "Phone IMEI"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Physical Device"
+msgstr "Физическое устройство"
+
+#: app/control-panes/logs/logs.html:1
+msgid "PID"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Place"
+msgstr "Место"
+
+#: app/control-panes/info/info.html:1
+msgid "Platform"
+msgstr "Платформа"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Play/Pause"
+msgstr "Играть/Пауза"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter a valid email"
+msgstr "Пожалуйста, введите корректный email"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your email"
+msgstr "Пожалуйста, введите email"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your LDAP username"
+msgstr ""
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your name"
+msgstr "Пожалуйста введите ваше имя"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your password"
+msgstr "Пожалуйста введите пароль"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store password"
+msgstr ""
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store username"
+msgstr ""
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Port"
+msgstr "Порт"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Port Forwarding"
+msgstr "Перенаправление портов"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Portrait"
+msgstr "Портетный"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Power"
+msgstr "Энергия"
+
+#: app/control-panes/info/info.html:1
+msgid "Power Source"
+msgstr "Источник энергии"
+
+#: app/components/stf/device/device-info-filter/index.js:24
+#: app/components/stf/device/device-info-filter/index.js:9
+msgid "Preparing"
+msgstr "Подготовка"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:104
+msgid "Press Back button"
+msgstr "Нажмите кнопку Назад"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:103
+msgid "Press Home button"
+msgstr "Нажмите кнопку Домой"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:102
+msgid "Press Menu button"
+msgstr "Нажмите кнопку Меню"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Previous"
+msgstr "Предыдущий"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Processing..."
+msgstr "Обрабатываю..."
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:37
+msgid "Product"
+msgstr "Продукт"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Pushing app..."
+msgstr "Загружаю приложение на устройство..."
+
+#: app/control-panes/info/info.html:1
+msgid "RAM"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:10
+#: app/components/stf/device/device-info-filter/index.js:25
+msgid "Ready"
+msgstr "Готово"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:39
+msgid "Reconnected successfully."
+msgstr "Переподключился успешно."
+
+#: app/components/stf/common-ui/refresh-page/refresh-page.html:1
+msgid "Refresh"
+msgstr "Обновить"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:49
+msgid "Released"
+msgstr "Свободно"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reload"
+msgstr "Перезагрузить"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug.html:1
+msgid "Remote debug"
+msgstr "Удалённая отладка"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "Remove"
+msgstr "Удалить"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+#: app/device-list/device-list.html:1
+msgid "Reset"
+msgstr "Сбросить"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reset all browser settings"
+msgstr "Сбросить все настройки браузера"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Reset Settings"
+msgstr "Сбросить настройки"
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Restart Device"
+msgstr "Перезагрузить устройство"
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retrieving the device screen has timed out."
+msgstr "Попытка получить снимок устройства не завершилась вовремя."
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retry"
+msgstr "Повторить"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Rewind"
+msgstr "Перемотать назад"
+
+#: app/control-panes/info/info.html:1
+msgid "Roaming"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "ROM"
+msgstr ""
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:92
+msgid "Rotate Left"
+msgstr "Повернуть влево"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:93
+msgid "Rotate Right"
+msgstr "Повернуть вправо"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run"
+msgstr "Выполнить"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run JavaScript"
+msgstr "Выполнить JavaScript"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:31
+msgid ""
+"Run the following on your command line to debug the device from your Browser"
+msgstr "Выполните эту команду, чтобы отладить устройство из вашего Браузера"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:28
+msgid ""
+"Run the following on your command line to debug the device from your IDE"
+msgstr "Выполните эту команду, чтобы отладить устройство из вашего IDE"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Run this command to copy the key to your clipboard"
+msgstr "Выполните эту команду, чтобы скопировать ключ в буфер обмена"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+msgid "Save ScreenShot"
+msgstr "Сохранить скриншот"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Save..."
+msgstr "Сохранить.."
+
+#: app/device-list/column/device-column-service.js:135
+msgid "Screen"
+msgstr "Экран"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Screenshot"
+msgstr "Снимок экрана"
+
+#: app/control-panes/control-panes-controller.js:8
+msgid "Screenshots"
+msgstr "Снимки экрана"
+
+#: app/control-panes/info/info.html:1
+msgid "SD Card Mounted"
+msgstr "SD карта подключена"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:171
+msgid "SDK"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Search"
+msgstr "Поиск"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:91
+msgid "Selects Next IME"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:159
+msgid "Serial"
+msgstr "Серийный номер"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Server"
+msgstr ""
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Server error. Check log output."
+msgstr "Ошибка сервера. Проверьте журнал."
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set"
+msgstr "Установить"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set Cookie"
+msgstr "Установить cookie"
+
+#: app/control-panes/dashboard/apps/apps.html:1 app/menu/menu.html:1
+msgid "Settings"
+msgstr "Настройки"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Shell"
+msgstr "Командная оболочка"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Show Screen"
+msgstr "Показать экран"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign In"
+msgstr "Войти"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign Out"
+msgstr "Выйти"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Silent Mode"
+msgstr "Тихий режим"
+
+#: app/control-panes/info/info.html:1
+msgid "SIM"
+msgstr ""
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Size"
+msgstr "Размер"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:26
+msgid "Socket connection was lost"
+msgstr "Подключение через socket потеряно"
+
+#: app/components/stf/device/device-info-filter/index.js:36
+msgid "Someone stole your device."
+msgstr "Кто-то утащил ваше устройство"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Special Keys"
+msgstr "Специальные кнопки"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Start/Stop Logging"
+msgstr "Начать/Остановить журналирование"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:25
+msgid "Status"
+msgstr "Статус"
+
+#: app/control-panes/advanced/input/input.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Stop"
+msgstr "Стоп"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/components/stf/device/device-info-filter/index.js:11
+#: app/control-panes/device-control/device-control.html:1
+msgid "Stop Using"
+msgstr "Освободить"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Store Account"
+msgstr "Учетная запись магазина приложений"
+
+#: app/control-panes/info/info.html:1
+msgid "Sub Type"
+msgstr "Подтип"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Switch Charset"
+msgstr "Переключить кодировку"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Tag"
+msgstr ""
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Pageshot (Needs WebView running)"
+msgstr "Сделать снимок странички (WebView должен быть запущен)"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Screenshot"
+msgstr "Сделать снимок экрана"
+
+#: app/control-panes/info/info.html:1
+msgid "Temperature"
+msgstr "Температура"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Text"
+msgstr "Текст"
+
+#: app/components/stf/screen/screen.html:1
+msgid "The current view is marked secure and cannot be viewed remotely."
+msgstr "Текущий просмотр отмечен как безопасный и к нему нельзя получить доступ удалённо."
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:11
+msgid "The device will be unavailable for a moment."
+msgstr "Устройство будет временно недоступно."
+
+#: app/components/stf/install/install-error-filter.js:34
+msgid "The existing package could not be deleted."
+msgstr "Существующий пакет не может быть удалён."
+
+#: app/components/stf/install/install-error-filter.js:58
+msgid ""
+"The new package couldn't be installed because the verification did not "
+"succeed."
+msgstr "Пакет не может быть установлен из-за ошибки верификации."
+
+#: app/components/stf/install/install-error-filter.js:56
+msgid ""
+"The new package couldn't be installed because the verification timed out."
+msgstr "Пакет не может быть установлен из-за превышения времени верификации."
+
+#: app/components/stf/install/install-error-filter.js:54
+msgid ""
+"The new package couldn't be installed in the specified install location "
+"because the media is not available."
+msgstr "Пакет не может быть установлен в указанное место поскольку оно недоступно."
+
+#: app/components/stf/install/install-error-filter.js:52
+msgid ""
+"The new package couldn't be installed in the specified install location."
+msgstr "Пакет не может быть установлен в указанное место."
+
+#: app/components/stf/install/install-error-filter.js:40
+msgid ""
+"The new package failed because it contains a content provider with thesame "
+"authority as a provider already installed in the system."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:44
+msgid ""
+"The new package failed because it has specified that it is a test-only "
+"package and the caller has not supplied the INSTALL_ALLOW_TEST flag."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:42
+msgid ""
+"The new package failed because the current SDK version is newer than that "
+"required by the package."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:38
+msgid ""
+"The new package failed because the current SDK version is older than that "
+"required by the package."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:36
+msgid ""
+"The new package failed while optimizing and validating its dex files, either"
+" because there was not enough storage or the validation failed."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:64
+msgid ""
+"The new package has an older version code than the currently installed "
+"package."
+msgstr "Новый пакет имеет более старую версию, чем уже установленный."
+
+#: app/components/stf/install/install-error-filter.js:62
+msgid "The new package is assigned a different UID than it previously held."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:48
+msgid "The new package uses a feature that is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:32
+msgid "The new package uses a shared library that is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:20
+msgid "The package archive file is invalid."
+msgstr "Файл архива программы повреждён."
+
+#: app/components/stf/install/install-error-filter.js:46
+msgid ""
+"The package being installed contains native code, but none that is "
+"compatible with the device's CPU_ABI."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:60
+msgid "The package changed from what the calling program expected."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:18
+msgid "The package is already installed."
+msgstr "Пакет уже установлен."
+
+#: app/components/stf/install/install-error-filter.js:24
+msgid ""
+"The package manager service found that the device didn't have enough storage"
+" space to install the app."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:84
+msgid ""
+"The parser did not find any actionable tags (instrumentation or application)"
+" in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:72
+msgid "The parser did not find any certificates in the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:78
+msgid "The parser encountered a bad or missing package name in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:80
+msgid "The parser encountered a bad shared user id name in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:76
+msgid ""
+"The parser encountered a CertificateEncodingException in one of the files in"
+" the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:70
+msgid "The parser encountered an unexpected exception."
+msgstr "Возникла непредвиденная исключительная ситуация во время работы синтаксического анализатора."
+
+#: app/components/stf/install/install-error-filter.js:82
+msgid "The parser encountered some structural problem in the manifest."
+msgstr "Синтаксический анализатор обнаружил структурные проблемы в манифесте."
+
+#: app/components/stf/install/install-error-filter.js:74
+msgid "The parser found inconsistent certificates on the files in the .apk."
+msgstr "Синтаксический анализатор обнаружил несовместимый сертификат в .apk файле"
+
+#: app/components/stf/install/install-error-filter.js:66
+msgid ""
+"The parser was given a path that is not a file, or does not end with the "
+"expected '.apk' extension."
+msgstr "Синтаксическому анализатору был передан не путь к файлу, или имя файла не заканчивается на '.apk'."
+
+#: app/components/stf/install/install-error-filter.js:68
+msgid "The parser was unable to retrieve the AndroidManifest.xml file."
+msgstr "Синтаксический анализатор не смог получить файл AndroidManifest.xml"
+
+#: app/components/stf/install/install-error-filter.js:28
+msgid "The requested shared user does not exist."
+msgstr "Требуемый общий пользователь не существует."
+
+#: app/components/stf/install/install-error-filter.js:90
+msgid ""
+"The system failed to install the package because its packaged native code "
+"did not match any of the ABIs supported by the system."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:86
+msgid "The system failed to install the package because of system issues."
+msgstr "Не удалось установить пакет по причине системной ошибки"
+
+#: app/components/stf/install/install-error-filter.js:88
+msgid ""
+"The system failed to install the package because the user is restricted from"
+" installing apps."
+msgstr "Не удалось установить пакет, так как данному пользователю запрещена установка приложений."
+
+#: app/components/stf/install/install-error-filter.js:22
+msgid "The URI passed in is invalid."
+msgstr "Невалидный URI."
+
+#: app/control-panes/logs/logs.html:1
+msgid "TID"
+msgstr "TID"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Time"
+msgstr "Время"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Tip:"
+msgstr "Подсказка:"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Title"
+msgstr ""
+
+#: app/control-panes/control-panes-hotkeys-controller.js:107
+msgid "Toggle Web/Native"
+msgstr "Переключить Web/Native"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Total Devices"
+msgstr "Всего устройств"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "translate"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+msgid "Try to reconnect"
+msgstr "Попробовать подключиться"
+
+#: app/control-panes/info/info.html:1
+msgid "Type"
+msgstr "Тип"
+
+#: app/components/stf/device/device-info-filter/index.js:23
+#: app/components/stf/device/device-info-filter/index.js:8
+msgid "Unauthorized"
+msgstr "Неавторизован"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uninstall"
+msgstr "Удалить"
+
+#: app/components/stf/device/device-info-filter/index.js:14
+#: app/components/stf/device/device-info-filter/index.js:29
+msgid "Unknown"
+msgstr "Неизвестный"
+
+#: app/components/stf/device/device-info-filter/index.js:40
+msgid "Unknown reason."
+msgstr "Неизвестная причина."
+
+#: app/control-panes/automation/device-settings/device-settings.html:6
+msgid "Unlock Rotation"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:51
+msgid "Unspecified Failure"
+msgstr ""
+
+#: app/components/stf/upload/upload-error-filter.js:7
+msgid "Upload failed"
+msgstr "Загрузка завершилась неудачно"
+
+#: app/control-panes/dashboard/install/install.html:5
+msgid "Upload From Link"
+msgstr "Загрузить по ссылке"
+
+#: app/components/stf/upload/upload-error-filter.js:8
+msgid "Upload unknown error"
+msgstr "Неизвестная ошибка загрузки"
+
+#: app/components/stf/upload/upload-error-filter.js:4
+msgid "Uploaded file is not valid"
+msgstr "Загруженный файл не валиден"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uploading..."
+msgstr "Загружается..."
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Usable Devices"
+msgstr "Доступные устройства"
+
+#: app/components/stf/device/device-info-filter/index.js:59
+msgid "USB"
+msgstr "USB"
+
+#: app/control-panes/advanced/usb/usb.html:1
+msgid "Usb speed"
+msgstr "Скорость USB"
+
+#: app/components/stf/device/device-info-filter/index.js:13
+msgid "Use"
+msgstr "Использовать"
+
+#: app/device-list/column/device-column-service.js:262
+msgid "User"
+msgstr "Пользователь"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Username"
+msgstr "Имя пользователя"
+
+#: app/components/stf/device/device-info-filter/index.js:26
+msgid "Using"
+msgstr "Используется"
+
+#: app/control-panes/info/info.html:1
+msgid "Using Fallback"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Version"
+msgstr "Версия"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "Version Update"
+msgstr "Обновление версии"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Vibrate Mode"
+msgstr "Режим вибрации"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "VNC"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Voltage"
+msgstr "Напряжение"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume"
+msgstr "Звук"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Down"
+msgstr "Тише"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Up"
+msgstr "Громче"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Warning:"
+msgstr ""
+
+#: app/menu/menu.html:1
+msgid "Web"
+msgstr "Интернет"
+
+#: app/control-panes/info/info.html:1
+msgid "Width"
+msgstr "Ширина"
+
+#: app/components/stf/device/device-info-filter/index.js:105
+#: app/components/stf/device/device-info-filter/index.js:97
+#: app/control-panes/automation/device-settings/device-settings.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "WiFi"
+msgstr "WiFi"
+
+#: app/components/stf/device/device-info-filter/index.js:98
+msgid "WiMAX"
+msgstr "WiMAX"
+
+#: app/components/stf/device/device-info-filter/index.js:60
+msgid "Wireless"
+msgstr "Беспроводное"
+
+#: app/control-panes/info/info.html:1
+msgid "X DPI"
+msgstr "X DPI"
+
+#: app/control-panes/info/info.html:1
+msgid "Y DPI"
+msgstr "Y DPI"
+
+#: app/components/stf/device/device-info-filter/index.js:113
+msgid "Yes"
+msgstr "Да"
+
+#: app/components/stf/device/device-info-filter/index.js:35
+msgid "You (or someone else) kicked the device."
+msgstr "Вы (или кто-то еще) отключили устройство."
diff --git a/crowdstf/res/common/lang/po/stf.zh-Hant.po b/crowdstf/res/common/lang/po/stf.zh-Hant.po
new file mode 100644
index 0000000..3cffc1d
--- /dev/null
+++ b/crowdstf/res/common/lang/po/stf.zh-Hant.po
@@ -0,0 +1,1562 @@
+#
+# Translators:
+# Yu Can <fineaisa@gmail.com>, 2016
+# dq wang <newbiner@gmail.com>, 2015
+# Yu Can <fineaisa@gmail.com>, 2016
+msgid ""
+msgstr ""
+"Project-Id-Version: STF\n"
+"PO-Revision-Date: 2016-04-02 14:16+0000\n"
+"Last-Translator: Yu Can <fineaisa@gmail.com>\n"
+"Language-Team: Chinese Traditional (http://www.transifex.com/openstf/stf/language/zh-Hant/)\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: zh-Hant\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+#: app/components/stf/device/device-info-filter/index.js:117
+#: app/components/stf/device/device-info-filter/index.js:52
+#: app/components/stf/device/device-info-filter/index.js:61
+#: app/components/stf/device/device-info-filter/index.js:71
+msgid "-"
+msgstr "-"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "A new version of STF is available"
+msgstr "STF有新版本可下载"
+
+#: app/components/stf/install/install-error-filter.js:26
+msgid "A package is already installed with the same name."
+msgstr "已安裝相同名稱的軟體囉。"
+
+#: app/components/stf/install/install-error-filter.js:30
+msgid ""
+"A previously installed package of the same name has a different signature "
+"than the new package (and the old package's data was not removed)."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:50
+msgid "A secure container mount point couldn't be accessed on external media."
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:178
+msgid "ABI"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:58
+msgid "AC"
+msgstr ""
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Access Tokens"
+msgstr "存取憑證"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Account"
+msgstr "帳號"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Action"
+msgstr "動作"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Actions"
+msgstr "更多動作"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Activity"
+msgstr "活動"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "ADB Keys"
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Add"
+msgstr "新增"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add ADB Key"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add Key"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Add the following ADB Key to STF?"
+msgstr ""
+
+#: app/layout/layout-controller.js:7
+msgid "Admin mode has been disabled."
+msgstr "停用 Admin 模式"
+
+#: app/layout/layout-controller.js:6
+msgid "Admin mode has been enabled."
+msgstr "啟用 Admin 模式"
+
+#: app/control-panes/control-panes-controller.js:20
+msgid "Advanced"
+msgstr "進階"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Advanced Input"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Airplane Mode"
+msgstr "飛行模式"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "App Store"
+msgstr "App Store"
+
+#: app/control-panes/dashboard/install/install.html:1
+msgid "App Upload"
+msgstr "上傳 App"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Apps"
+msgstr "Apps"
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:10
+msgid "Are you sure you want to reboot this device?"
+msgstr "你確定要重開這台裝置 ?"
+
+#: app/control-panes/control-panes-controller.js:14
+msgid "Automation"
+msgstr "自動化"
+
+#: app/components/stf/device/device-info-filter/index.js:28
+msgid "Available"
+msgstr "可用"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Back"
+msgstr "返回"
+
+#: app/control-panes/info/info.html:1
+msgid "Battery"
+msgstr "電池"
+
+#: app/device-list/column/device-column-service.js:202
+msgid "Battery Health"
+msgstr "電池健康"
+
+#: app/device-list/column/device-column-service.js:226
+msgid "Battery Level"
+msgstr "電池電量"
+
+#: app/device-list/column/device-column-service.js:210
+msgid "Battery Source"
+msgstr "電池電源"
+
+#: app/device-list/column/device-column-service.js:218
+msgid "Battery Status"
+msgstr "電池狀態"
+
+#: app/device-list/column/device-column-service.js:239
+msgid "Battery Temp"
+msgstr "電池溫度"
+
+#: app/components/stf/device/device-info-filter/index.js:89
+msgid "Bluetooth"
+msgstr "藍芽"
+
+#: app/device-list/column/device-column-service.js:153
+msgid "Browser"
+msgstr "瀏覽器"
+
+#: app/components/stf/device/device-info-filter/index.js:12
+#: app/components/stf/device/device-info-filter/index.js:27
+msgid "Busy"
+msgstr "忙碌"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Busy Devices"
+msgstr "忙碌中的裝置"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Camera"
+msgstr "相機"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Cancel"
+msgstr "取消"
+
+#: app/components/stf/upload/upload-error-filter.js:6
+msgid "Cannot access specified URL"
+msgstr "無法進入指定網址"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:43
+msgid "Carrier"
+msgstr "電信商"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Category"
+msgstr "分類"
+
+#: app/components/stf/device/device-info-filter/index.js:67
+msgid "Charging"
+msgstr "充電中"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Check errors below"
+msgstr "請確認下面的錯誤"
+
+#: app/components/stf/common-ui/clear-button/clear-button.html:1
+#: app/control-panes/advanced/run-js/run-js.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Clear"
+msgstr "清除"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Clipboard"
+msgstr "剪貼簿"
+
+#: app/components/stf/device/device-info-filter/index.js:46
+msgid "Cold"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:21
+#: app/components/stf/device/device-info-filter/index.js:6
+#: app/control-panes/info/info.html:1
+msgid "Connected"
+msgstr "已連線"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:20
+msgid "Connected successfully."
+msgstr "連線成功"
+
+#: app/menu/menu.html:1
+msgid "Control"
+msgstr "控制"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Cookies"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Cores"
+msgstr "核心"
+
+#: app/control-panes/info/info.html:1
+#: app/control-panes/performance/cpu/cpu.html:1
+msgid "CPU"
+msgstr "CPU"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Current rotation:"
+msgstr "目前螢幕方向:"
+
+#: app/device-list/device-list.html:1
+msgid "Customize"
+msgstr "自訂"
+
+#: app/control-panes/advanced/input/input.html:12
+msgid "D-pad Center"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:20
+msgid "D-pad Down"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:9
+msgid "D-pad Left"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:15
+msgid "D-pad Right"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:4
+msgid "D-pad Up"
+msgstr ""
+
+#: app/control-panes/control-panes-controller.js:41
+msgid "Dashboard"
+msgstr ""
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Data"
+msgstr "資料"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Date"
+msgstr "日期"
+
+#: app/components/stf/device/device-info-filter/index.js:48
+msgid "Dead"
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Delete"
+msgstr "刪除"
+
+#: app/control-panes/info/info.html:1
+msgid "Density"
+msgstr ""
+
+#: app/device-list/device-list.html:1
+msgid "Details"
+msgstr "細項"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Developer"
+msgstr "開發者"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/inspect/inspect.html:1
+msgid "Device"
+msgstr "裝置"
+
+#: app/device-list/details/device-list-details-directive.js:38
+#: app/device-list/icons/device-list-icons-directive.js:122
+msgid "Device cannot get kicked from the group"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:38
+msgid "Device is not present anymore for some reason."
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:39
+msgid "Device is present but offline."
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Device Photo"
+msgstr "裝置相片"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Device Settings"
+msgstr "裝置設定"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+msgid "Device was disconnected"
+msgstr "裝置已離線"
+
+#: app/components/stf/device/device-info-filter/index.js:37
+msgid "Device was kicked by automatic timeout."
+msgstr ""
+
+#: app/device-list/device-list.html:1 app/menu/menu.html:1
+msgid "Devices"
+msgstr "裝置"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Disable WiFi"
+msgstr "關閉 WiFi"
+
+#: app/components/stf/device/device-info-filter/index.js:68
+msgid "Discharging"
+msgstr "放電中"
+
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+#: app/components/stf/device/device-info-filter/index.js:20
+#: app/components/stf/device/device-info-filter/index.js:5
+msgid "Disconnected"
+msgstr "離線"
+
+#: app/control-panes/info/info.html:1
+msgid "Display"
+msgstr "顯示"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Drop file to upload"
+msgstr "拖曳到此上傳"
+
+#: app/components/stf/device/device-info-filter/index.js:90
+msgid "Dummy"
+msgstr ""
+
+#: app/settings/notifications/notifications.html:1
+msgid "Enable notifications"
+msgstr "開啟通知"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Enable WiFi"
+msgstr "開啟 WiFi"
+
+#: app/control-panes/info/info.html:1
+msgid "Encrypted"
+msgstr ""
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:31
+msgid "Error"
+msgstr "錯誤"
+
+#: app/components/stf/control/control-service.js:129
+msgid "Error while getting data"
+msgstr "取得資料時錯誤"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:35
+msgid "Error while reconnecting"
+msgstr "重新連線時錯誤"
+
+#: app/components/stf/device/device-info-filter/index.js:91
+msgid "Ethernet"
+msgstr ""
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Executes remote shell commands"
+msgstr ""
+
+#: app/components/stf/upload/upload-error-filter.js:5
+msgid "Failed to download file"
+msgstr "下載檔案失敗"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Fast Forward"
+msgstr "快轉"
+
+#: app/control-panes/control-panes-controller.js:26
+msgid "File Explorer"
+msgstr "檔案瀏覽器"
+
+#: app/components/stf/common-ui/filter-button/filter-button.html:1
+msgid "Filter"
+msgstr "篩選"
+
+#: app/control-panes/info/info.html:1
+msgid "Find Device"
+msgstr "尋找裝置"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Fingerprint"
+msgstr "指紋"
+
+#: app/control-panes/info/info.html:1
+msgid "FPS"
+msgstr "FPS"
+
+#: app/control-panes/info/info.html:1
+msgid "Frequency"
+msgstr "頻率"
+
+#: app/components/stf/device/device-info-filter/index.js:69
+msgid "Full"
+msgstr ""
+
+#: app/settings/settings-controller.js:5
+msgid "General"
+msgstr "一般"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate Access Token"
+msgstr "產生存取憑證"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Generate Login for VNC"
+msgstr ""
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate New Token"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "Get"
+msgstr "擷取"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Get clipboard contents"
+msgstr "取得剪貼簿內容"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Back"
+msgstr "返回"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Forward"
+msgstr ""
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:89
+msgid "Go to Device List"
+msgstr "前往裝置清單"
+
+#: app/components/stf/device/device-info-filter/index.js:47
+msgid "Good"
+msgstr "良好"
+
+#: app/control-panes/info/info.html:1
+msgid "Hardware"
+msgstr "硬體"
+
+#: app/control-panes/info/info.html:1
+msgid "Health"
+msgstr "健康"
+
+#: app/control-panes/info/info.html:1
+msgid "Height"
+msgstr "高度"
+
+#: app/menu/menu.html:1
+msgid "Help"
+msgstr "幫助"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Hide Screen"
+msgstr "隱藏畫面"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Home"
+msgstr ""
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Host"
+msgstr ""
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Hostname"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "ICCID"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "ID"
+msgstr "ID"
+
+#: app/control-panes/info/info.html:1
+msgid "IMEI"
+msgstr "IMEI"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Incorrect login details"
+msgstr "不正確的登入資訊"
+
+#: app/control-panes/control-panes-controller.js:32
+msgid "Info"
+msgstr "資訊"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspect Device"
+msgstr "查看裝置"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspecting is currently only supported in WebView"
+msgstr ""
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspector"
+msgstr "檢查器"
+
+#: app/components/stf/install/install-error-filter.js:13
+msgid "Installation canceled by user."
+msgstr "使用者取消安裝"
+
+#: app/components/stf/install/install-error-filter.js:9
+msgid "Installation failed due to an unknown error."
+msgstr "不明原因的安裝錯誤"
+
+#: app/components/stf/install/install-error-filter.js:7
+msgid "Installation succeeded."
+msgstr "安裝成功"
+
+#: app/components/stf/install/install-error-filter.js:11
+msgid "Installation timed out."
+msgstr "安裝超時"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Installing app..."
+msgstr "App 安裝中..."
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Key"
+msgstr ""
+
+#: app/settings/settings-controller.js:10
+msgid "Keys"
+msgstr ""
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Landscape"
+msgstr "橫式"
+
+#: app/settings/general/language/language.html:1
+msgid "Language"
+msgstr "語言"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Launch Activity"
+msgstr "執行活動"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Launching activity..."
+msgstr "活動執行中"
+
+#: app/control-panes/info/info.html:1 app/control-panes/logs/logs.html:1
+msgid "Level"
+msgstr "電量"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Local Settings"
+msgstr "本機設定"
+
+#: app/device-list/column/device-column-service.js:250
+msgid "Location"
+msgstr "位置"
+
+#: app/control-panes/automation/device-settings/device-settings.html:7
+msgid "Lock Rotation"
+msgstr "鎖定螢幕旋轉"
+
+#: app/control-panes/control-panes-controller.js:50
+msgid "Logs"
+msgstr ""
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Maintenance"
+msgstr "維護"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid ""
+"Make sure to copy your access token now. You won't be able to see it again."
+msgstr ""
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Manage Apps"
+msgstr "管理 Apps"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Manner Mode"
+msgstr "管理模式"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:165
+msgid "Manufacturer"
+msgstr "製造商"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Media"
+msgstr "媒體"
+
+#: app/control-panes/info/info.html:1
+msgid "Memory"
+msgstr "記憶體"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Menu"
+msgstr "選單"
+
+#: app/components/stf/device/device-info-filter/index.js:92
+msgid "Mobile"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:93
+msgid "Mobile DUN"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:94
+msgid "Mobile High Priority"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:95
+msgid "Mobile MMS"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:96
+msgid "Mobile SUPL"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:31
+msgid "Model"
+msgstr "型號"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "More about Access Tokens"
+msgstr "關於存取憑證"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "More about ADB Keys"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Mute"
+msgstr "靜音"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Name"
+msgstr "名稱"
+
+#: app/menu/menu.html:1
+msgid "Native"
+msgstr ""
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Navigation"
+msgstr "導航"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:129
+msgid "Network"
+msgstr "網路"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Next"
+msgstr "下一首"
+
+#: app/components/stf/device/device-info-filter/index.js:115
+msgid "No"
+msgstr ""
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "No access tokens"
+msgstr "沒有存取憑證"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "No ADB keys"
+msgstr ""
+
+#: app/components/stf/control/control-service.js:126
+msgid "No clipboard data"
+msgstr "剪貼簿無資料"
+
+#: app/control-panes/resources/resources.html:1
+msgid "No cookies to show"
+msgstr ""
+
+#: app/components/stf/screen/screen.html:1
+msgid "No device screen"
+msgstr "沒有裝置畫面"
+
+#: app/device-list/empty/device-list-empty.html:1
+msgid "No devices connected"
+msgstr "無裝置連線"
+
+#: app/components/stf/common-ui/modals/lightbox-image/lightbox-image.html:1
+msgid "No photo available"
+msgstr "沒有可用的照片"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "No Ports Forwarded"
+msgstr ""
+
+#: app/control-panes/screenshots/screenshots.html:5
+msgid "No screenshots taken"
+msgstr "尚未拍截圖"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Normal Mode"
+msgstr "正常模式"
+
+#: app/components/stf/device/device-info-filter/index.js:70
+msgid "Not Charging"
+msgstr "未充電"
+
+#: app/device-list/column/device-column-service.js:256
+msgid "Notes"
+msgstr "備註"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Nothing to inspect"
+msgstr ""
+
+#: app/settings/notifications/notifications.html:1
+msgid "Notifications"
+msgstr "通知"
+
+#: app/control-panes/info/info.html:1
+msgid "Number"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:22
+#: app/components/stf/device/device-info-filter/index.js:7
+msgid "Offline"
+msgstr "離線"
+
+#: app/components/stf/common-ui/error-message/error-message.html:1
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Oops!"
+msgstr "糟糕了!有錯誤發生"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Open"
+msgstr "開啟"
+
+#: app/control-panes/info/info.html:1
+msgid "Orientation"
+msgstr "螢幕方向"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:55
+msgid "OS"
+msgstr "平台"
+
+#: app/components/stf/device/device-info-filter/index.js:49
+msgid "Over Voltage"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:50
+msgid "Overheat"
+msgstr "過熱"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Package"
+msgstr "套件"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Password"
+msgstr "密碼"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Permissions"
+msgstr "權限"
+
+#: app/device-list/column/device-column-service.js:184
+msgid "Phone"
+msgstr "手機"
+
+#: app/device-list/column/device-column-service.js:196
+msgid "Phone ICCID"
+msgstr "手機 ICCID"
+
+#: app/device-list/column/device-column-service.js:190
+msgid "Phone IMEI"
+msgstr "手機 IMEI"
+
+#: app/control-panes/info/info.html:1
+msgid "Physical Device"
+msgstr "實體裝置"
+
+#: app/control-panes/logs/logs.html:1
+msgid "PID"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Place"
+msgstr "位置"
+
+#: app/control-panes/info/info.html:1
+msgid "Platform"
+msgstr "平台"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Play/Pause"
+msgstr "播放 / 暫停"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter a valid email"
+msgstr "請輸入正確的 email"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your email"
+msgstr "請輸入你的 email"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your LDAP username"
+msgstr "請輸入你的 LDAP 帳號"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your name"
+msgstr "請輸入你的名字"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your password"
+msgstr "請輸入你的密碼"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store password"
+msgstr "請輸入你儲存的密碼"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store username"
+msgstr "請輸入你儲存的帳號"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Port"
+msgstr ""
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Port Forwarding"
+msgstr ""
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Portrait"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Power"
+msgstr "電源鍵"
+
+#: app/control-panes/info/info.html:1
+msgid "Power Source"
+msgstr "電源"
+
+#: app/components/stf/device/device-info-filter/index.js:24
+#: app/components/stf/device/device-info-filter/index.js:9
+msgid "Preparing"
+msgstr "準備中"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:104
+msgid "Press Back button"
+msgstr "按返回鍵"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:103
+msgid "Press Home button"
+msgstr "按主目錄鍵"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:102
+msgid "Press Menu button"
+msgstr "按選單鍵"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Previous"
+msgstr "上一首"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Processing..."
+msgstr "處理中..."
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:37
+msgid "Product"
+msgstr "產品"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Pushing app..."
+msgstr "App 傳送中..."
+
+#: app/control-panes/info/info.html:1
+msgid "RAM"
+msgstr "記憶體"
+
+#: app/components/stf/device/device-info-filter/index.js:10
+#: app/components/stf/device/device-info-filter/index.js:25
+msgid "Ready"
+msgstr "就緒"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:39
+msgid "Reconnected successfully."
+msgstr "重新連線成功"
+
+#: app/components/stf/common-ui/refresh-page/refresh-page.html:1
+msgid "Refresh"
+msgstr "更新"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:49
+msgid "Released"
+msgstr "發佈"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reload"
+msgstr "重新讀取"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug.html:1
+msgid "Remote debug"
+msgstr "遠端除蟲"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "Remove"
+msgstr "移除"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+#: app/device-list/device-list.html:1
+msgid "Reset"
+msgstr "重設"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reset all browser settings"
+msgstr "重設所有瀏覽器設定"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Reset Settings"
+msgstr "重設設定"
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Restart Device"
+msgstr "裝置重新啟動"
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retrieving the device screen has timed out."
+msgstr "取得裝置畫面已超時"
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retry"
+msgstr "重試"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Rewind"
+msgstr "倒帶"
+
+#: app/control-panes/info/info.html:1
+msgid "Roaming"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "ROM"
+msgstr "唯讀記憶體"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:92
+msgid "Rotate Left"
+msgstr "向左旋轉"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:93
+msgid "Rotate Right"
+msgstr "向右旋轉"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run"
+msgstr "執行"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run JavaScript"
+msgstr "執行 JavaScript"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:31
+msgid ""
+"Run the following on your command line to debug the device from your Browser"
+msgstr ""
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:28
+msgid ""
+"Run the following on your command line to debug the device from your IDE"
+msgstr ""
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Run this command to copy the key to your clipboard"
+msgstr ""
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+msgid "Save ScreenShot"
+msgstr "儲存螢幕截圖"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Save..."
+msgstr ""
+
+#: app/device-list/column/device-column-service.js:135
+msgid "Screen"
+msgstr "螢幕尺寸"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Screenshot"
+msgstr "螢幕截圖"
+
+#: app/control-panes/control-panes-controller.js:8
+msgid "Screenshots"
+msgstr "更多螢幕截圖"
+
+#: app/control-panes/info/info.html:1
+msgid "SD Card Mounted"
+msgstr "已安裝 SD 卡"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:171
+msgid "SDK"
+msgstr ""
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Search"
+msgstr "搜尋"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:91
+msgid "Selects Next IME"
+msgstr "選擇下一個輸入法"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:159
+msgid "Serial"
+msgstr "序號"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Server"
+msgstr "伺服器"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Server error. Check log output."
+msgstr "伺服器錯誤. 請確認輸出的紀錄"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set"
+msgstr ""
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set Cookie"
+msgstr ""
+
+#: app/control-panes/dashboard/apps/apps.html:1 app/menu/menu.html:1
+msgid "Settings"
+msgstr "設定"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Shell"
+msgstr ""
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Show Screen"
+msgstr "顯示畫面"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign In"
+msgstr "登入"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign Out"
+msgstr "登出"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Silent Mode"
+msgstr "靜音模式"
+
+#: app/control-panes/info/info.html:1
+msgid "SIM"
+msgstr "SIM"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Size"
+msgstr "尺寸"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:26
+msgid "Socket connection was lost"
+msgstr "Socket 失去連線"
+
+#: app/components/stf/device/device-info-filter/index.js:36
+msgid "Someone stole your device."
+msgstr "別人偷了你的裝置."
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Special Keys"
+msgstr "特殊按鍵"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Start/Stop Logging"
+msgstr "開始 / 停止 紀錄"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:25
+msgid "Status"
+msgstr "狀態"
+
+#: app/control-panes/advanced/input/input.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Stop"
+msgstr "停止"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/components/stf/device/device-info-filter/index.js:11
+#: app/control-panes/device-control/device-control.html:1
+msgid "Stop Using"
+msgstr "停止使用"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Store Account"
+msgstr "儲存帳號"
+
+#: app/control-panes/info/info.html:1
+msgid "Sub Type"
+msgstr "次類別"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Switch Charset"
+msgstr "切換大小寫"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Tag"
+msgstr "標籤"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Pageshot (Needs WebView running)"
+msgstr "取得頁面截圖 (需執行 WebView)"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Screenshot"
+msgstr "截圖"
+
+#: app/control-panes/info/info.html:1
+msgid "Temperature"
+msgstr "溫度"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Text"
+msgstr "文字"
+
+#: app/components/stf/screen/screen.html:1
+msgid "The current view is marked secure and cannot be viewed remotely."
+msgstr "目前的畫面已被保護,無法從遠端監看"
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:11
+msgid "The device will be unavailable for a moment."
+msgstr "此裝置暫時無法使用"
+
+#: app/components/stf/install/install-error-filter.js:34
+msgid "The existing package could not be deleted."
+msgstr "現存的套件無法刪除"
+
+#: app/components/stf/install/install-error-filter.js:58
+msgid ""
+"The new package couldn't be installed because the verification did not "
+"succeed."
+msgstr "新套件無法安裝,驗證不成功."
+
+#: app/components/stf/install/install-error-filter.js:56
+msgid ""
+"The new package couldn't be installed because the verification timed out."
+msgstr "驗證超時,新套件無法安裝"
+
+#: app/components/stf/install/install-error-filter.js:54
+msgid ""
+"The new package couldn't be installed in the specified install location "
+"because the media is not available."
+msgstr "目前媒體無法使用,無法安裝新套件到指定位置"
+
+#: app/components/stf/install/install-error-filter.js:52
+msgid ""
+"The new package couldn't be installed in the specified install location."
+msgstr "無法安裝新套件到指定位置"
+
+#: app/components/stf/install/install-error-filter.js:40
+msgid ""
+"The new package failed because it contains a content provider with thesame "
+"authority as a provider already installed in the system."
+msgstr "新套件安裝失敗,相同的內容供應商授權已存在."
+
+#: app/components/stf/install/install-error-filter.js:44
+msgid ""
+"The new package failed because it has specified that it is a test-only "
+"package and the caller has not supplied the INSTALL_ALLOW_TEST flag."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:42
+msgid ""
+"The new package failed because the current SDK version is newer than that "
+"required by the package."
+msgstr " 新套件安裝失敗,因為目前的 SDK 版本太新"
+
+#: app/components/stf/install/install-error-filter.js:38
+msgid ""
+"The new package failed because the current SDK version is older than that "
+"required by the package."
+msgstr "新套件安裝失敗,目前 SDK 版本太舊"
+
+#: app/components/stf/install/install-error-filter.js:36
+msgid ""
+"The new package failed while optimizing and validating its dex files, either"
+" because there was not enough storage or the validation failed."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:64
+msgid ""
+"The new package has an older version code than the currently installed "
+"package."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:62
+msgid "The new package is assigned a different UID than it previously held."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:48
+msgid "The new package uses a feature that is not available."
+msgstr "新套件使用尚未支援的功能"
+
+#: app/components/stf/install/install-error-filter.js:32
+msgid "The new package uses a shared library that is not available."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:20
+msgid "The package archive file is invalid."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:46
+msgid ""
+"The package being installed contains native code, but none that is "
+"compatible with the device's CPU_ABI."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:60
+msgid "The package changed from what the calling program expected."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:18
+msgid "The package is already installed."
+msgstr "套件已安裝"
+
+#: app/components/stf/install/install-error-filter.js:24
+msgid ""
+"The package manager service found that the device didn't have enough storage"
+" space to install the app."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:84
+msgid ""
+"The parser did not find any actionable tags (instrumentation or application)"
+" in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:72
+msgid "The parser did not find any certificates in the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:78
+msgid "The parser encountered a bad or missing package name in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:80
+msgid "The parser encountered a bad shared user id name in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:76
+msgid ""
+"The parser encountered a CertificateEncodingException in one of the files in"
+" the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:70
+msgid "The parser encountered an unexpected exception."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:82
+msgid "The parser encountered some structural problem in the manifest."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:74
+msgid "The parser found inconsistent certificates on the files in the .apk."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:66
+msgid ""
+"The parser was given a path that is not a file, or does not end with the "
+"expected '.apk' extension."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:68
+msgid "The parser was unable to retrieve the AndroidManifest.xml file."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:28
+msgid "The requested shared user does not exist."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:90
+msgid ""
+"The system failed to install the package because its packaged native code "
+"did not match any of the ABIs supported by the system."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:86
+msgid "The system failed to install the package because of system issues."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:88
+msgid ""
+"The system failed to install the package because the user is restricted from"
+" installing apps."
+msgstr ""
+
+#: app/components/stf/install/install-error-filter.js:22
+msgid "The URI passed in is invalid."
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "TID"
+msgstr ""
+
+#: app/control-panes/logs/logs.html:1
+msgid "Time"
+msgstr "時間"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Tip:"
+msgstr "提示:"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Title"
+msgstr "標題"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:107
+msgid "Toggle Web/Native"
+msgstr "切換 Web / Native"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Total Devices"
+msgstr "所有裝置"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "translate"
+msgstr "翻譯"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+msgid "Try to reconnect"
+msgstr "嘗試重新連線"
+
+#: app/control-panes/info/info.html:1
+msgid "Type"
+msgstr "類型"
+
+#: app/components/stf/device/device-info-filter/index.js:23
+#: app/components/stf/device/device-info-filter/index.js:8
+msgid "Unauthorized"
+msgstr "未授權"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uninstall"
+msgstr "移除"
+
+#: app/components/stf/device/device-info-filter/index.js:14
+#: app/components/stf/device/device-info-filter/index.js:29
+msgid "Unknown"
+msgstr "未知"
+
+#: app/components/stf/device/device-info-filter/index.js:40
+msgid "Unknown reason."
+msgstr "未知原因"
+
+#: app/control-panes/automation/device-settings/device-settings.html:6
+msgid "Unlock Rotation"
+msgstr "解除螢幕旋轉"
+
+#: app/components/stf/device/device-info-filter/index.js:51
+msgid "Unspecified Failure"
+msgstr ""
+
+#: app/components/stf/upload/upload-error-filter.js:7
+msgid "Upload failed"
+msgstr "上傳失敗"
+
+#: app/control-panes/dashboard/install/install.html:5
+msgid "Upload From Link"
+msgstr "從連結上傳"
+
+#: app/components/stf/upload/upload-error-filter.js:8
+msgid "Upload unknown error"
+msgstr "未知的上傳錯誤"
+
+#: app/components/stf/upload/upload-error-filter.js:4
+msgid "Uploaded file is not valid"
+msgstr "上傳的檔案無效"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uploading..."
+msgstr "上傳中..."
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Usable Devices"
+msgstr "可用的裝置"
+
+#: app/components/stf/device/device-info-filter/index.js:59
+msgid "USB"
+msgstr "USB"
+
+#: app/control-panes/advanced/usb/usb.html:1
+msgid "Usb speed"
+msgstr "Usb 速度"
+
+#: app/components/stf/device/device-info-filter/index.js:13
+msgid "Use"
+msgstr "使用"
+
+#: app/device-list/column/device-column-service.js:262
+msgid "User"
+msgstr "使用者"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Username"
+msgstr "帳號"
+
+#: app/components/stf/device/device-info-filter/index.js:26
+msgid "Using"
+msgstr "使用中"
+
+#: app/control-panes/info/info.html:1
+msgid "Using Fallback"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Version"
+msgstr "版本"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "Version Update"
+msgstr "版本更新"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Vibrate Mode"
+msgstr "震動模式"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "VNC"
+msgstr "VNC"
+
+#: app/control-panes/info/info.html:1
+msgid "Voltage"
+msgstr "電壓"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume"
+msgstr "音量"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Down"
+msgstr "降低音量"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Up"
+msgstr "提高音量"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Warning:"
+msgstr "警告:"
+
+#: app/menu/menu.html:1
+msgid "Web"
+msgstr "網頁"
+
+#: app/control-panes/info/info.html:1
+msgid "Width"
+msgstr "寬度"
+
+#: app/components/stf/device/device-info-filter/index.js:105
+#: app/components/stf/device/device-info-filter/index.js:97
+#: app/control-panes/automation/device-settings/device-settings.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "WiFi"
+msgstr "WiFi"
+
+#: app/components/stf/device/device-info-filter/index.js:98
+msgid "WiMAX"
+msgstr "WiMAX"
+
+#: app/components/stf/device/device-info-filter/index.js:60
+msgid "Wireless"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "X DPI"
+msgstr ""
+
+#: app/control-panes/info/info.html:1
+msgid "Y DPI"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:113
+msgid "Yes"
+msgstr ""
+
+#: app/components/stf/device/device-info-filter/index.js:35
+msgid "You (or someone else) kicked the device."
+msgstr "你 (或別人) 把裝置踢掉了."
diff --git a/crowdstf/res/common/lang/po/stf.zh_CN.po b/crowdstf/res/common/lang/po/stf.zh_CN.po
new file mode 100644
index 0000000..0d810a2
--- /dev/null
+++ b/crowdstf/res/common/lang/po/stf.zh_CN.po
@@ -0,0 +1,1566 @@
+# 
+# Translators:
+# Jon Liang <jonqchk@gmail.com>, 2016
+# Peng Wang <buaawp@gmail.com>, 2016
+# qingliangcn <qing.liang.cn@gmail.com>, 2015
+# shengxiang <codeskyblue@gmail.com>, 2015
+# Peng Wang <buaawp@gmail.com>, 2016
+# Ye Yorick <yexiali0791@163.com>, 2016
+# 凯允 杨 <956090321@qq.com>, 2015
+msgid ""
+msgstr ""
+"Project-Id-Version: STF\n"
+"PO-Revision-Date: 2016-04-18 06:32+0000\n"
+"Last-Translator: Jon Liang <jonqchk@gmail.com>\n"
+"Language-Team: Chinese (China) (http://www.transifex.com/openstf/stf/language/zh_CN/)\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: zh_CN\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+#: app/components/stf/device/device-info-filter/index.js:117
+#: app/components/stf/device/device-info-filter/index.js:52
+#: app/components/stf/device/device-info-filter/index.js:61
+#: app/components/stf/device/device-info-filter/index.js:71
+msgid "-"
+msgstr "VNC(虚拟网络计算机远程工具)远程登录"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "A new version of STF is available"
+msgstr "STF有新版本可下载"
+
+#: app/components/stf/install/install-error-filter.js:26
+msgid "A package is already installed with the same name."
+msgstr "已经安装了一个相同名字的安装包"
+
+#: app/components/stf/install/install-error-filter.js:30
+msgid ""
+"A previously installed package of the same name has a different signature "
+"than the new package (and the old package's data was not removed)."
+msgstr "新安装包同之前的某个同名安装包发生了签名冲突(老的安装包的数据没有移除)"
+
+#: app/components/stf/install/install-error-filter.js:50
+msgid "A secure container mount point couldn't be accessed on external media."
+msgstr "外部存储无法访问安全容器挂载点"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:178
+msgid "ABI"
+msgstr "ABI"
+
+#: app/components/stf/device/device-info-filter/index.js:58
+msgid "AC"
+msgstr "AC"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Access Tokens"
+msgstr "访问令牌"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Account"
+msgstr "帐户"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Action"
+msgstr "动作"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Actions"
+msgstr "更多动作"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Activity"
+msgstr "活动"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "ADB Keys"
+msgstr "安卓调试桥密钥"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Add"
+msgstr "添加"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add ADB Key"
+msgstr "添加ADB Key"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Add Key"
+msgstr "添加Key"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Add the following ADB Key to STF?"
+msgstr "添加以下的ADB Key到STF?"
+
+#: app/layout/layout-controller.js:7
+msgid "Admin mode has been disabled."
+msgstr "管理员模式已关闭"
+
+#: app/layout/layout-controller.js:6
+msgid "Admin mode has been enabled."
+msgstr "管理员模式已启用"
+
+#: app/control-panes/control-panes-controller.js:20
+msgid "Advanced"
+msgstr "高级"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Advanced Input"
+msgstr "高级输入"
+
+#: app/control-panes/info/info.html:1
+msgid "Airplane Mode"
+msgstr "飞行模式"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "App Store"
+msgstr "应用商店"
+
+#: app/control-panes/dashboard/install/install.html:1
+msgid "App Upload"
+msgstr "上传APP"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Apps"
+msgstr "应用程序"
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:10
+msgid "Are you sure you want to reboot this device?"
+msgstr "你确定要重启这台设备么?"
+
+#: app/control-panes/control-panes-controller.js:14
+msgid "Automation"
+msgstr "自动化"
+
+#: app/components/stf/device/device-info-filter/index.js:28
+msgid "Available"
+msgstr "可用"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Back"
+msgstr "返回"
+
+#: app/control-panes/info/info.html:1
+msgid "Battery"
+msgstr "电池"
+
+#: app/device-list/column/device-column-service.js:202
+msgid "Battery Health"
+msgstr "电池健康"
+
+#: app/device-list/column/device-column-service.js:226
+msgid "Battery Level"
+msgstr "电池电量"
+
+#: app/device-list/column/device-column-service.js:210
+msgid "Battery Source"
+msgstr "电池电源"
+
+#: app/device-list/column/device-column-service.js:218
+msgid "Battery Status"
+msgstr "电池状态"
+
+#: app/device-list/column/device-column-service.js:239
+msgid "Battery Temp"
+msgstr "电池温度"
+
+#: app/components/stf/device/device-info-filter/index.js:89
+msgid "Bluetooth"
+msgstr "蓝牙"
+
+#: app/device-list/column/device-column-service.js:153
+msgid "Browser"
+msgstr "浏览器"
+
+#: app/components/stf/device/device-info-filter/index.js:12
+#: app/components/stf/device/device-info-filter/index.js:27
+msgid "Busy"
+msgstr "繁忙"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Busy Devices"
+msgstr "繁忙的设备"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Camera"
+msgstr "相机"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Cancel"
+msgstr "取消"
+
+#: app/components/stf/upload/upload-error-filter.js:6
+msgid "Cannot access specified URL"
+msgstr "无法访问指定的URL"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:43
+msgid "Carrier"
+msgstr "信号"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Category"
+msgstr "分类"
+
+#: app/components/stf/device/device-info-filter/index.js:67
+msgid "Charging"
+msgstr "充电中"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Check errors below"
+msgstr "检查下列错误"
+
+#: app/components/stf/common-ui/clear-button/clear-button.html:1
+#: app/control-panes/advanced/run-js/run-js.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Clear"
+msgstr "清除"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Clipboard"
+msgstr "剪贴板"
+
+#: app/components/stf/device/device-info-filter/index.js:46
+msgid "Cold"
+msgstr "冷却"
+
+#: app/components/stf/device/device-info-filter/index.js:21
+#: app/components/stf/device/device-info-filter/index.js:6
+#: app/control-panes/info/info.html:1
+msgid "Connected"
+msgstr "已连接"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:20
+msgid "Connected successfully."
+msgstr "连接成功"
+
+#: app/menu/menu.html:1
+msgid "Control"
+msgstr "控制"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Cookies"
+msgstr "Cookies"
+
+#: app/control-panes/info/info.html:1
+msgid "Cores"
+msgstr "核"
+
+#: app/control-panes/info/info.html:1
+#: app/control-panes/performance/cpu/cpu.html:1
+msgid "CPU"
+msgstr "CPU"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Current rotation:"
+msgstr "当前屏幕旋转"
+
+#: app/device-list/device-list.html:1
+msgid "Customize"
+msgstr "自定义"
+
+#: app/control-panes/advanced/input/input.html:12
+msgid "D-pad Center"
+msgstr "模拟键--中间"
+
+#: app/control-panes/advanced/input/input.html:20
+msgid "D-pad Down"
+msgstr "模拟键--下"
+
+#: app/control-panes/advanced/input/input.html:9
+msgid "D-pad Left"
+msgstr "模拟键--左"
+
+#: app/control-panes/advanced/input/input.html:15
+msgid "D-pad Right"
+msgstr "模拟键--右"
+
+#: app/control-panes/advanced/input/input.html:4
+msgid "D-pad Up"
+msgstr "模拟键--上"
+
+#: app/control-panes/control-panes-controller.js:41
+msgid "Dashboard"
+msgstr "控制面板"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Data"
+msgstr "数据"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Date"
+msgstr "日期"
+
+#: app/components/stf/device/device-info-filter/index.js:48
+msgid "Dead"
+msgstr "无效"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Delete"
+msgstr "删除"
+
+#: app/control-panes/info/info.html:1
+msgid "Density"
+msgstr "密度"
+
+#: app/device-list/device-list.html:1
+msgid "Details"
+msgstr "细节"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Developer"
+msgstr "开发者"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/inspect/inspect.html:1
+msgid "Device"
+msgstr "设备"
+
+#: app/device-list/details/device-list-details-directive.js:38
+#: app/device-list/icons/device-list-icons-directive.js:122
+msgid "Device cannot get kicked from the group"
+msgstr "设备无法从该组移出"
+
+#: app/components/stf/device/device-info-filter/index.js:38
+msgid "Device is not present anymore for some reason."
+msgstr "设备由于某些原因找不到"
+
+#: app/components/stf/device/device-info-filter/index.js:39
+msgid "Device is present but offline."
+msgstr "设备已找到但处于离线状态"
+
+#: app/control-panes/info/info.html:1
+msgid "Device Photo"
+msgstr "设备照片"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Device Settings"
+msgstr "设备设置"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+msgid "Device was disconnected"
+msgstr "设备已断开连接"
+
+#: app/components/stf/device/device-info-filter/index.js:37
+msgid "Device was kicked by automatic timeout."
+msgstr "设备由于超时已被移出"
+
+#: app/device-list/device-list.html:1 app/menu/menu.html:1
+msgid "Devices"
+msgstr "设备"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Disable WiFi"
+msgstr "关闭WIFI"
+
+#: app/components/stf/device/device-info-filter/index.js:68
+msgid "Discharging"
+msgstr "未充电"
+
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+#: app/components/stf/device/device-info-filter/index.js:20
+#: app/components/stf/device/device-info-filter/index.js:5
+msgid "Disconnected"
+msgstr "断开连接"
+
+#: app/control-panes/info/info.html:1
+msgid "Display"
+msgstr "播放"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Drop file to upload"
+msgstr "拖放文件到这里以上传"
+
+#: app/components/stf/device/device-info-filter/index.js:90
+msgid "Dummy"
+msgstr "虚拟的"
+
+#: app/settings/notifications/notifications.html:1
+msgid "Enable notifications"
+msgstr "允许提醒"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Enable WiFi"
+msgstr "启用WIFI"
+
+#: app/control-panes/info/info.html:1
+msgid "Encrypted"
+msgstr "加密的"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:31
+msgid "Error"
+msgstr "错误"
+
+#: app/components/stf/control/control-service.js:129
+msgid "Error while getting data"
+msgstr "获取数据时发生错误"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:35
+msgid "Error while reconnecting"
+msgstr "重新连接时发生错误"
+
+#: app/components/stf/device/device-info-filter/index.js:91
+msgid "Ethernet"
+msgstr "以太网"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Executes remote shell commands"
+msgstr "执行远程shell命令"
+
+#: app/components/stf/upload/upload-error-filter.js:5
+msgid "Failed to download file"
+msgstr "文件下载失败"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Fast Forward"
+msgstr "快进"
+
+#: app/control-panes/control-panes-controller.js:26
+msgid "File Explorer"
+msgstr "文件管理器"
+
+#: app/components/stf/common-ui/filter-button/filter-button.html:1
+msgid "Filter"
+msgstr "过滤器"
+
+#: app/control-panes/info/info.html:1
+msgid "Find Device"
+msgstr "查找设备"
+
+#: app/components/stf/common-ui/modals/add-adb-key-modal/add-adb-key-modal.html:1
+msgid "Fingerprint"
+msgstr "指纹"
+
+#: app/control-panes/info/info.html:1
+msgid "FPS"
+msgstr "FPS"
+
+#: app/control-panes/info/info.html:1
+msgid "Frequency"
+msgstr "频率"
+
+#: app/components/stf/device/device-info-filter/index.js:69
+msgid "Full"
+msgstr "全部"
+
+#: app/settings/settings-controller.js:5
+msgid "General"
+msgstr "通用"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate Access Token"
+msgstr "生成访问令牌"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Generate Login for VNC"
+msgstr "生成登录VNC"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Generate New Token"
+msgstr "生成新令牌"
+
+#: app/control-panes/logs/logs.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "Get"
+msgstr "获取"
+
+#: app/control-panes/dashboard/clipboard/clipboard.html:1
+msgid "Get clipboard contents"
+msgstr "获取剪贴板内容"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Back"
+msgstr "返回"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Go Forward"
+msgstr "前进"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:89
+msgid "Go to Device List"
+msgstr "跳转到设备列表"
+
+#: app/components/stf/device/device-info-filter/index.js:47
+msgid "Good"
+msgstr "良好"
+
+#: app/control-panes/info/info.html:1
+msgid "Hardware"
+msgstr "硬件"
+
+#: app/control-panes/info/info.html:1
+msgid "Health"
+msgstr "健康"
+
+#: app/control-panes/info/info.html:1
+msgid "Height"
+msgstr "高度"
+
+#: app/menu/menu.html:1
+msgid "Help"
+msgstr "帮助"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Hide Screen"
+msgstr "隐藏屏幕"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/device-control/device-control.html:1
+msgid "Home"
+msgstr "主屏界面"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Host"
+msgstr "主机地址"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Hostname"
+msgstr "主机名"
+
+#: app/control-panes/info/info.html:1
+msgid "ICCID"
+msgstr "集成电路卡识别码"
+
+#: app/control-panes/info/info.html:1
+msgid "ID"
+msgstr "ID"
+
+#: app/control-panes/info/info.html:1
+msgid "IMEI"
+msgstr "IMEI"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Incorrect login details"
+msgstr "登录信息错误"
+
+#: app/control-panes/control-panes-controller.js:32
+msgid "Info"
+msgstr "信息"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspect Device"
+msgstr "被检查设备"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspecting is currently only supported in WebView"
+msgstr "检查目前只支持网页视图"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Inspector"
+msgstr "检查器"
+
+#: app/components/stf/install/install-error-filter.js:13
+msgid "Installation canceled by user."
+msgstr "用户已取消安装。"
+
+#: app/components/stf/install/install-error-filter.js:9
+msgid "Installation failed due to an unknown error."
+msgstr "未知原因导致安装失败"
+
+#: app/components/stf/install/install-error-filter.js:7
+msgid "Installation succeeded."
+msgstr "安装成功"
+
+#: app/components/stf/install/install-error-filter.js:11
+msgid "Installation timed out."
+msgstr "安装超时"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Installing app..."
+msgstr "安装 app..."
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Key"
+msgstr "密钥"
+
+#: app/settings/settings-controller.js:10
+msgid "Keys"
+msgstr "按键"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Landscape"
+msgstr "横排"
+
+#: app/settings/general/language/language.html:1
+msgid "Language"
+msgstr "语言"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Launch Activity"
+msgstr "启动活动"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Launching activity..."
+msgstr "活动启动中..."
+
+#: app/control-panes/info/info.html:1 app/control-panes/logs/logs.html:1
+msgid "Level"
+msgstr "等级"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Local Settings"
+msgstr "本地设置"
+
+#: app/device-list/column/device-column-service.js:250
+msgid "Location"
+msgstr "位置"
+
+#: app/control-panes/automation/device-settings/device-settings.html:7
+msgid "Lock Rotation"
+msgstr "锁定屏幕旋转"
+
+#: app/control-panes/control-panes-controller.js:50
+msgid "Logs"
+msgstr "日志"
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Maintenance"
+msgstr "维护"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid ""
+"Make sure to copy your access token now. You won't be able to see it again."
+msgstr "请确保已备份您的身份验证凭证,此凭证后续将不再显示!"
+
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "Manage Apps"
+msgstr "管理Apps"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Manner Mode"
+msgstr "管理模式"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:165
+msgid "Manufacturer"
+msgstr "制造商"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Media"
+msgstr "媒体"
+
+#: app/control-panes/info/info.html:1
+msgid "Memory"
+msgstr "内存"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Menu"
+msgstr "菜单"
+
+#: app/components/stf/device/device-info-filter/index.js:92
+msgid "Mobile"
+msgstr "手机"
+
+#: app/components/stf/device/device-info-filter/index.js:93
+msgid "Mobile DUN"
+msgstr "手机网络桥接"
+
+#: app/components/stf/device/device-info-filter/index.js:94
+msgid "Mobile High Priority"
+msgstr "移动网络最高优先级"
+
+#: app/components/stf/device/device-info-filter/index.js:95
+msgid "Mobile MMS"
+msgstr "手机彩信"
+
+#: app/components/stf/device/device-info-filter/index.js:96
+msgid "Mobile SUPL"
+msgstr "平面定位特定移动数据连接"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:31
+msgid "Model"
+msgstr "型号"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "More about Access Tokens"
+msgstr "关于身份验证凭证"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "More about ADB Keys"
+msgstr "更多关于安卓调试桥密钥"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Mute"
+msgstr "静音"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Name"
+msgstr "名称"
+
+#: app/menu/menu.html:1
+msgid "Native"
+msgstr "本地"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Navigation"
+msgstr "导航"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:129
+msgid "Network"
+msgstr "网络"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Next"
+msgstr "下一步"
+
+#: app/components/stf/device/device-info-filter/index.js:115
+msgid "No"
+msgstr "否"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "No access tokens"
+msgstr "没有身份验证凭证"
+
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "No ADB keys"
+msgstr "没有安卓调试桥的密钥"
+
+#: app/components/stf/control/control-service.js:126
+msgid "No clipboard data"
+msgstr "剪贴板没有数据"
+
+#: app/control-panes/resources/resources.html:1
+msgid "No cookies to show"
+msgstr "没有本地cookies缓存"
+
+#: app/components/stf/screen/screen.html:1
+msgid "No device screen"
+msgstr "没有设备画面"
+
+#: app/device-list/empty/device-list-empty.html:1
+msgid "No devices connected"
+msgstr "无设备连接"
+
+#: app/components/stf/common-ui/modals/lightbox-image/lightbox-image.html:1
+msgid "No photo available"
+msgstr "沒有可用的照片"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "No Ports Forwarded"
+msgstr "没有端口转发"
+
+#: app/control-panes/screenshots/screenshots.html:5
+msgid "No screenshots taken"
+msgstr "未拍截图"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Normal Mode"
+msgstr "正常模式\""
+
+#: app/components/stf/device/device-info-filter/index.js:70
+msgid "Not Charging"
+msgstr "未充电"
+
+#: app/device-list/column/device-column-service.js:256
+msgid "Notes"
+msgstr "标注"
+
+#: app/control-panes/inspect/inspect.html:1
+msgid "Nothing to inspect"
+msgstr "无需检查"
+
+#: app/settings/notifications/notifications.html:1
+msgid "Notifications"
+msgstr "通知"
+
+#: app/control-panes/info/info.html:1
+msgid "Number"
+msgstr "数字"
+
+#: app/components/stf/device/device-info-filter/index.js:22
+#: app/components/stf/device/device-info-filter/index.js:7
+msgid "Offline"
+msgstr "离线"
+
+#: app/components/stf/common-ui/error-message/error-message.html:1
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Oops!"
+msgstr "出错了!"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Open"
+msgstr "打开"
+
+#: app/control-panes/info/info.html:1
+msgid "Orientation"
+msgstr "屏幕方向"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:55
+msgid "OS"
+msgstr "操作系统"
+
+#: app/components/stf/device/device-info-filter/index.js:49
+msgid "Over Voltage"
+msgstr "电压过高"
+
+#: app/components/stf/device/device-info-filter/index.js:50
+msgid "Overheat"
+msgstr "过热"
+
+#: app/control-panes/dashboard/install/activities/activities.html:1
+msgid "Package"
+msgstr "程序安装包"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Password"
+msgstr "密码"
+
+#: app/control-panes/explorer/explorer.html:1
+msgid "Permissions"
+msgstr "权限"
+
+#: app/device-list/column/device-column-service.js:184
+msgid "Phone"
+msgstr "手机"
+
+#: app/device-list/column/device-column-service.js:196
+msgid "Phone ICCID"
+msgstr "手机集成电路卡识别码(手机SIM卡唯一识别码)"
+
+#: app/device-list/column/device-column-service.js:190
+msgid "Phone IMEI"
+msgstr "手机国际移动设备标识"
+
+#: app/control-panes/info/info.html:1
+msgid "Physical Device"
+msgstr "物理设备"
+
+#: app/control-panes/logs/logs.html:1
+msgid "PID"
+msgstr "进程号"
+
+#: app/control-panes/info/info.html:1
+msgid "Place"
+msgstr "位置"
+
+#: app/control-panes/info/info.html:1
+msgid "Platform"
+msgstr "平台"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Play/Pause"
+msgstr "播放/暂停"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter a valid email"
+msgstr "请输入正确格式的 email"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your email"
+msgstr "请输入您的 email"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your LDAP username"
+msgstr "请输入您的LDAP用户名"
+
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Please enter your name"
+msgstr "请输入您的姓名"
+
+#: auth/ldap/scripts/signin/signin.html:1
+msgid "Please enter your password"
+msgstr "请输入您的密码"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store password"
+msgstr "请输入您所保存的密码"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Please enter your Store username"
+msgstr "输入您所保存的用户名"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Port"
+msgstr "端口"
+
+#: app/control-panes/advanced/port-forwarding/port-forwarding.html:1
+msgid "Port Forwarding"
+msgstr "转发端口"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Portrait"
+msgstr "竖排"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Power"
+msgstr "电源"
+
+#: app/control-panes/info/info.html:1
+msgid "Power Source"
+msgstr "电源来源"
+
+#: app/components/stf/device/device-info-filter/index.js:24
+#: app/components/stf/device/device-info-filter/index.js:9
+msgid "Preparing"
+msgstr "准备中"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:104
+msgid "Press Back button"
+msgstr "按后退键"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:103
+msgid "Press Home button"
+msgstr "按Home键"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:102
+msgid "Press Menu button"
+msgstr "按菜单键"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Previous"
+msgstr "先前的"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Processing..."
+msgstr "处理中..."
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:37
+msgid "Product"
+msgstr "产品"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Pushing app..."
+msgstr "正在推送 app..."
+
+#: app/control-panes/info/info.html:1
+msgid "RAM"
+msgstr "随机存取存储器"
+
+#: app/components/stf/device/device-info-filter/index.js:10
+#: app/components/stf/device/device-info-filter/index.js:25
+msgid "Ready"
+msgstr "就绪"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:39
+msgid "Reconnected successfully."
+msgstr "重新连接成功"
+
+#: app/components/stf/common-ui/refresh-page/refresh-page.html:1
+msgid "Refresh"
+msgstr "刷新"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:49
+msgid "Released"
+msgstr "释放"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reload"
+msgstr "重新加载"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug.html:1
+msgid "Remote debug"
+msgstr "远程调试"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+#: app/settings/keys/adb-keys/adb-keys.html:1
+msgid "Remove"
+msgstr "移除"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+#: app/device-list/device-list.html:1
+msgid "Reset"
+msgstr "重置"
+
+#: app/control-panes/dashboard/navigation/navigation.html:1
+msgid "Reset all browser settings"
+msgstr "重置所有浏览器设置"
+
+#: app/settings/general/local/local-settings.html:1
+msgid "Reset Settings"
+msgstr "重置设置"
+
+#: app/control-panes/advanced/maintenance/maintenance.html:1
+msgid "Restart Device"
+msgstr "重启设备"
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retrieving the device screen has timed out."
+msgstr "获取设备画面超时"
+
+#: app/components/stf/screen/screen.html:1
+msgid "Retry"
+msgstr "重试"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Rewind"
+msgstr "回滚"
+
+#: app/control-panes/info/info.html:1
+msgid "Roaming"
+msgstr "漫游状态"
+
+#: app/control-panes/info/info.html:1
+msgid "ROM"
+msgstr "ROM"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:92
+msgid "Rotate Left"
+msgstr "向左翻转"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/control-panes/control-panes-hotkeys-controller.js:93
+msgid "Rotate Right"
+msgstr "向右翻转"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run"
+msgstr "运行"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Run JavaScript"
+msgstr "运行 JavaScript"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:31
+msgid ""
+"Run the following on your command line to debug the device from your Browser"
+msgstr "运行下面的命令行从您的浏览器中调试设备"
+
+#: app/control-panes/dashboard/remote-debug/remote-debug-controller.js:28
+msgid ""
+"Run the following on your command line to debug the device from your IDE"
+msgstr "运行下面命令行从您的IDE调试设备"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Run this command to copy the key to your clipboard"
+msgstr "运行此命令将密钥复制到剪贴板"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+msgid "Save ScreenShot"
+msgstr "保存屏幕截图"
+
+#: app/control-panes/advanced/run-js/run-js.html:1
+msgid "Save..."
+msgstr "正在保存"
+
+#: app/device-list/column/device-column-service.js:135
+msgid "Screen"
+msgstr "屏幕"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Screenshot"
+msgstr "屏幕截图"
+
+#: app/control-panes/control-panes-controller.js:8
+msgid "Screenshots"
+msgstr "更多屏幕截图"
+
+#: app/control-panes/info/info.html:1
+msgid "SD Card Mounted"
+msgstr "SD卡已加载"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:171
+msgid "SDK"
+msgstr "SDK"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Search"
+msgstr "搜索"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:91
+msgid "Selects Next IME"
+msgstr "选择下一个输入法"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:159
+msgid "Serial"
+msgstr "串行"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "Server"
+msgstr "服务器"
+
+#: auth/ldap/scripts/signin/signin.html:1
+#: auth/mock/scripts/signin/signin.html:1
+msgid "Server error. Check log output."
+msgstr "服务器错误,请检查输出的日志纪录。"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set"
+msgstr "设置"
+
+#: app/control-panes/resources/resources.html:1
+msgid "Set Cookie"
+msgstr "设置cookies缓存"
+
+#: app/control-panes/dashboard/apps/apps.html:1 app/menu/menu.html:1
+msgid "Settings"
+msgstr "设置"
+
+#: app/control-panes/dashboard/shell/shell.html:1
+msgid "Shell"
+msgstr "Shell脚本"
+
+#: app/control-panes/device-control/device-control.html:1
+msgid "Show Screen"
+msgstr "显示屏幕"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign In"
+msgstr "登录"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Sign Out"
+msgstr "注销"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Silent Mode"
+msgstr "静音模式"
+
+#: app/control-panes/info/info.html:1
+msgid "SIM"
+msgstr "SIM"
+
+#: app/control-panes/explorer/explorer.html:1
+#: app/control-panes/info/info.html:1
+msgid "Size"
+msgstr "大小"
+
+#: app/components/stf/socket/socket-state/socket-state-directive.js:26
+msgid "Socket connection was lost"
+msgstr "双向的通信连接丢失"
+
+#: app/components/stf/device/device-info-filter/index.js:36
+msgid "Someone stole your device."
+msgstr "有人占用了你的设备"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Special Keys"
+msgstr "特殊要点"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Start/Stop Logging"
+msgstr "开始 / 停止 日志纪录"
+
+#: app/control-panes/info/info.html:1
+#: app/device-list/column/device-column-service.js:25
+msgid "Status"
+msgstr "状态"
+
+#: app/control-panes/advanced/input/input.html:1
+#: app/control-panes/logs/logs.html:1
+msgid "Stop"
+msgstr "停止"
+
+#: app/components/stf/device-context-menu/device-context-menu.html:1
+#: app/components/stf/device/device-info-filter/index.js:11
+#: app/control-panes/device-control/device-control.html:1
+msgid "Stop Using"
+msgstr "停止使用"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Store Account"
+msgstr "保存账号"
+
+#: app/control-panes/info/info.html:1
+msgid "Sub Type"
+msgstr "子类型"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Switch Charset"
+msgstr "切换字符集"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Tag"
+msgstr "标签"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Pageshot (Needs WebView running)"
+msgstr "使用截图(需要WebView 运行)"
+
+#: app/control-panes/screenshots/screenshots.html:1
+msgid "Take Screenshot"
+msgstr "屏幕截图"
+
+#: app/control-panes/info/info.html:1
+msgid "Temperature"
+msgstr "温度"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Text"
+msgstr "文字"
+
+#: app/components/stf/screen/screen.html:1
+msgid "The current view is marked secure and cannot be viewed remotely."
+msgstr "当前视图有安全标记,并且不能远程查看"
+
+#: app/control-panes/advanced/maintenance/maintenance-controller.js:11
+msgid "The device will be unavailable for a moment."
+msgstr "此设备将暂时无法使用"
+
+#: app/components/stf/install/install-error-filter.js:34
+msgid "The existing package could not be deleted."
+msgstr "现有的程序包可能不会被删除"
+
+#: app/components/stf/install/install-error-filter.js:58
+msgid ""
+"The new package couldn't be installed because the verification did not "
+"succeed."
+msgstr "新包不能安装,因为验证没有成功。"
+
+#: app/components/stf/install/install-error-filter.js:56
+msgid ""
+"The new package couldn't be installed because the verification timed out."
+msgstr "新包不能安装,因为验证超时。"
+
+#: app/components/stf/install/install-error-filter.js:54
+msgid ""
+"The new package couldn't be installed in the specified install location "
+"because the media is not available."
+msgstr "新包不能安装在指定的安装位置,因为媒体是不可用。"
+
+#: app/components/stf/install/install-error-filter.js:52
+msgid ""
+"The new package couldn't be installed in the specified install location."
+msgstr "新包不能安装在指定的安装位置。"
+
+#: app/components/stf/install/install-error-filter.js:40
+msgid ""
+"The new package failed because it contains a content provider with thesame "
+"authority as a provider already installed in the system."
+msgstr "新的包安装失败,因为它包含了相同名字的认证已经安装在系统中提供一个内容提供商。"
+
+#: app/components/stf/install/install-error-filter.js:44
+msgid ""
+"The new package failed because it has specified that it is a test-only "
+"package and the caller has not supplied the INSTALL_ALLOW_TEST flag."
+msgstr "新包安装失败,因为它已被指定,它是一个用于测试的包和调用者并没有给它提供INSTALL_ALLOW_TEST标志。"
+
+#: app/components/stf/install/install-error-filter.js:42
+msgid ""
+"The new package failed because the current SDK version is newer than that "
+"required by the package."
+msgstr "新的包安装失败,因为当前的SDK版本比程序包所依赖的版本高。"
+
+#: app/components/stf/install/install-error-filter.js:38
+msgid ""
+"The new package failed because the current SDK version is older than that "
+"required by the package."
+msgstr "新的包安装失败,因为当前的SDK比安装包所依赖的版本低。"
+
+#: app/components/stf/install/install-error-filter.js:36
+msgid ""
+"The new package failed while optimizing and validating its dex files, either"
+" because there was not enough storage or the validation failed."
+msgstr "新的包安装失败,可能没有验证dex文件或者没有足够的存储导致验证失败。"
+
+#: app/components/stf/install/install-error-filter.js:64
+msgid ""
+"The new package has an older version code than the currently installed "
+"package."
+msgstr "新的软件包比目前安装的软件包代码版本旧。"
+
+#: app/components/stf/install/install-error-filter.js:62
+msgid "The new package is assigned a different UID than it previously held."
+msgstr "新包分配置的UID与它以前持有的不同。"
+
+#: app/components/stf/install/install-error-filter.js:48
+msgid "The new package uses a feature that is not available."
+msgstr "新包使用功能不可用。"
+
+#: app/components/stf/install/install-error-filter.js:32
+msgid "The new package uses a shared library that is not available."
+msgstr "新包使用的共享库不可用"
+
+#: app/components/stf/install/install-error-filter.js:20
+msgid "The package archive file is invalid."
+msgstr "包存档文件无效。"
+
+#: app/components/stf/install/install-error-filter.js:46
+msgid ""
+"The package being installed contains native code, but none that is "
+"compatible with the device's CPU_ABI."
+msgstr "正在安装软件包中包含源生内核,但都没有与该设备的CPU_ABI兼容。"
+
+#: app/components/stf/install/install-error-filter.js:60
+msgid "The package changed from what the calling program expected."
+msgstr "程序包被预期调用的程序所修改。"
+
+#: app/components/stf/install/install-error-filter.js:18
+msgid "The package is already installed."
+msgstr "程序包已经安装。"
+
+#: app/components/stf/install/install-error-filter.js:24
+msgid ""
+"The package manager service found that the device didn't have enough storage"
+" space to install the app."
+msgstr "包管理器服务发现该设备没有足够的存储空间来安装应用程序。"
+
+#: app/components/stf/install/install-error-filter.js:84
+msgid ""
+"The parser did not find any actionable tags (instrumentation or application)"
+" in the manifest."
+msgstr "解析器没有发现清单任何可操作的标签(工具或应用程序) 。"
+
+#: app/components/stf/install/install-error-filter.js:72
+msgid "The parser did not find any certificates in the .apk."
+msgstr "解析器没有发现在.apk文件的任何证书。"
+
+#: app/components/stf/install/install-error-filter.js:78
+msgid "The parser encountered a bad or missing package name in the manifest."
+msgstr "分析器在清单中遇到损坏或丢失的包名。"
+
+#: app/components/stf/install/install-error-filter.js:80
+msgid "The parser encountered a bad shared user id name in the manifest."
+msgstr "分析器在清单中遇到坏共享的用户ID名称。"
+
+#: app/components/stf/install/install-error-filter.js:76
+msgid ""
+"The parser encountered a CertificateEncodingException in one of the files in"
+" the .apk."
+msgstr "解析器在的apk的一个文件时遇到证书编码异常。"
+
+#: app/components/stf/install/install-error-filter.js:70
+msgid "The parser encountered an unexpected exception."
+msgstr "解析器遇到意外的异常。"
+
+#: app/components/stf/install/install-error-filter.js:82
+msgid "The parser encountered some structural problem in the manifest."
+msgstr "分析器在清单中遇到的一些结构性问题。"
+
+#: app/components/stf/install/install-error-filter.js:74
+msgid "The parser found inconsistent certificates on the files in the .apk."
+msgstr "分析器发现在的apk文件不一致的证书。"
+
+#: app/components/stf/install/install-error-filter.js:66
+msgid ""
+"The parser was given a path that is not a file, or does not end with the "
+"expected '.apk' extension."
+msgstr "解析器所获得的是一个路径而不是一个文件,或者文件没有以‘.apk’结尾。"
+
+#: app/components/stf/install/install-error-filter.js:68
+msgid "The parser was unable to retrieve the AndroidManifest.xml file."
+msgstr "分析器无法检索AndroidManifest.xml文件。"
+
+#: app/components/stf/install/install-error-filter.js:28
+msgid "The requested shared user does not exist."
+msgstr "所请求的共享用户不存在"
+
+#: app/components/stf/install/install-error-filter.js:90
+msgid ""
+"The system failed to install the package because its packaged native code "
+"did not match any of the ABIs supported by the system."
+msgstr "该系统无法安装此软件包,因为其包装原生内核没有匹配到应系统所支持的的ABI。"
+
+#: app/components/stf/install/install-error-filter.js:86
+msgid "The system failed to install the package because of system issues."
+msgstr "因为系统问题无法安装程序。"
+
+#: app/components/stf/install/install-error-filter.js:88
+msgid ""
+"The system failed to install the package because the user is restricted from"
+" installing apps."
+msgstr "程序安装失败,因为用户被限制安装该应用软件"
+
+#: app/components/stf/install/install-error-filter.js:22
+msgid "The URI passed in is invalid."
+msgstr "该URI传递无效"
+
+#: app/control-panes/logs/logs.html:1
+msgid "TID"
+msgstr "线程号"
+
+#: app/control-panes/logs/logs.html:1
+msgid "Time"
+msgstr "时间"
+
+#: app/components/stf/keys/add-adb-key/add-adb-key.html:1
+msgid "Tip:"
+msgstr "提示:"
+
+#: app/components/stf/tokens/generate-access-token/generate-access-token.html:1
+msgid "Title"
+msgstr "标题"
+
+#: app/control-panes/control-panes-hotkeys-controller.js:107
+msgid "Toggle Web/Native"
+msgstr "切换网络/本地"
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Total Devices"
+msgstr "设备总数"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/control-panes/resources/resources.html:1
+msgid "translate"
+msgstr "翻译"
+
+#: app/components/stf/common-ui/modals/fatal-message/fatal-message.html:1
+#: app/components/stf/common-ui/modals/socket-disconnected/socket-disconnected.html:1
+msgid "Try to reconnect"
+msgstr "尝试重新连接"
+
+#: app/control-panes/info/info.html:1
+msgid "Type"
+msgstr "类型"
+
+#: app/components/stf/device/device-info-filter/index.js:23
+#: app/components/stf/device/device-info-filter/index.js:8
+msgid "Unauthorized"
+msgstr "未授权的"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uninstall"
+msgstr "卸载"
+
+#: app/components/stf/device/device-info-filter/index.js:14
+#: app/components/stf/device/device-info-filter/index.js:29
+msgid "Unknown"
+msgstr "未知"
+
+#: app/components/stf/device/device-info-filter/index.js:40
+msgid "Unknown reason."
+msgstr "未知的原因。"
+
+#: app/control-panes/automation/device-settings/device-settings.html:6
+msgid "Unlock Rotation"
+msgstr "旋转解锁"
+
+#: app/components/stf/device/device-info-filter/index.js:51
+msgid "Unspecified Failure"
+msgstr "未指定的故障"
+
+#: app/components/stf/upload/upload-error-filter.js:7
+msgid "Upload failed"
+msgstr "上传失败"
+
+#: app/control-panes/dashboard/install/install.html:5
+msgid "Upload From Link"
+msgstr "从超链接上传"
+
+#: app/components/stf/upload/upload-error-filter.js:8
+msgid "Upload unknown error"
+msgstr "未知的上传错误"
+
+#: app/components/stf/upload/upload-error-filter.js:4
+msgid "Uploaded file is not valid"
+msgstr "上传的文件是无效的"
+
+#: app/control-panes/dashboard/install/install.html:7
+msgid "Uploading..."
+msgstr "上传中..."
+
+#: app/device-list/stats/device-list-stats.html:1
+msgid "Usable Devices"
+msgstr "可用的设备"
+
+#: app/components/stf/device/device-info-filter/index.js:59
+msgid "USB"
+msgstr "USB"
+
+#: app/control-panes/advanced/usb/usb.html:1
+msgid "Usb speed"
+msgstr "USB速度"
+
+#: app/components/stf/device/device-info-filter/index.js:13
+msgid "Use"
+msgstr "使用"
+
+#: app/device-list/column/device-column-service.js:262
+msgid "User"
+msgstr "用户"
+
+#: app/control-panes/automation/store-account/store-account.html:1
+msgid "Username"
+msgstr "用户名"
+
+#: app/components/stf/device/device-info-filter/index.js:26
+msgid "Using"
+msgstr "使用中"
+
+#: app/control-panes/info/info.html:1
+msgid "Using Fallback"
+msgstr "使用回退"
+
+#: app/control-panes/info/info.html:1
+msgid "Version"
+msgstr "版本"
+
+#: app/components/stf/common-ui/modals/version-update/version-update.html:1
+msgid "Version Update"
+msgstr "版本更新"
+
+#: app/control-panes/automation/device-settings/device-settings.html:1
+msgid "Vibrate Mode"
+msgstr "振动模式"
+
+#: app/control-panes/advanced/vnc/vnc.html:1
+msgid "VNC"
+msgstr "VNC"
+
+#: app/control-panes/info/info.html:1
+msgid "Voltage"
+msgstr "电压"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume"
+msgstr "音量"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Down"
+msgstr "减小音量"
+
+#: app/control-panes/advanced/input/input.html:1
+msgid "Volume Up"
+msgstr "增加音量"
+
+#: app/settings/keys/access-tokens/access-tokens.html:1
+msgid "Warning:"
+msgstr "警告:"
+
+#: app/menu/menu.html:1
+msgid "Web"
+msgstr "Web"
+
+#: app/control-panes/info/info.html:1
+msgid "Width"
+msgstr "宽度"
+
+#: app/components/stf/device/device-info-filter/index.js:105
+#: app/components/stf/device/device-info-filter/index.js:97
+#: app/control-panes/automation/device-settings/device-settings.html:1
+#: app/control-panes/dashboard/apps/apps.html:1
+msgid "WiFi"
+msgstr "WiFi"
+
+#: app/components/stf/device/device-info-filter/index.js:98
+msgid "WiMAX"
+msgstr "WiMAX"
+
+#: app/components/stf/device/device-info-filter/index.js:60
+msgid "Wireless"
+msgstr "无线"
+
+#: app/control-panes/info/info.html:1
+msgid "X DPI"
+msgstr "X DPI"
+
+#: app/control-panes/info/info.html:1
+msgid "Y DPI"
+msgstr "Y DPI"
+
+#: app/components/stf/device/device-info-filter/index.js:113
+msgid "Yes"
+msgstr "是"
+
+#: app/components/stf/device/device-info-filter/index.js:35
+msgid "You (or someone else) kicked the device."
+msgstr "你 (或別人) 已移出了该设备。"
diff --git a/crowdstf/res/common/lang/translations/stf.es.json b/crowdstf/res/common/lang/translations/stf.es.json
new file mode 100644
index 0000000..de42762
--- /dev/null
+++ b/crowdstf/res/common/lang/translations/stf.es.json
@@ -0,0 +1 @@
+{"es":{"A new version of STF is available":"Una nueva versión de STF está disponible","A package is already installed with the same name.":"Ya hay un paquete instalado con el mismo nombre","Access Tokens":"Tokens de acceso","Account":"Cuenta","Action":"Acción","Actions":"Acciones","Activity":"Actividad","Add":"Añadir","Add ADB Key":"Añadir Llave de ADB","Add Key":"Añadir Llave","Admin mode has been disabled.":"El modo administrador se ha desactivado","Admin mode has been enabled.":"El modo administrador se ha activado","Advanced":"Avanzado","Airplane Mode":"Modo avión","App Upload":"Subir aplicación","Apps":"Aplicaciones","Are you sure you want to reboot this device?":"¿Estás seguro de querer reiniciar este dispositivo?","Automation":"Automatización","Available":"Disponible","Back":"Atrás","Battery":"Batería","Battery Level":"Nivel de batería","Battery Status":"Estado de la batería","Bluetooth":"Bluetooth","Browser":"Navegador","Busy":"En uso","Busy Devices":"Dispositivos en uso","Camera":"Cámara","Cancel":"Cancelar","Cannot access specified URL":"No se puedo accecer a la URL especificada","Category":"Categoría","Charging":"Cargando","Check errors below":"Comprueba los siguientes errores","Clear":"Limpiar","Clipboard":"Portapapeles","Cold":"Frío","Connected":"Conectado","Connected successfully.":"Conectado con éxito","Control":"Control","Cookies":"Cookies","Cores":"Núcleos","CPU":"CPU","Customize":"Personalizar","Dashboard":"Tablero","Data":"Datos","Date":"Fecha","Delete":"Borrar","Density":"Densidad","Details":"Detalles","Developer":"Desarrollador","Device":"Dispositivo","Device Settings":"Configuración de Dispositivo","Device was disconnected":"El dispositivo se ha desconectado","Devices":"Dispositivos","Disable WiFi":"Deshabilitar WIFI","Disconnected":"Desconectado","Enable notifications":"Habilitar notificaciones","Enable WiFi":"Habilitar WIFI","Encrypted":"Encriptado","Error":"Error","Ethernet":"Ethernet","Failed to download file":"Fallo al descargar el fichero","File Explorer":"Explorador de fichero","Find Device":"Encontrar dispositivo","General":"General","Generate New Token":"Generar nuevo token","Go to Device List":"Ir a la lista de dispositivos","Hardware":"Hardware","Height":"Ancho","Help":"Ayuda","Hide Screen":"Ocultar pantalla","Home":"Home","IMEI":"IMEI","Info":"Información","Inspect Device":"Inspeccionar dispositivo","Inspector":"Inspector","Installation canceled by user.":"Instalación cancelada por el usuario","Installation failed due to an unknown error.":"La instalación falló debido a un error desconocido","Installation succeeded.":"Instalado con éxito","Installation timed out.":"La instalación superó el tiempo de espera","Installing app...":"Instalando aplicación...","Language":"Idioma","Level":"Nivel","Lock Rotation":"Bloquear rotación","Maintenance":"Mantenimiento","Make sure to copy your access token now. You won't be able to see it again!":"Asegúrate de copiar tu token de acceso ahora. ¡No podrás volver a verlo más!","Manage Apps":"Gestionar aplicaciones","Memory":"Memoria","Menu":"Menú","Mobile":"Móvil","Model":"Modelo","More about Access Tokens":"Más sobre Tokens de acceso","Mute":"Silencio","Name":"Nombre","Native":"Nativo","Navigation":"Navegación","Network":"Red","Next":"Siguiente","No":"No","No access tokens":"Sin tokens de acceso","No clipboard data":"No hay datos en el portapapeles","No cookies to show":"No hay cookies que mostrar","No devices connected":"No hay dispositivos conectados","No photo available":"No hay imagen disponible","No screenshots taken":"No hay capturas de pantalla","Normal Mode":"Modo normal","Not Charging":"No se está cargando","Notes":"Notas","Nothing to inspect":"No hay nada que inspeccionar","Notifications":"Notificaciones","Number":"Número","Offline":"Offline","Oops!":"¡Ups!","Open":"Abrir","Orientation":"Orientación","Package":"Paquete","Password":"Contraseña","Permissions":"Permisos","Phone":"Teléfono","Phone IMEI":"IMEI del teléfono","Physical Device":"Dispositivo físico","Platform":"Plataforma","Play/Pause":"Inicio/Pausa","Please enter a valid email":"Por favor, introduce un email válido","Please enter your email":"Por favor, introduce tu email","Please enter your LDAP username":"Por favor, introduce tu usuario de LDAP","Please enter your name":"Por favor, introduce tu nombre","Please enter your password":"Por favor, introduce tu contraseña","Port":"Puerto","Preparing":"Preparando","Press Back button":"Pulsa el botón Volver","Press Home button":"Pulsa el botón Home","Press Menu button":"Pulsa el botón Menú","Previous":"Anterior","Processing...":"Procesando...","Product":"Producto","RAM":"RAM","Ready":"Listo","Refresh":"Actualizar","Reload":"Recargar","Remote debug":"Conexión remota","Remove":"Eliminar","Reset":"Reiniciar","Reset all browser settings":"Restablecer todos los ajustes del navegador","Reset Settings":"Restablecer ajustes","Restart Device":"Reiniciar dispositivo","Retry":"Reintentar","ROM":"ROM","Rotate Left":"Rotar a la izquierda","Rotate Right":"Rotar a la derecha","Run":"Ejecutar","Run JavaScript":"Ejecutar JavaScript","Run this command to copy the key to your clipboard":"Ejecuta este comando para copiar la clave al portapapeles","Save ScreenShot":"Guardar captura de pantalla","Save...":"Guardar","Screen":"Pantalla","Screenshot":"Captura de pantalla","Screenshots":"Capturas de Pantalla","SDK":"SDK","Search":"Buscar","Serial":"Serie","Server":"Servidor","Settings":"Configuración","Shell":"Línea de Comandos","Show Screen":"Mostar pantalla","Sign In":"Acceder","Sign Out":"Desconectar","Silent Mode":"Modo silencio","SIM":"SIM","Size":"Tamaño","Socket connection was lost":"Se perdió la conexión con el socket","Someone stole your device.":"Alguien robó tu dispositivo","Special Keys":"Teclas especiales","Status":"Estado","Stop":"Parar","Sub Type":"Subtipo","Tag":"Etiqueta","Take Screenshot":"Capturar pantalla","Temperature":"Temperatura","Text":"Texto","The current view is marked secure and cannot be viewed remotely.":"La vista actual está marcada como segura y no puede ser vista de forma remota","The device will be unavailable for a moment.":"El dispositivo no estará disponible durante unos instantes","The existing package could not be deleted.":"El paquete no se pudo eliminar","The new package couldn't be installed because the verification did not succeed.":"El nuevo paquete no se pudo instalar porque no se pudo verificar","The new package couldn't be installed because the verification timed out.":"El nuevo paquete no se pudo instalar porque se excedió el tiempo de espera al verificarlo","The new package couldn't be installed in the specified install location.":"El nuevo paquete no se pudo instalar en el sitio especificado para su instalación","The new package failed because the current SDK version is newer than that required by the package.":"El nuevo paquete falló porque la versión actual del SDK es más reciente que la que requiere el paquete","The new package failed because the current SDK version is older than that required by the package.":"El nuevo paquete falló porque la versión actual del SDK es más antigua que la que requiere el paquete","The new package has an older version code than the currently installed package.":"El nuevo paquete tiene una versión de código más antigua que el paquete instalado actualmente.","The new package uses a feature that is not available.":"El nuevo paquete utiliza una característica que no está disponible.","The package archive file is invalid.":"El archivo del paquete no es válido","The package being installed contains native code, but none that is compatible with the device's CPU_ABI.":"El paquete que se está instalando contiene código nativo que no es compatible con el CPU_ABI del dispositivo.","The package is already installed.":"El paquete ya está instalado","Tip:":"Truco:","Title":"Título","Total Devices":"Dispositivos Totales","translate":"traducir","Try to reconnect":"Volver a conectar","Type":"Tipo","Unauthorized":"No autorizado","Uninstall":"Desinstalar","Unknown":"Desconocido","Unknown reason.":"Razón desconocida.","Unlock Rotation":"Desbloquear rotación","Unspecified Failure":"Fallo no especificado","Upload failed":"Subida fallida","Upload From Link":"Subir desde enlace","Upload unknown error":"Error de subida desconocido","Uploaded file is not valid":"El fichero de subida no es válido","Uploading...":"Subiendo...","USB":"USB","Usb speed":"Velocidad de USB","Use":"Uso","User":"Usuario","Username":"Nombre de usuario","Using":"En uso","Version":"Versión","Vibrate Mode":"Modo vibración","Volume":"Volumen","Volume Down":"Bajar volumen","Volume Up":"Subir volumen","Warning:":"Atención:","Web":"Web","Width":"Ancho","WiFi":"WIFI","Yes":"Sí"}}
\ No newline at end of file
diff --git a/crowdstf/res/common/lang/translations/stf.fr.json b/crowdstf/res/common/lang/translations/stf.fr.json
new file mode 100644
index 0000000..d5a5b1a
--- /dev/null
+++ b/crowdstf/res/common/lang/translations/stf.fr.json
@@ -0,0 +1 @@
+{"fr":{"-":"-","A new version of STF is available":"Une nouvelle version de STF est disponible","A package is already installed with the same name.":"Un paquet est déjà installé avec le même nom","A previously installed package of the same name has a different signature than the new package (and the old package's data was not removed).":"Un paquet précédemment installée du même nom a une signature différente de celle du nouveau paquet (et les données de l'ancien paquet n'a pas été supprimée).","A secure container mount point couldn't be accessed on external media.":"Un conteneur sécurisé équipé ne peut pas être accessible sur un support externe.","ABI":"IBP","AC":"AC","Access Tokens":"Jetons d'Accès","Account":"Compte","Action":"Action","Actions":"Actions","Activity":"Activité","ADB Keys":"Clefs ADB","Add":"Ajouter","Add ADB Key":"Ajouter une Clef ADB","Add Key":"Ajouter une Clef","Add the following ADB Key to STF?":"Ajouter la Clef ADB suivante dans STF?","Admin mode has been disabled.":"Le Mode Administrateur a été désactivé","Admin mode has been enabled.":"Le Mode Administrateur a été activé","Advanced":"Avancé","Advanced Input":"Entrée Avancé","Airplane Mode":"Mode Avion","App Store":"App Store","App Upload":"Téléverser une Application","Apps":"Applications","Are you sure you want to reboot this device?":"Est vous sûr de vouloir redémarrer ce terminal?","Automation":"Automatisation","Available":"Disponible","Back":"Précédent","Battery":"Batterie","Battery Health":"Santé de la Batterie","Battery Level":"Niveau de la Batterie","Battery Source":"Source de la Batterie","Battery Status":"Statut de la Batterie","Battery Temp":"Température de la Batterie","Bluetooth":"Bluetooth","Browser":"Navigateur","Busy":"Occupé","Busy Devices":"Terminaux Occupés","Camera":"Caméra","Cancel":"Annuler","Cannot access specified URL":"Impossible d’accéder à l'URL spécifiée","Carrier":"Opérateur","Category":"Catégorie","Charging":"Chargement","Check errors below":"Vérifier les erreurs ci-dessous","Clear":"Nettoyer","Clipboard":"Presse-papier","Cold":"Froid","Connected":"Connecté","Connected successfully.":"Connexion réussie","Control":"Contrôle","Cookies":"Cookies","Cores":"Coeurs","CPU":"CPU","Current rotation:":"Rotation actuelle","Customize":"Personnaliser","D-pad Center":"D-pad Centre","D-pad Down":"D-pad Bas","D-pad Left":"D-pad Gauche","D-pad Right":"D-pad Droite","D-pad Up":"D-pad Haut","Dashboard":"Tableau","Data":"Données","Date":"Date","Dead":"Mort","Delete":"Supprimer","Density":"Densité","Details":"Détails","Developer":"Développeur","Device":"Terminal","Device cannot get kicked from the group":"Le Terminal ne peut pas être exclu du groupe","Device is not present anymore for some reason.":"Le Terminal n'est plus présent pour certaines raisons","Device is present but offline.":"Le Terminal est présent mais Hors-Ligne","Device Photo":"Photos du Terminal","Device Settings":"Paramètres du Terminal","Device was disconnected":"Le Terminal était déconnecté","Device was kicked by automatic timeout.":"Le Terminal a été exclu par le Timeout automatique","Devices":"Terminaux","Disable WiFi":"Désactiver le Wifi","Discharging":"En Décharge","Disconnected":"Déconnecté","Display":"écran","Drop file to upload":"Déposer le fichier à téléverser","Dummy":"Mannequin","Enable notifications":"Activer les notifications","Enable WiFi":"Activer le Wifi","Encrypted":"Crypté","Error":"Erreur","Error while getting data":"Erreur lors de l'obtention de données","Error while reconnecting":"Erreur lors de la reconnexion","Ethernet":"Ethernet","Executes remote shell commands":"Exécute des commandes Shell à distance","Failed to download file":"Impossible de télécharger le fichier","Fast Forward":"Avance Rapide","File Explorer":"Explorateur de Fichiers","Filter":"Filtrer","Find Device":"Trouver un Terminal","Fingerprint":"Empreinte Digitale","FPS":"FPS","Frequency":"Fréquence","Full":"Rempli","General":"Général","Generate Access Token":"Générer un Jeton d'Accès","Generate Login for VNC":"Générer un identifiant pour VNC","Generate New Token":"Générer un Nouveau Jeton","Get":"Obtenir","Get clipboard contents":"Obtenir le contenu du Presse-Papier","Go Back":"Retour","Go Forward":"Avancer","Go to Device List":"Aller à la Liste des Terminaux","Good":"Bien","Hardware":"Matériel","Health":"Santé","Height":"Taille","Help":"Aide","Hide Screen":"Cacher l'écran","Home":"Accueil","Host":"Hôte","Hostname":"Nom de l'Hôte","ICCID":"ICCID","ID":"ID","IMEI":"IMEI","Incorrect login details":"Informations de connexion incorrectes","Info":"Informations","Inspect Device":"Inspecter le Terminal","Inspecting is currently only supported in WebView":"L'inspection est actuellement pris en charge uniquement dans WebView","Inspector":"Inspecteur","Installation canceled by user.":"Installation annulée par l'utilisateur","Installation failed due to an unknown error.":"Installation échouée due à une erreur inconnue","Installation succeeded.":"Installation réussie","Installation timed out.":"L'installation a expirée.","Installing app...":"En cours d'installation de l'application","Key":"Clef","Keys":"Clefs","Landscape":"Paysage","Language":"Langage","Launch Activity":"Lancer l'Activité","Launching activity...":"En cours de lancement de l'activité ...","Level":"Niveau","Local Settings":"Paramètres locaux","Location":"Localisation","Lock Rotation":"Bloquer la Rotation","Logs":"Logs","Maintenance":"Maintenance","Make sure to copy your access token now. You won't be able to see it again.":"Assurez-vous de copier votre jeton d'accès maintenant. Vous ne serez pas en mesure de le voir à nouveau.","Manage Apps":"Gérer les Applications","Manner Mode":"Mode Silencieux","Manufacturer":"Fabricant","Media":"Médias","Memory":"Mémoire","Menu":"Menu","Mobile":"Mobile","Mobile DUN":"Réseau Commuté","Mobile High Priority":"Mobile en Priorité Haute","Mobile MMS":"MMS","Mobile SUPL":"SUPL","Model":"Modèle","More about Access Tokens":"En savoir plus sur les Jetons d'Accès","More about ADB Keys":"En savoir plus sur les Clefs ADB","Mute":"Muet","Name":"Nom","Native":"Natif","Navigation":"Navigation","Network":"Réseau","Next":"Suivant","No":"Non","No access tokens":"Pas d'accès aux jetons","No ADB keys":"Pas de clefs ADB","No clipboard data":"Pas de données dans le Presse-Papier","No cookies to show":"Pas de cookies à afficher","No device screen":"Pas d'écran de terminal","No devices connected":"Pas de terminaux connectés","No photo available":"Pas de photos disponibles","No Ports Forwarded":"Pas de ports redirigés","No screenshots taken":"Pas de captures d'écran prises","Normal Mode":"Mode Normal","Not Charging":"Pas en charge","Notes":"Notes","Nothing to inspect":"Rien à inspecter","Notifications":"Notifications","Number":"Nombre","Offline":"Hors Ligne","Oops!":"Oups!","Open":"Ouvrir","Orientation":"Orientation","OS":"OS","Over Voltage":"Surtension","Overheat":"Surchauffe","Package":"Paquet","Password":"Mot de Passe","Permissions":"Permissions","Phone":"Téléphone","Phone ICCID":"ICCID du Téléphone","Phone IMEI":"IMEI du Téléphone","Physical Device":"Terminal Physique","PID":"PID","Place":"Place","Platform":"Plateforme","Play/Pause":"Jouer/Pause","Please enter a valid email":"S'il vous plaît entrez un e-mail valide","Please enter your email":"S'il vous plaît entrez vôtre e-mail","Please enter your LDAP username":"S'il vous plaît entrez vôtre compte LDAP","Please enter your name":"S'il vous plaît entrez vôtre nom","Please enter your password":"S'il vous plaît entrez vôtre mot de passe","Please enter your Store password":"S'il vous plaît entrez vôtre mot de passe du Store","Please enter your Store username":"S'il vous plaît entrez vôtre identifiant du Store","Port":"Port","Port Forwarding":"Redirection de Ports","Portrait":"Portrait","Power":"Alimentation","Power Source":"Source d'Alimentation","Preparing":"En Préparation","Press Back button":"Appuyer sur le bouton Retour","Press Home button":"Appuyer sur le bouton Accueil","Press Menu button":"Appuyer sur le bouton Menu","Previous":"Précédent","Processing...":"En Traitement ....","Product":"Produit","Pushing app...":"En cours de téléversement des Applications ....","RAM":"RAM","Ready":"Prêt","Reconnected successfully.":"Reconnexions réussis","Refresh":"Rafraîchir","Released":"Versionée","Reload":"Recharger","Remote debug":"Débogage à distance","Remove":"Enlever","Reset":"Réinitialiser","Reset all browser settings":"Réinitialiser tous les paramètres des navigateurs","Reset Settings":"Réinitialiser les paramètres","Restart Device":"Redémarrer le Terminal","Retrieving the device screen has timed out.":"La récupération de l'écran de l'appareil a expiré.","Retry":"Recommencer","Rewind":"Rembobiner","Roaming":"Roaming","ROM":"ROM","Rotate Left":"Tourner vers la gauche","Rotate Right":"Tourner vers la droite","Run":"Exécuter","Run JavaScript":"Exécuter JavaScript","Run the following on your command line to debug the device from your Browser":"Exécutez la commande suivante sur la ligne de commande pour déboguer le périphérique de votre navigateur","Run the following on your command line to debug the device from your IDE":"Exécutez la commande suivante sur la ligne de commande pour déboguer le périphérique de votre IDE","Run this command to copy the key to your clipboard":"Exécutez cette commande pour copier la clef de votre presse-papier","Save ScreenShot":"Sauver la capture d'écran","Save...":"Sauvegarde ...","Screen":"écran","Screenshot":"Capture d'écran","Screenshots":"Captures d'écran","SD Card Mounted":"Carte SD Monté","SDK":"SDK","Search":"Rechercher","Selects Next IME":"Sélectionner le prochain IME","Serial":"Sériel","Server":"Serveur","Server error. Check log output.":"Erreur Serveur. Vérifier les logs de sortie.","Set":"Paramétrer","Set Cookie":"Paramétrer le Cookie","Settings":"Paramètres","Shell":"Shell","Show Screen":"Afficher l'écran","Sign In":"S'enregistrer","Sign Out":"Se déconnecter","Silent Mode":"Mode Silencieux","SIM":"SIM","Size":"Taille","Socket connection was lost":"La connexion au Socket a été perdu","Someone stole your device.":"Quelqu'un a volé votre terminal.","Special Keys":"Clefs spéciales","Start/Stop Logging":"Démarrer/Arrêter les logs","Status":"Statut","Stop":"Arrêter","Stop Using":"Cesser d'utiliser","Store Account":"Compte du Store","Sub Type":"Sous Type","Switch Charset":"Permuter le Charset","Tag":"étiquette","Take Pageshot (Needs WebView running)":"Prendre une Prise de vue de la page (besoin de WebView)","Take Screenshot":"Prendre une Capture d'écran","Temperature":"Température","Text":"Texte","The current view is marked secure and cannot be viewed remotely.":"La vue actuelle est marqué sécurisé et ne peut être consulté à distance.","The device will be unavailable for a moment.":"Le terminal ne sera pas disponible pour un moment","The existing package could not be deleted.":"Le package existant ne peut pas être supprimé.","The new package couldn't be installed because the verification did not succeed.":"Le nouveau paquet n'a pas pu être installé car la vérification n'a pas réussi.","The new package couldn't be installed because the verification timed out.":"Le nouveau paquet n'a pas pu être installé car la vérification a expiré.","The new package couldn't be installed in the specified install location because the media is not available.":"Le nouveau paquet n'a pas pu être installé à l'emplacement spécifié pour l'installation parce que les médias ne sont pas disponibles.","The new package couldn't be installed in the specified install location.":"Le nouveau paquet n'a pas pu être installé à l'emplacement spécifié pour installation.","The new package failed because it contains a content provider with thesame authority as a provider already installed in the system.":"Le nouveau paquet a échoué car it contient un fournisseur de contenu avec la même autorité en tant que fournisseur déjà installé dans le système.","The new package failed because it has specified that it is a test-only package and the caller has not supplied the INSTALL_ALLOW_TEST flag.":"Le nouveau paquet a échoué car il a précisé qu'il est un paquet de test uniquement et l'appelant n'a pas fourni le drapeau INSTALL_ALLOW_TEST.","The new package failed because the current SDK version is newer than that required by the package.":"Le nouveau paquet a échoué parce que la version actuelle du SDK est plus récente que celle requise par le paquet.","The new package failed because the current SDK version is older than that required by the package.":"Le nouveau paquet a échoué parce que la version actuelle du SDK est plus ancienne que celle requise par le paquet.","The new package failed while optimizing and validating its dex files, either because there was not enough storage or the validation failed.":"Le nouveau paquet a échoué lors de l'optimisation et l'évaluation de ses fichiers dex, soit parce qu'il n'y avait pas assez de stockage ou la validation a échoué.","The new package has an older version code than the currently installed package.":"Le nouveau paquet a un code de version plus ancien que le paquet actuellement installé.","The new package is assigned a different UID than it previously held.":"Le nouveau paquet est affecté un ID différent qu'il détenait auparavant.","The new package uses a feature that is not available.":"Le nouveau paquet utilise une fonctionnalité qui n'est pas disponible.","The new package uses a shared library that is not available.":"Le nouveau paquet utilise une bibliothèque partagée qui n'est pas disponible.","The package archive file is invalid.":"Le fichier d'archive de paquet est invalide.","The package being installed contains native code, but none that is compatible with the device's CPU_ABI.":"Le package installé contient du code natif, mais aucun qui soit compatible avec le CPU_ABI du terminal.","The package changed from what the calling program expected.":"Le paquet a changé de ce que le programme appelant avait prévu.","The package is already installed.":"Le paquet est déjà installé","The package manager service found that the device didn't have enough storage space to install the app.":"Le service de gestionnaire de paquets a constaté que le terminal ne dispose pas de suffisamment d'espace de stockage pour installer l'application.","The parser did not find any actionable tags (instrumentation or application) in the manifest.":"L'analyseur n'a pas trouvé toutes les tags actionnables (de l'instrumentation et des applications) dans le manifest.","The parser did not find any certificates in the .apk.":"L'analyseur n'a pas trouvé de certificat dans le fichier .apk.","The parser encountered a bad or missing package name in the manifest.":"L'analyseur a rencontré un mauvais ou manquant nom du paquet dans le manifest.","The parser encountered a bad shared user id name in the manifest.":"L'analyseur a rencontré un mauvais nom d'utilisateur partagé dans le manifest.","The parser encountered a CertificateEncodingException in one of the files in the .apk.":"L'analyseur a rencontré une CertificateEncodingException dans l'un des fichiers de l'apk.","The parser encountered an unexpected exception.":"L'analyseur a rencontré une exception inattendue.","The parser encountered some structural problem in the manifest.":"L'analyseur a rencontré un problème structurel dans le manifest.","The parser found inconsistent certificates on the files in the .apk.":"L'analyseur a trouvé des certificats contradictoires sur les fichiers de l'apk.","The parser was given a path that is not a file, or does not end with the expected '.apk' extension.":"L'analyseur a donné un chemin qui n'est pas un fichier, ou ne se termine pas avec le \".apk\" extension attendue.","The parser was unable to retrieve the AndroidManifest.xml file.":"L'analyseur n'a pas pu extraire le fichier AndroidManifest.xml.","The requested shared user does not exist.":"L'utilisateur requêté partagé n'existe pas.","The system failed to install the package because its packaged native code did not match any of the ABIs supported by the system.":"Le système n'a pas réussi à installer le paquet parce que son code natif emballé ne correspond à aucune ABI supporté par le système.","The system failed to install the package because of system issues.":"Le système n'a pas réussi à installer le paquet en raison de problèmes du système.","The system failed to install the package because the user is restricted from installing apps.":"Le système n'a pas réussi à installer le paquet parce que l'utilisateur est limité à partir de l'installation d'applications.","The URI passed in is invalid.":"L'URI transmise n'est pas invalide.","TID":"TID","Time":"Temps","Tip:":"Astuce:","Title":"Titre","Toggle Web/Native":"Basculer de Web/Natif","Total Devices":"Nombre total de Terminaux","translate":"Traduire","Try to reconnect":"Essayer de se reconnecter","Type":"Type","Unauthorized":"Non Autorisé","Uninstall":"Désinstaller","Unknown":"Inconnu","Unknown reason.":"Raison inconnue.","Unlock Rotation":"Débloquer la Rotation","Unspecified Failure":"Défaillance non spécifiée","Upload failed":"Téléversement raté","Upload From Link":"Téléverser depuis le Lien","Upload unknown error":"Erreur inconnue lors du Téléversement","Uploaded file is not valid":"Le fichier téléversé n'est pas valide","Uploading...":"En cours de téléversement ...","Usable Devices":"Terminaux utilisables","USB":"USB","Usb speed":"Vitesse USB","Use":"Utiliser","User":"Utilisateur","Username":"Nom de l'utilisateur","Using":"En Utilisation","Using Fallback":"Reprise de l'Utilisation","Version":"Version","Version Update":"Version de la mise à jour","Vibrate Mode":"Mode Vibration","VNC":"VNC","Voltage":"Tension","Volume":"Volume","Volume Down":"Baisser le Volume","Volume Up":"Augmenter le Volume","Warning:":"Avertissement:","Web":"Web","Width":"Largeur","WiFi":"Wifi","WiMAX":"WiMax","Wireless":"Sans Fil","X DPI":"X DPI","Y DPI":"Y DPI","Yes":"Oui","You (or someone else) kicked the device.":"Vous (ou quelqu'un d'autre) a exclu le Terminal."}}
\ No newline at end of file
diff --git a/crowdstf/res/common/lang/translations/stf.ja.json b/crowdstf/res/common/lang/translations/stf.ja.json
new file mode 100644
index 0000000..e1d4811
--- /dev/null
+++ b/crowdstf/res/common/lang/translations/stf.ja.json
@@ -0,0 +1 @@
+{"ja":{"-":"-","A new version of STF is available":"STFの新しいバージョンがリリースされました","ABI":"ABI","AC":"AC","Access Tokens":"アクセストークン","Account":"アカウント","Action":"アクション","Actions":"アクション","Activity":"アクティビティ","ADB Keys":"ADBキー","Add":"追加","Add ADB Key":"ADBキーを追加","Add Key":"キー追加","Add the following ADB Key to STF?":"STFに下記のADBキーを追加しますか?","Admin mode has been disabled.":"管理モードは無効になりました。","Admin mode has been enabled.":"管理モードは有効になりました。","Advanced":"高度機能","Advanced Input":"高度な入力","Airplane Mode":"機内モード","App Store":"アプリストア","App Upload":"アプリアップロード","Apps":"アプリ","Are you sure you want to reboot this device?":"この端末を再起動しますか?","Automation":"自動化","Available":"利用可能","Back":"戻る","Battery":"バッテリー","Battery Health":"バッテリー健康状態","Battery Level":"バッテリーレベル","Battery Source":"バッテリー電力源","Battery Status":"バッテリー状態","Battery Temp":"バッテリー温度","Bluetooth":"Bluetooth","Browser":"ブラウザ","Busy":"貸し出し中","Busy Devices":"貸出し中","Camera":"カメラ","Cancel":"キャンセル","Cannot access specified URL":"指定されたURLはアクセスできません","Carrier":"キャリア","Category":"カテゴリー","Charging":"充電中","Check errors below":"下記エラーがありました","Clear":"クリア","Clipboard":"クリップボード","Cold":"コールド","Connected":"接続中","Connected successfully.":"接続できました。","Control":"リモート操作","Cookies":"クッキー","Cores":"コア数","CPU":"CPU","Customize":"カスタマイズ","D-pad Center":"D-padセンター","D-pad Down":"D-pad下","D-pad Left":"D-pad左","D-pad Right":"D-pad右","D-pad Up":"D-pad上","Dashboard":"ダッシュボード","Data":"データ","Date":"日付","Dead":"残量なし","Delete":"削除","Density":"表示密度","Details":"詳細","Developer":"開発者","Device":"デバイス","Device cannot get kicked from the group":"このデバイスはグループからキックできません。","Device is not present anymore for some reason.":"実機が見えなくなりました。","Device is present but offline.":"デバイスは接続されているが、オフラインになっています。","Device Photo":"実機写真","Device Settings":"実機設定","Device was disconnected":"デバイスへの接続が切れました","Device was kicked by automatic timeout.":"デバイスは自動タイムアウトにより切断されました。","Devices":"端末リスト","Disable WiFi":"無線LANを無効にする","Discharging":"放電中","Disconnected":"切断中","Display":"ディスプレー","Drop file to upload":"ここにファイルをドロップ","Dummy":"ダミー","Enable notifications":"通知を有効にする","Enable WiFi":"無線LANを有効にする","Encrypted":"暗号化","Error":"エラー","Error while getting data":"データ取得中にエラーが発生しました。","Error while reconnecting":"再接続時にエラーが発生しました","Ethernet":"イーサーネット","Executes remote shell commands":"リモートシェルコマンドを実行する","Failed to download file":"ファイルのダウンロードが失敗しした。","Fast Forward":"早送り","File Explorer":"エクスプローラー","Filter":"フィルター","Find Device":"実機を探す","Fingerprint":"指紋","FPS":"FPS","Frequency":"クロック","Full":"フル","General":"一般","Generate Access Token":"アクセストークン生成","Generate Login for VNC":"VNC用にログイン情報生成","Generate New Token":"新規トークン生成","Get":"取得","Get clipboard contents":"クリップボードの中身を取得する","Go Back":"戻る","Go Forward":"進む","Go to Device List":"端末リストへ","Good":"良い","Hardware":"ハードウェア","Health":"健康状態","Height":"高さ","Help":"ヘルプ","Hide Screen":"画面を非表しない","Home":"ホーム","Host":"ホスト","Hostname":"ホスト名","ICCID":"ICCID","ID":"ID","IMEI":"IMEI","Incorrect login details":"不正ログイン情報","Info":"情報","Inspect Device":"端末の要素検証","Inspecting is currently only supported in WebView":"要素の検証機能は、現在WebViewのみ対応","Inspector":"要素の検証","Installation canceled by user.":"インストールはユーザーによってキャンセルされました。","Installation failed due to an unknown error.":"インストールが未知のエラーで失敗しました。","Installation succeeded.":"インストールが完了しました。","Installation timed out.":"インストールがタイムアウトしました。","Installing app...":"アプリをインストール中...","Key":"キー","Keys":"認証キー","Landscape":"横","Language":"言語","Launch Activity":"アクティビティを起動する","Launching activity...":"アクティビティを起動中...","Level":"レベル","Local Settings":"ローカル設定","Location":"場所","Lock Rotation":"回転ロック","Logs":"ログ","Maintenance":"メンテナンス","Manage Apps":"アプリ管理","Manner Mode":"マナーモード","Manufacturer":"メーカー","Media":"メディア","Memory":"メモリー","Menu":"メニュー","Mobile":"モバイル","Mobile DUN":"モバイルDUN","Mobile High Priority":"モバイル最優先","Mobile MMS":"モバイルMMS","Mobile SUPL":"モバイルSUPL","Model":"機種名","More about Access Tokens":"アクセストークンについて","More about ADB Keys":"ADBキーについて","Mute":"音を消す","Name":"名称","Native":"Native","Navigation":"ブラウジング","Network":"ネットワーク","Next":"次","No":"いいえ","No access tokens":"アクセストークンはありません","No ADB keys":"ADBキーはありません","No clipboard data":"クリップボードデータはありません","No cookies to show":"クッキーはありません","No device screen":"画面が表示できません","No devices connected":"端末が接続されていません","No photo available":"写真はありません","No Ports Forwarded":"フォワードされたポートはありません","No screenshots taken":"キャプチャはありません","Normal Mode":"通常モード","Not Charging":"充電されていない","Notes":"注釈","Nothing to inspect":"要素の検証対象はありません","Notifications":"通知","Number":"番号","Offline":"オフライン","Oops!":"おっと!","Open":"開く","Orientation":"方向","OS":"OS","Over Voltage":"過電圧","Overheat":"過熱","Package":"パッケージ","Password":"パスワード","Permissions":"権限","Phone":"電話番号","Phone ICCID":"携帯ICCID","Phone IMEI":"携帯IMEI","Physical Device":"物理デバイス","PID":"PID","Place":"場所","Platform":"プラットホーム","Play/Pause":"再生/停止","Please enter a valid email":"有効なメールアドレスを入力してください","Please enter your email":"メールアドレスを入力してください","Please enter your LDAP username":"LDAPユーザー名を入力してください","Please enter your name":"お名前を入力してください","Please enter your password":"パスワードを入力してください","Please enter your Store password":"ストアのパスワードを入力してください","Please enter your Store username":"ストアのユーザ名を入力してください","Port":"ポート","Port Forwarding":"ポートフォワーディング","Portrait":"縦","Power":"電源","Power Source":"電力源","Preparing":"準備中","Press Back button":"戻るボタンを押す","Press Home button":"ホームボタンを押す","Press Menu button":"メニューボタンを押す","Previous":"前","Processing...":"処理中...","Product":"型番","Pushing app...":"アプリをプッシュ中...","RAM":"RAM","Ready":"利用可能","Reconnected successfully.":"正常に再接続しました。","Refresh":"更新","Released":"発売日","Reload":"再読込","Remote debug":"リモートデバッグ","Remove":"削除","Reset":"初期化","Reset all browser settings":"ブラウザの設定をリセットする","Reset Settings":"すべての設定をリセット","Restart Device":"端末を再起動","Retrieving the device screen has timed out.":"実機画面の取得はタイムアウトになりました。","Retry":"再試行","Rewind":"巻き戻す","Roaming":"ローミング","ROM":"ROM","Rotate Left":"左回りに回転","Rotate Right":"右回りに回転","Run":"実行","Run JavaScript":"JavaScript注入","Run the following on your command line to debug the device from your Browser":"次のコマンドをコマンドラインで実行しますと、お使いのブラウザより端末のデバッグができます。","Run the following on your command line to debug the device from your IDE":"次のコマンドをコマンドラインで実行しますと、お使いのIDEより端末のデバッグができます。","Run this command to copy the key to your clipboard":"次のコマンドを実行しますと、キーがコピーされます","Save ScreenShot":"スクリーンショットを保存する","Save...":"保存する...","Screen":"解像度","Screenshot":"キャプチャ","Screenshots":"キャプチャ","SD Card Mounted":"SDカード","SDK":"SDK","Search":"検索","Selects Next IME":"入力モードの切り替え","Serial":"シリアル","Server":"サーバー","Server error. Check log output.":"サーバーエラー。ログを確認してください。","Set":"設定","Set Cookie":"クッキー設定","Settings":"設定","Shell":"シェル","Show Screen":"画面を表示する","Sign In":"サインイン","Sign Out":"サインアウト","Silent Mode":"マナーモード","SIM":"SIM","Size":"サイズ","Socket connection was lost":"ソケットへの接続が切れました","Someone stole your device.":"誰かはデバイスを盗みました。","Special Keys":"特別なキー","Start/Stop Logging":"ログ取得の開始/停止","Status":"ステータス","Stop":"停止","Stop Using":"停止する","Store Account":"ストアアカウント","Sub Type":"サブタイプ","Switch Charset":"文字入力の切り替え","Tag":"タグ","Take Pageshot (Needs WebView running)":"ページ全体ショットを撮る(現在はWebViewのみ対応)","Take Screenshot":"スクリーンショットを撮る","Temperature":"温度","Text":"テキスト","The device will be unavailable for a moment.":"しばらく端末が利用できなくなります。","The existing package could not be deleted.":"既存のパッケージは削除できませんでした。","The URI passed in is invalid.":"渡されたURIは無効です。","TID":"TID","Time":"時刻","Tip:":"ヒント:","Title":"タイトル","Toggle Web/Native":"ウェブ/ネイティブを選択","Total Devices":"全機種","Try to reconnect":"再接続する","Type":"タイプ","Unauthorized":"権限外","Uninstall":"削除","Unknown":"未知","Unknown reason.":"未知。","Unlock Rotation":"回転アンロック","Unspecified Failure":"未定義の失敗","Upload failed":"アップロードが失敗しました","Upload From Link":"リンク先よりアップロードする","Upload unknown error":"アップロード未知エラー","Uploaded file is not valid":"アップロードされたファイル","Uploading...":"アップロード中...","Usable Devices":"利用可能","USB":"USB","Usb speed":"USB速度","Use":"利用する","User":"ユーザ","Username":"ユーザ名","Using":"利用中","Using Fallback":"フォールバックを使用中","Version":"バージョン","Version Update":"バージョンアップ","Vibrate Mode":"マナーモード(バイブON)","VNC":"VNC","Voltage":"電圧","Volume":"音量","Volume Down":"音量↓","Volume Up":"音量↑","Warning:":"注意:","Web":"Web","Width":"幅","WiFi":"無線LAN","WiMAX":"WiMAX","Wireless":"無線","X DPI":"X DPI","Y DPI":"Y DPI","Yes":"はい","You (or someone else) kicked the device.":"この実機はキックされました。"}}
\ No newline at end of file
diff --git a/crowdstf/res/common/lang/translations/stf.ko_KR.json b/crowdstf/res/common/lang/translations/stf.ko_KR.json
new file mode 100644
index 0000000..1b060b2
--- /dev/null
+++ b/crowdstf/res/common/lang/translations/stf.ko_KR.json
@@ -0,0 +1 @@
+{"ko_KR":{"-":"-","A new version of STF is available":"새 버전의 STF를 사용 가능 합니다","A package is already installed with the same name.":"동일한 패키지가 설치되어 있습니다.","A previously installed package of the same name has a different signature than the new package (and the old package's data was not removed).":"이전에 설치된 패키지와 새로운 패키지의 서명이 다릅니다(또한 이전에 설치된 패키지 데이터가 삭제되지 않았습니다).","ABI":"ABI","AC":"AC","Access Tokens":"액세스 토큰","Account":"계정","Action":"동작","Actions":"동작","Activity":"액티비티","ADB Keys":"ADB 키","Add":"추가","Add ADB Key":"ADB 키 추가","Add Key":"키 추가","Add the following ADB Key to STF?":"다음 ADB 키를 STF에 추가 하시겠습니까?","Admin mode has been disabled.":"관리자 모드가 비활성화 되었습니다.","Admin mode has been enabled.":"관리자 모드가 활성화 되었습니다.","Advanced":"고급","Advanced Input":"고급 입력","Airplane Mode":"비행기 모드","App Store":"앱 스토어","App Upload":"앱 업로드","Apps":"앱","Are you sure you want to reboot this device?":"해당 단말기를 재시작 하시겠습니까?","Automation":"자동화","Available":"사용 가능","Back":"뒤로 가기","Battery":"배터리","Battery Health":"베터리 상태","Battery Level":"베터리 수준","Battery Source":"베터리 종류","Battery Status":"배터리 충전 상태","Battery Temp":"배터리 온도","Bluetooth":"블루투스","Browser":"브라우저","Busy":"점유중","Busy Devices":"사용 중인 단말기","Camera":"카메라","Cancel":"취소","Cannot access specified URL":"지정한 URL에 접근 할 수 없습니다","Carrier":"통신사","Category":"범주","Charging":"충전중","Check errors below":"아래 오류를 확인 하세요","Clear":"지우기","Clipboard":"클립보드","Cold":"양호","Connected":"연결","Connected successfully.":"연결했습니다","Control":"컨트롤 화면","Cookies":"쿠키","Cores":"코어 종류","CPU":"CPU","Customize":"사용자 지정","D-pad Center":"D-pad 가운데","D-pad Down":"D-pad 아래쪽","D-pad Left":"D-pad 왼쪽","D-pad Right":"D-pad 오른쪽","D-pad Up":"D-pad 위쪽","Dashboard":"대시보드","Data":"데이터","Date":"날짜","Dead":"정지","Delete":"삭제","Density":"해상도","Details":"세부 정보","Developer":"개발자 옵션","Device":"단말기","Device is not present anymore for some reason.":"더이상 단말기가 존재하지 않습니다.","Device is present but offline.":"장치가 연결되어 있지만 오프라인 상태입니다.","Device Photo":"단말기 사진","Device Settings":"단말기 설정","Device was disconnected":"연결이 끊어졌습니다","Device was kicked by automatic timeout.":"시간초과로 인해 단말기 사용이 종료되었습니다.","Devices":"단말기 리스트","Disable WiFi":"WiFi 비활성화","Discharging":"충전중이 아님","Disconnected":"연결 끊김","Display":"화면","Drop file to upload":"업로드 할 파일을 올려놓으세요","Dummy":"더미","Enable notifications":"알림 사용","Enable WiFi":"WiFi 활성화","Encrypted":"암호화","Error":"오류","Error while getting data":"데이터를 얻어오는데 실패했습니다","Error while reconnecting":"재연결이 실패 했습니다","Ethernet":"이더넷","Executes remote shell commands":"원격 쉘 명령을 실행합니다","Failed to download file":"파일을 다운로드 할 수 없습니다","Fast Forward":"빨리 감기","File Explorer":"파일 탐색기","Filter":"필터","Find Device":"장치 찾기","Fingerprint":"지문","FPS":"FPS","Frequency":"속도","Full":"전체","General":"일반","Generate Access Token":"액세스 토큰 생성","Generate Login for VNC":"VNC 로그인 생성","Generate New Token":"새로운 토큰 생성","Get":"시작","Get clipboard contents":"클립보드 내용을 가져옵니다","Go Back":"뒤로 이동","Go Forward":"앞으로 이동","Go to Device List":"단말기 목록으로 이동","Good":"양호","Hardware":"하드웨어","Health":"상태","Height":"높이","Help":"도움말","Hide Screen":"화면 숨김","Home":"홈","Host":"호스트","Hostname":"호스트이름","ICCID":"ICCID","ID":"ID","IMEI":"IMEI","Incorrect login details":"잘못된 로그인 정보","Info":"단말기 정보","Inspect Device":"단말기 검사","Inspecting is currently only supported in WebView":"검사는 웹뷰에서만 지원합니다","Inspector":"검사기","Installation canceled by user.":"사용자가 설치를 취소했습니다","Installation failed due to an unknown error.":"알 수 없는 오류로 설치가 실패했습니다","Installation succeeded.":"설치가 성공했습니다","Installation timed out.":"설치 시간 초과","Installing app...":"앱 설치중...","Key":"키","Keys":"키","Landscape":"가로","Language":"언어","Launch Activity":"액티비티 실행","Launching activity...":"액티비티 실행중...","Level":"수준","Local Settings":"로컬 설정","Location":"위치","Lock Rotation":"화면 잠금","Logs":"로그","Maintenance":"유지 관리","Make sure to copy your access token now. You won't be able to see it again!":"엑세스 토큰을 복사하세요. 다시 확인할 수 없습니다!","Manage Apps":"앱 관리","Manner Mode":"매너 모드","Manufacturer":"제조사","Media":"미디어","Memory":"메모리","Menu":"메뉴","Mobile":"모바일","Mobile DUN":"모바일 DUN","Mobile MMS":"모바일 MMS","Mobile SUPL":"모바일 SUPL","Model":"모델","More about Access Tokens":"좀 더 자세한 엑세스 토큰에 대해서 확인하기","More about ADB Keys":"좀 더 자세하게 ADB 키에 대해서 확인하기","Mute":"음소거","Name":"이름","Native":"네이티브","Navigation":"탐색","Network":"네트워크","Next":"다음","No":"아니오","No access tokens":"등록된 엑세스 토큰이 없습니다","No ADB keys":"등록된 ADB 키가 없습니다","No clipboard data":"클립보드 데이터가 없습니다","No cookies to show":"어떤 쿠키도 없습니다","No devices connected":"연결된 단말기가 없습니다","No photo available":"이용 가능한 사진이 없습니다","No Ports Forwarded":"포트 포워딩 설정이 없습니다","No screenshots taken":"저장된 스크린샷이 없습니다","Normal Mode":"표준 모드","Not Charging":"충전 안함","Notes":"메모","Notifications":"알림","Number":"전화번호","Offline":"오프라인","Oops!":"웁스!","Open":"열기","Orientation":"화면 방향","OS":"운영체제","Over Voltage":"과전압","Overheat":"과열","Package":"패키지","Password":"비밀번호","Permissions":"권한","Phone":"휴대폰","Phone ICCID":"휴대폰 ICCID","Phone IMEI":"휴대폰 IMEI","Physical Device":"물리 단말기","PID":"PID","Place":"위치","Platform":"플랫폼","Play/Pause":"재생/일시 중지","Please enter a valid email":"유효한 이메일 주소를 입력하세요","Please enter your email":"이메일 주소를 입력하세요","Please enter your LDAP username":"LDAP 사용자 이름을 입력하세요","Please enter your name":"이름을 입력하세요","Please enter your password":"비밀번호를 입력하세요","Please enter your Store password":"앱 스토어 비밀번호를 입력하세요","Please enter your Store username":"앱 스토어 아이디를 입력하세요","Port":"포트","Port Forwarding":"포트 포워딩","Portrait":"세로","Power":"전력","Power Source":"전력원","Preparing":"준비중","Press Back button":"뒤로가기 버튼을 누르세요","Press Home button":"홈 버튼을 누르세요","Press Menu button":"메뉴 버튼을 누르세요","Previous":"이전","Processing...":"처리중...","Product":"제품명","Pushing app...":"앱 전송중...","RAM":"RAM","Reconnected successfully.":"재연결이 성공했습니다","Refresh":"새로고침","Released":"릴리즈","Reload":"다시 로드","Remote debug":"원격 디버그","Remove":"제거","Reset":"초기화","Reset all browser settings":"모든 브라우저 설정 초기화","Reset Settings":"설정 초기화","Restart Device":"단말기 재시작","Retrieving the device screen has timed out.":"단말기 화면을 가져 오는 시간이 초과 되었습니다.","Retry":"재시도","Rewind":"되감기","Roaming":"로밍","ROM":"ROM","Rotate Left":"왼쪽으로 회전","Rotate Right":"오른쪽으로 회전","Run":"실행","Run JavaScript":"자바스크립트 실행","Run the following on your command line to debug the device from your IDE":"아래의 명령줄을 실행하여 IDE에서 디버그를 실행하세요","Save ScreenShot":"스크린 샷 저장","Save...":"저장...","Screen":"화면","Screenshot":"스크린 샷","Screenshots":"스크린 샷","SD Card Mounted":"SD카드 마운트","SDK":"SDK","Search":"검색","Serial":"일련 번호","Server":"서버","Server error. Check log output.":"서버 에러. 로그를 확인하세요","Set":"Set","Set Cookie":"쿠키 설정","Settings":"설정","Shell":"셸","Show Screen":"화면 표시","Sign In":"로그인","Sign Out":"로그아웃","Silent Mode":"음소거","SIM":"SIM","Size":"크기","Socket connection was lost":"소켓 연결이 끊겼습니다","Special Keys":"특수 키","Start/Stop Logging":"시작/종료 로깅","Status":"상태","Stop":"정지","Stop Using":"사용 종료","Store Account":"저장소 계정","Sub Type":"하위 유형","Switch Charset":"문자 집합 변경","Tag":"태그","Take Screenshot":"스크린샷 캡처","Temperature":"온도","Text":"텍스트","The device will be unavailable for a moment.":"이 단말기는 잠시동안 사용 할 수 없습니다.","The existing package could not be deleted.":"기존 패키지를 삭제 할 수 없습니다.","The new package couldn't be installed because the verification did not succeed.":"검증되지 않은 새로운 패키지는 설치 할 수 없습니다.","The new package couldn't be installed because the verification timed out.":"검증 시간이 초과하여 새로운 패키지를 설치 할 수 없습니다.","The new package couldn't be installed in the specified install location because the media is not available.":"미디어를 사용할 수 없어 지정한 위치에 새로운 패키지를 설치 할 수 없습니다.","The new package couldn't be installed in the specified install location.":"지정한 위치에 새로운 패키지를 설치 할 수 없습니다.","The new package failed because it contains a content provider with thesame authority as a provider already installed in the system.":"이미 동일한 패키지가 설치되어 있어 설치 할 수 없습니다.","The new package failed because the current SDK version is newer than that required by the package.":"SDK 버전이 높아 새로운 패키지를 설치 할 수 없습니다.","The new package failed because the current SDK version is older than that required by the package.":"SDK 버전이 낮아 새로운 패키지를 설치 할 수 없습니다.","The new package has an older version code than the currently installed package.":"새로운 패키지에 오래된 버전의 코드가 존재하여 설치 할 수 없습니다.","The new package is assigned a different UID than it previously held.":"새로운 패키지는 이전 패키지와 다른 UID가 할당 됐습니다.","The package archive file is invalid.":"패키지 아카이브 파일이 잘못 되었습니다.","The package is already installed.":"패키지가 이미 설치 되어 있습니다.","The parser encountered a bad or missing package name in the manifest.":"매니페스트에 잘못되거나 누락된 패키지 이름을 발견했습니다.","The parser encountered a bad shared user id name in the manifest.":"매니페스트에 잘못된 아이디나 이름을 발견했습니다.","The parser encountered an unexpected exception.":"예상하지 못한 예외가 발생하였습니다.","The parser encountered some structural problem in the manifest.":"매니페스트에 몇 가지 구조적인 문제가 발생 했습니다.","The parser found inconsistent certificates on the files in the .apk.":".apk 파일에서 일치하지 않은 인증서를 발견 했습니다.","The requested shared user does not exist.":"요청된 공용 사용자가 존재하지 않습니다.","The URI passed in is invalid.":"URL이 잘못 전달 됐습니다.","TID":"TID","Time":"시간","Tip:":"팁","Title":"제목","Toggle Web/Native":"웹/네이티브 전환","Total Devices":"총 단말기 수","translate":"번역","Try to reconnect":"다시 연결","Type":"유형","Unauthorized":"미인증","Uninstall":"설치 제거","Unknown":"알 수 없음","Unknown reason.":"알 수 없는 이유","Unlock Rotation":"회전 잠금 해제","Unspecified Failure":"지정되지 않은 오류","Upload failed":"업로드 실패","Upload From Link":"링크로 업로드","Upload unknown error":"업로드시 알수 없는 에러가 발생하였습니다","Uploaded file is not valid":"업로드된 파일이 유효하지 않습니다","Uploading...":"업로드중...","Usable Devices":"사용 가능한 단말기","USB":"USB","Usb speed":"Usb 속도","Use":"사용","User":"사용자","Username":"사용자 이름","Using":"사용중","Using Fallback":"대체","Version":"버전","Version Update":"버전 업데이트","Vibrate Mode":"진동","VNC":"VNC","Voltage":"전압","Volume":"음량","Volume Down":"음량 줄이기","Volume Up":"음량 올리기","Warning:":"경고","Web":"웹","Width":"너비","WiFi":"WiFi","WiMAX":"WiMAX","Wireless":"무선","X DPI":"X DPI","Y DPI":"Y DPI","Yes":"네","You (or someone else) kicked the device.":"당신(혹은 다른 누군가)이 단말기를 사용 종료 하였습니다."}}
\ No newline at end of file
diff --git a/crowdstf/res/common/lang/translations/stf.pl.json b/crowdstf/res/common/lang/translations/stf.pl.json
new file mode 100644
index 0000000..86ca583
--- /dev/null
+++ b/crowdstf/res/common/lang/translations/stf.pl.json
@@ -0,0 +1 @@
+{"pl":{"-":"-","A new version of STF is available":"Nowa wersja STF jest dostępna","A package is already installed with the same name.":"Pakiet o tej samej nazwie jest już zainstalowany","A previously installed package of the same name has a different signature than the new package (and the old package's data was not removed).":"Poprzednio zainstalowana paczka o tej samej nazwie ma inną sygnaturę od nowej (oraz dane starej paczki nie zostały usunięte).","A secure container mount point couldn't be accessed on external media.":"Zewnętrzne media nie mogą uzyskać dostępu do punktu montowania zabezpieczonego kontenera.","ABI":"ABI","AC":"AC","Access Tokens":"Tokeny dostępu","Account":"Konto","Action":"Akcja","Actions":"Akcje","Activity":"Aktywność","ADB Keys":"Klucze ADB","Add":"Dodaj","Add ADB Key":"Dodaj klucz ADB","Add Key":"Dodaj klucz","Add the following ADB Key to STF?":"Dodać następujący klucz ADB do STF?","Admin mode has been disabled.":"Tryb admina został wyłączony.","Admin mode has been enabled.":"Tryb admina został włączony","Advanced":"Zaawansowany","Advanced Input":"Wprowadzanie zaawansowane","Airplane Mode":"Tryb samolotowy","App Store":"Sklep","App Upload":"Wysyłanie aplikacji","Apps":"Aplikacje","Are you sure you want to reboot this device?":"Czy na pewno chcesz zrestartować to urządzenie?","Automation":"Automatyzacja","Available":"Dostępny","Back":"Wróć","Battery":"Bateria","Battery Health":"Kondycja baterii","Battery Level":"Poziom baterii","Battery Source":"Źródło baterii","Battery Status":"Status baterii","Battery Temp":"Temperatura baterii","Bluetooth":"Bluetooth","Browser":"Przeglądarka","Busy":"Zajęty","Busy Devices":"Zajęte urządzenia","Camera":"Aparat","Cancel":"Anuluj","Cannot access specified URL":"Nie można uzyskać dostępu do podanego adresu URL","Carrier":"Kariera","Category":"Kategoria","Charging":"Ładowanie","Check errors below":"Zwróć uwagę na błędy","Clear":"Czysty","Clipboard":"Schowek","Cold":"Chłodny","Connected":"Połączony","Connected successfully.":"Połączono pomyślnie.","Control":"Kontrola","Cookies":"Ciasteczka","Cores":"Rdzenia","CPU":"CPU","Customize":"Personalizuj","D-pad Center":"D-Pad Środek","D-pad Down":"D-pad Dół","D-pad Left":"D-pad Lewo","D-pad Right":"D-pad Prawo","D-pad Up":"D-pad Góra","Dashboard":"Kokpit","Data":"Dane","Dead":"Martwy","Delete":"Usuń","Density":"Gęstość","Details":"Szczegóły","Developer":"Developer","Device":"Urządzenie","Device cannot get kicked from the group":"Urządzenie nie może zostać wyrzucone z grupy","Device is not present anymore for some reason.":"Urządzenie z jakiegoś powodu nie jest już dostępne","Device is present but offline.":"Urządzenie jest dostępne, lecz jest offline.","Device Photo":"Zdjęcie urządzenia","Device Settings":"Ustawienia urządzenia","Device was disconnected":"Urządzenie zostało odłączone","Device was kicked by automatic timeout.":"Urządzenie zostało wyrzucone z powodu braku aktywności.","Devices":"Urządzenia","Disable WiFi":"Wyłącz WiFi","Discharging":"Rozładowywanie","Disconnected":"Odłączone","Display":"Wyświetlacz","Drop file to upload":"Upuść plik aby go wysłać","Dummy":"Głupie","Enable notifications":"Włącz powiadomienia","Enable WiFi":"Włącz WiFi","Encrypted":"Zaszyfrowane","Error":"Błąd","Error while getting data":"Zapytanie o dane zakończone błędem","Error while reconnecting":"Ponowne połączenie nie powiodło się","Ethernet":"Ethernet","Executes remote shell commands":"Wykonuje zdalne polecenia powłoki","Failed to download file":"Pobieranie zakończone niepowodzeniem","Fast Forward":"Przekaż do","File Explorer":"Przeglądaj pliki","Filter":"Filtr","Find Device":"Znajdź urządzenie","Fingerprint":"Odcisk palca","FPS":"FPS","Frequency":"Częstotliwość","Full":"Pełny","General":"Ogólne","Generate Access Token":"Generuj token dostępu","Generate Login for VNC":"Generuj login do VNC","Generate New Token":"Wygeneruj nowy token","Get":"Weź","Get clipboard contents":"Kopiuj zawartość schowka","Go Back":"Wróć","Go Forward":"Przejdź do","Go to Device List":"Przejdź do listy urządzeń","Good":"Dobrze","Hardware":"Hardware","Health":"Kondycja","Height":"Wysokość","Help":"Pomoc","Hide Screen":"Ukryj ekran","Home":"Dom","Host":"Gospodarz","Hostname":"Nazwa gospodarza","ICCID":"ICCID","ID":"ID","IMEI":"IMEI","Incorrect login details":"Niepoprawne dane logowania","Info":"Info","Inspect Device":"Zbadaj urządzenie","Inspector":"Inspektor","Installation canceled by user.":"Instalacja anulowana przez użytkownika.","Installation failed due to an unknown error.":"Instalacja nie powiodła się z powodu nieznanego błedu.","Installation succeeded.":"Instalacja powiodła się.","Installing app...":"Instalowanie aplikacji..","Key":"Klucz","Keys":"Klucze","Language":"Język","Launch Activity":"Uruchom aktywność","Launching activity...":"Uruchamianie aktywności..","Level":"Poziom","Local Settings":"Ustawienia lokalne","Location":"Lokacja","Lock Rotation":"Zablokuj rotację","Logs":"Logi","Manage Apps":"Zarządzaj aplikacjami","Memory":"Pamięć","Menu":"Menu","Model":"Model","Mute":"Wycisz","Name":"Nazwa","Navigation":"Nawigacja","Network":"Sieć","Next":"Dalej","No":"Nie","No access tokens":"Brak kluczy dostępu","No ADB keys":"Brak kluczy ADB","No clipboard data":"Brak danych w schowku","No cookies to show":"Brak ciastek do pokazania","No device screen":"Brak obrazu urządzenia","No devices connected":"Brak podłączonych urządzeń","No photo available":"Brak zdjęć","No Ports Forwarded":"Brak przekierowań portów","Notifications":"Notyfikacje","Offline":"Offline","Oops!":"Ups!","Open":"Otwórz","Orientation":"Orientacja","OS":"OS","Package":"Paczka","Password":"Hasło","Permissions":"Pozwolenia","PID":"PID","Platform":"Platforma","Please enter a valid email":"Proszę wpisać poprawny adres email","Please enter your email":"Proszę wpisać adres email","Please enter your password":"Proszę wpisać swoje hasło","Please enter your Store password":"Proszę wpisać swoje hasło do sklepu","Port":"Port","Port Forwarding":"Przekierowanie portów","Power":"Zasilanie","Power Source":"Źródło zasilania","Preparing":"Przygotowywanie","Press Back button":"Naciśnij klawisz \"Wstecz\"","Press Home button":"Naciśnij klawicz \"Home\"","Press Menu button":"Naciśnij klawicz \"Menu\"","Previous":"Poprzednie","Processing...":"Przetwarzanie..","Product":"Produkt","Pushing app...":"Pushowanie aplikacji..","RAM":"RAM","Ready":"Gotowe","Refresh":"Odśwież","Remove":"Usuń"}}
\ No newline at end of file
diff --git a/crowdstf/res/common/lang/translations/stf.ru_RU.json b/crowdstf/res/common/lang/translations/stf.ru_RU.json
new file mode 100644
index 0000000..53087fb
--- /dev/null
+++ b/crowdstf/res/common/lang/translations/stf.ru_RU.json
@@ -0,0 +1 @@
+{"ru_RU":{"A new version of STF is available":"Доступна новая версия STF","A package is already installed with the same name.":"Пакет уже установлен с таким же названием.","A previously installed package of the same name has a different signature than the new package (and the old package's data was not removed).":"Ранее установленный пакет с таким же названием имеет отличающуюся цифровую подпись (также не удалены данные старого пакета)","Account":"Учетная запись","Action":"Действие","Actions":"Действия","ADB Keys":"Ключи ADB","Add":"Добавить","Add ADB Key":"Добавить ADB ключ","Add Key":"Добавить ключ","Add the following ADB Key to STF?":"Добавить ADB ключ к STF?","Admin mode has been disabled.":"Режим администратора отключен.","Admin mode has been enabled.":"Режим администратора включен.","Advanced":"Расширенный","Advanced Input":"Расширенный ввод","Airplane Mode":"Режим В самолёте","App Store":"Play Маркет","App Upload":"Загрузка приложения","Apps":"Приложения","Are you sure you want to reboot this device?":"Вы уверены, что хотите перезагрузить устройство?","Automation":"Автоматизация","Available":"Доступен","Back":"Назад","Battery":"Аккумулятор","Battery Health":"Состояние батареи","Battery Level":"Уровень зарядки аккумулятора","Battery Status":"Статус аккумулятора","Battery Temp":"Температура аккумулятора","Browser":"Браузер","Busy":"Занято","Busy Devices":"Используемые устройства","Camera":"Камера","Cancel":"Отменить","Cannot access specified URL":"Невозможно отрыть заданный URL","Carrier":"Оператор","Category":"Категория","Charging":"Заряжается","Check errors below":"Проверьте сообщения об ошибках ниже","Clear":"Очистить","Clipboard":"Буфер обмена","Cold":"Холодно","Connected":"Подключено","Connected successfully.":"Подключено успешно.","Cores":"Ядер","CPU":"Процессор","Customize":"Настроить","D-pad Center":"Центральная кнопка D-pad","D-pad Down":"Вниз","D-pad Left":"Влево","D-pad Right":"Вправо","D-pad Up":"Вверх","Dashboard":"Приборная панель","Data":"Данные","Dead":"Не отвечает","Delete":"Удалить","Density":"Плотность","Details":"Детали","Developer":"Разработчик","Device":"Устройство","Device cannot get kicked from the group":"Устройство не может быть исключено из группы","Device is not present anymore for some reason.":"Подключение к устройству отсутствует по неизвестной причине.","Device is present but offline.":"Устройство подключено, но не активно.","Device Photo":"Фото устройства","Device Settings":"Настройки устройства","Device was disconnected":"Устройство было отключено","Device was kicked by automatic timeout.":"Устройство было отключено по таймауту.","Devices":"Устройства","Disable WiFi":"Отключить WiFi","Discharging":"Разряжается","Disconnected":"Отключено","Display":"Экран","Drop file to upload":"Перетащите файл для загрузки","Dummy":"Макет","Enable notifications":"Включить уведомления","Enable WiFi":"Включить WiFi","Encrypted":"Зашифровано","Error":"Ошибка","Error while getting data":"Ошибка во время получения данных","Error while reconnecting":"Ошибка переподключения","Ethernet":"Проводная сеть","Executes remote shell commands":"Выполняет удалённые команды shell","Failed to download file":"Не удалось загрузить файл","Fast Forward":"Перемотка вперёд","Filter":"Фильтр","Find Device":"Обнаружить устройство","Fingerprint":"Отпечаток пальца","Frequency":"Частота","Full":"Полный","General":"Общие","Get":"Получить","Get clipboard contents":"Получить содержимое буфера обмена","Go Back":"Назад","Go Forward":"Вперёд","Go to Device List":"Открыть список устройств","Good":"Хорошее","Hardware":"Железо","Health":"Состояние","Height":"Высота","Help":"Помощь","Hide Screen":"Спрятать экран","Home":"Домашний экран","Hostname":"Имя хоста","Incorrect login details":"Некорректные логин или пароль","Info":"Инфо","Inspect Device":"Инспектировать устройство","Inspecting is currently only supported in WebView":"Инспектирование пока поддерживается в WebView","Inspector":"Инспекто","Installation canceled by user.":"Установка отменена пользователем.","Installation failed due to an unknown error.":"Установка не удалась по неизвестной причине.","Installation succeeded.":"Установка прошла успешно.","Installation timed out.":"Время установки истекло.","Installing app...":"Устанавливаем приложение...","Key":"Ключ","Keys":"Ключи","Landscape":"Ландшафт","Language":"Язык","Launch Activity":"Запустить приложение","Launching activity...":"Приложение запускается...","Level":"Уровень","Local Settings":"Локальные настройки","Location":"Местоположение","Logs":"Журнал","Maintenance":"Обслуживание","Manage Apps":"Управление приложениями","Manufacturer":"Производитель","Memory":"Память","Menu":"Меню","Mobile":"Мобильный","Model":"Модель","More about Access Tokens":"Подробнее о ключах доступа","More about ADB Keys":"Подробнее о ADB ключах","Mute":"Выключить звук","Name":"Имя","Native":"Нативный","Navigation":"Навигация","Network":"Сеть","Next":"Следующий","No":"Нет","No access tokens":"Ключи доступа отсутствуют","No ADB keys":"ADB ключи отсутствуют","No clipboard data":"В буфере обмена нет данных","No cookies to show":"Отсутствуют cookies","No devices connected":"Нет подключенных устройств","No photo available":"Отсутствует фото","No Ports Forwarded":"Отсутствуют перенаправленные порты","No screenshots taken":"Снимки экрана отсутствуют","Normal Mode":"Нормальный режим","Not Charging":"Не заряжается","Notes":"Записи","Notifications":"Уведомления","Number":"Число","Oops!":"Ой","Open":"Открыть","Orientation":"Ориентация","Package":"Пакет","Password":"Пароль","Phone":"Телефон","Physical Device":"Физическое устройство","Place":"Место","Platform":"Платформа","Play/Pause":"Играть/Пауза","Please enter a valid email":"Пожалуйста, введите корректный email","Please enter your email":"Пожалуйста, введите email","Please enter your name":"Пожалуйста введите ваше имя","Please enter your password":"Пожалуйста введите пароль","Port":"Порт","Port Forwarding":"Перенаправление портов","Portrait":"Портетный","Power":"Энергия","Power Source":"Источник энергии","Preparing":"Подготовка","Press Back button":"Нажмите кнопку Назад","Press Home button":"Нажмите кнопку Домой","Press Menu button":"Нажмите кнопку Меню","Previous":"Предыдущий","Processing...":"Обрабатываю...","Product":"Продукт","Pushing app...":"Загружаю приложение на устройство...","Ready":"Готово","Reconnected successfully.":"Переподключился успешно.","Refresh":"Обновить","Released":"Свободно","Reload":"Перезагрузить","Remote debug":"Удалённая отладка","Remove":"Удалить","Reset":"Сбросить","Reset all browser settings":"Сбросить все настройки браузера","Reset Settings":"Сбросить настройки","Restart Device":"Перезагрузить устройство","Retrieving the device screen has timed out.":"Попытка получить снимок устройства не завершилась вовремя.","Retry":"Повторить","Rewind":"Перемотать назад","Rotate Left":"Повернуть влево","Rotate Right":"Повернуть вправо","Run":"Выполнить","Run JavaScript":"Выполнить JavaScript","Run the following on your command line to debug the device from your Browser":"Выполните эту команду, чтобы отладить устройство из вашего Браузера","Run the following on your command line to debug the device from your IDE":"Выполните эту команду, чтобы отладить устройство из вашего IDE","Run this command to copy the key to your clipboard":"Выполните эту команду, чтобы скопировать ключ в буфер обмена","Save ScreenShot":"Сохранить скриншот","Save...":"Сохранить..","Screen":"Экран","Screenshot":"Снимок экрана","Screenshots":"Снимки экрана","SD Card Mounted":"SD карта подключена","Search":"Поиск","Serial":"Серийный номер","Server error. Check log output.":"Ошибка сервера. Проверьте журнал.","Set":"Установить","Set Cookie":"Установить cookie","Settings":"Настройки","Shell":"Командная оболочка","Show Screen":"Показать экран","Sign In":"Войти","Sign Out":"Выйти","Silent Mode":"Тихий режим","Size":"Размер","Socket connection was lost":"Подключение через socket потеряно","Someone stole your device.":"Кто-то утащил ваше устройство","Special Keys":"Специальные кнопки","Start/Stop Logging":"Начать/Остановить журналирование","Status":"Статус","Stop":"Стоп","Stop Using":"Освободить","Store Account":"Учетная запись магазина приложений","Sub Type":"Подтип","Switch Charset":"Переключить кодировку","Take Pageshot (Needs WebView running)":"Сделать снимок странички (WebView должен быть запущен)","Take Screenshot":"Сделать снимок экрана","Temperature":"Температура","Text":"Текст","The current view is marked secure and cannot be viewed remotely.":"Текущий просмотр отмечен как безопасный и к нему нельзя получить доступ удалённо.","The device will be unavailable for a moment.":"Устройство будет временно недоступно.","The existing package could not be deleted.":"Существующий пакет не может быть удалён.","The new package couldn't be installed because the verification did not succeed.":"Пакет не может быть установлен из-за ошибки верификации.","The new package couldn't be installed because the verification timed out.":"Пакет не может быть установлен из-за превышения времени верификации.","The new package couldn't be installed in the specified install location because the media is not available.":"Пакет не может быть установлен в указанное место поскольку оно недоступно.","The new package couldn't be installed in the specified install location.":"Пакет не может быть установлен в указанное место.","The new package has an older version code than the currently installed package.":"Новый пакет имеет более старую версию, чем уже установленный.","The package archive file is invalid.":"Файл архива программы повреждён.","The package is already installed.":"Пакет уже установлен.","The parser encountered an unexpected exception.":"Возникла непредвиденная исключительная ситуация во время работы синтаксического анализатора.","The parser encountered some structural problem in the manifest.":"Синтаксический анализатор обнаружил структурные проблемы в манифесте.","The parser found inconsistent certificates on the files in the .apk.":"Синтаксический анализатор обнаружил несовместимый сертификат в .apk файле","The parser was given a path that is not a file, or does not end with the expected '.apk' extension.":"Синтаксическому анализатору был передан не путь к файлу, или имя файла не заканчивается на '.apk'.","The parser was unable to retrieve the AndroidManifest.xml file.":"Синтаксический анализатор не смог получить файл AndroidManifest.xml","The requested shared user does not exist.":"Требуемый общий пользователь не существует.","The system failed to install the package because of system issues.":"Не удалось установить пакет по причине системной ошибки","The system failed to install the package because the user is restricted from installing apps.":"Не удалось установить пакет, так как данному пользователю запрещена установка приложений.","The URI passed in is invalid.":"Невалидный URI.","TID":"TID","Time":"Время","Tip:":"Подсказка:","Toggle Web/Native":"Переключить Web/Native","Total Devices":"Всего устройств","Try to reconnect":"Попробовать подключиться","Type":"Тип","Unauthorized":"Неавторизован","Uninstall":"Удалить","Unknown":"Неизвестный","Unknown reason.":"Неизвестная причина.","Upload failed":"Загрузка завершилась неудачно","Upload From Link":"Загрузить по ссылке","Upload unknown error":"Неизвестная ошибка загрузки","Uploaded file is not valid":"Загруженный файл не валиден","Uploading...":"Загружается...","Usable Devices":"Доступные устройства","USB":"USB","Usb speed":"Скорость USB","Use":"Использовать","User":"Пользователь","Username":"Имя пользователя","Using":"Используется","Version":"Версия","Version Update":"Обновление версии","Vibrate Mode":"Режим вибрации","Voltage":"Напряжение","Volume":"Звук","Volume Down":"Тише","Volume Up":"Громче","Web":"Интернет","Width":"Ширина","WiFi":"WiFi","WiMAX":"WiMAX","Wireless":"Беспроводное","X DPI":"X DPI","Y DPI":"Y DPI","Yes":"Да","You (or someone else) kicked the device.":"Вы (или кто-то еще) отключили устройство."}}
\ No newline at end of file
diff --git a/crowdstf/res/common/lang/translations/stf.zh-Hant.json b/crowdstf/res/common/lang/translations/stf.zh-Hant.json
new file mode 100644
index 0000000..a566ed4
--- /dev/null
+++ b/crowdstf/res/common/lang/translations/stf.zh-Hant.json
@@ -0,0 +1 @@
+{"zh-Hant":{"-":"-","A new version of STF is available":"STF有新版本可下载","A package is already installed with the same name.":"已安裝相同名稱的軟體囉。","Access Tokens":"存取憑證","Account":"帳號","Action":"動作","Actions":"更多動作","Activity":"活動","Add":"新增","Admin mode has been disabled.":"停用 Admin 模式","Admin mode has been enabled.":"啟用 Admin 模式","Advanced":"進階","Airplane Mode":"飛行模式","App Store":"App Store","App Upload":"上傳 App","Apps":"Apps","Are you sure you want to reboot this device?":"你確定要重開這台裝置 ?","Automation":"自動化","Available":"可用","Back":"返回","Battery":"電池","Battery Health":"電池健康","Battery Level":"電池電量","Battery Source":"電池電源","Battery Status":"電池狀態","Battery Temp":"電池溫度","Bluetooth":"藍芽","Browser":"瀏覽器","Busy":"忙碌","Busy Devices":"忙碌中的裝置","Camera":"相機","Cancel":"取消","Cannot access specified URL":"無法進入指定網址","Carrier":"電信商","Category":"分類","Charging":"充電中","Check errors below":"請確認下面的錯誤","Clear":"清除","Clipboard":"剪貼簿","Connected":"已連線","Connected successfully.":"連線成功","Control":"控制","Cores":"核心","CPU":"CPU","Current rotation:":"目前螢幕方向:","Customize":"自訂","Data":"資料","Date":"日期","Delete":"刪除","Details":"細項","Developer":"開發者","Device":"裝置","Device Photo":"裝置相片","Device Settings":"裝置設定","Device was disconnected":"裝置已離線","Devices":"裝置","Disable WiFi":"關閉 WiFi","Discharging":"放電中","Disconnected":"離線","Display":"顯示","Drop file to upload":"拖曳到此上傳","Enable notifications":"開啟通知","Enable WiFi":"開啟 WiFi","Error":"錯誤","Error while getting data":"取得資料時錯誤","Error while reconnecting":"重新連線時錯誤","Failed to download file":"下載檔案失敗","Fast Forward":"快轉","File Explorer":"檔案瀏覽器","Filter":"篩選","Find Device":"尋找裝置","Fingerprint":"指紋","FPS":"FPS","Frequency":"頻率","General":"一般","Generate Access Token":"產生存取憑證","Get":"擷取","Get clipboard contents":"取得剪貼簿內容","Go Back":"返回","Go to Device List":"前往裝置清單","Good":"良好","Hardware":"硬體","Health":"健康","Height":"高度","Help":"幫助","Hide Screen":"隱藏畫面","ID":"ID","IMEI":"IMEI","Incorrect login details":"不正確的登入資訊","Info":"資訊","Inspect Device":"查看裝置","Inspector":"檢查器","Installation canceled by user.":"使用者取消安裝","Installation failed due to an unknown error.":"不明原因的安裝錯誤","Installation succeeded.":"安裝成功","Installation timed out.":"安裝超時","Installing app...":"App 安裝中...","Landscape":"橫式","Language":"語言","Launch Activity":"執行活動","Launching activity...":"活動執行中","Level":"電量","Local Settings":"本機設定","Location":"位置","Lock Rotation":"鎖定螢幕旋轉","Maintenance":"維護","Manage Apps":"管理 Apps","Manner Mode":"管理模式","Manufacturer":"製造商","Media":"媒體","Memory":"記憶體","Menu":"選單","Model":"型號","More about Access Tokens":"關於存取憑證","Mute":"靜音","Name":"名稱","Navigation":"導航","Network":"網路","Next":"下一首","No access tokens":"沒有存取憑證","No clipboard data":"剪貼簿無資料","No device screen":"沒有裝置畫面","No devices connected":"無裝置連線","No photo available":"沒有可用的照片","No screenshots taken":"尚未拍截圖","Normal Mode":"正常模式","Not Charging":"未充電","Notes":"備註","Notifications":"通知","Offline":"離線","Oops!":"糟糕了!有錯誤發生","Open":"開啟","Orientation":"螢幕方向","OS":"平台","Overheat":"過熱","Package":"套件","Password":"密碼","Permissions":"權限","Phone":"手機","Phone ICCID":"手機 ICCID","Phone IMEI":"手機 IMEI","Physical Device":"實體裝置","Place":"位置","Platform":"平台","Play/Pause":"播放 / 暫停","Please enter a valid email":"請輸入正確的 email","Please enter your email":"請輸入你的 email","Please enter your LDAP username":"請輸入你的 LDAP 帳號","Please enter your name":"請輸入你的名字","Please enter your password":"請輸入你的密碼","Please enter your Store password":"請輸入你儲存的密碼","Please enter your Store username":"請輸入你儲存的帳號","Power":"電源鍵","Power Source":"電源","Preparing":"準備中","Press Back button":"按返回鍵","Press Home button":"按主目錄鍵","Press Menu button":"按選單鍵","Previous":"上一首","Processing...":"處理中...","Product":"產品","Pushing app...":"App 傳送中...","RAM":"記憶體","Ready":"就緒","Reconnected successfully.":"重新連線成功","Refresh":"更新","Released":"發佈","Reload":"重新讀取","Remote debug":"遠端除蟲","Remove":"移除","Reset":"重設","Reset all browser settings":"重設所有瀏覽器設定","Reset Settings":"重設設定","Restart Device":"裝置重新啟動","Retrieving the device screen has timed out.":"取得裝置畫面已超時","Retry":"重試","Rewind":"倒帶","ROM":"唯讀記憶體","Rotate Left":"向左旋轉","Rotate Right":"向右旋轉","Run":"執行","Run JavaScript":"執行 JavaScript","Save ScreenShot":"儲存螢幕截圖","Screen":"螢幕尺寸","Screenshot":"螢幕截圖","Screenshots":"更多螢幕截圖","SD Card Mounted":"已安裝 SD 卡","Search":"搜尋","Selects Next IME":"選擇下一個輸入法","Serial":"序號","Server":"伺服器","Server error. Check log output.":"伺服器錯誤. 請確認輸出的紀錄","Settings":"設定","Show Screen":"顯示畫面","Sign In":"登入","Sign Out":"登出","Silent Mode":"靜音模式","SIM":"SIM","Size":"尺寸","Socket connection was lost":"Socket 失去連線","Someone stole your device.":"別人偷了你的裝置.","Special Keys":"特殊按鍵","Start/Stop Logging":"開始 / 停止 紀錄","Status":"狀態","Stop":"停止","Stop Using":"停止使用","Store Account":"儲存帳號","Sub Type":"次類別","Switch Charset":"切換大小寫","Tag":"標籤","Take Pageshot (Needs WebView running)":"取得頁面截圖 (需執行 WebView)","Take Screenshot":"截圖","Temperature":"溫度","Text":"文字","The current view is marked secure and cannot be viewed remotely.":"目前的畫面已被保護,無法從遠端監看","The device will be unavailable for a moment.":"此裝置暫時無法使用","The existing package could not be deleted.":"現存的套件無法刪除","The new package couldn't be installed because the verification did not succeed.":"新套件無法安裝,驗證不成功.","The new package couldn't be installed because the verification timed out.":"驗證超時,新套件無法安裝","The new package couldn't be installed in the specified install location because the media is not available.":"目前媒體無法使用,無法安裝新套件到指定位置","The new package couldn't be installed in the specified install location.":"無法安裝新套件到指定位置","The new package failed because it contains a content provider with thesame authority as a provider already installed in the system.":"新套件安裝失敗,相同的內容供應商授權已存在.","The new package failed because the current SDK version is newer than that required by the package.":" 新套件安裝失敗,因為目前的 SDK 版本太新","The new package failed because the current SDK version is older than that required by the package.":"新套件安裝失敗,目前 SDK 版本太舊","The new package uses a feature that is not available.":"新套件使用尚未支援的功能","The package is already installed.":"套件已安裝","Time":"時間","Tip:":"提示:","Title":"標題","Toggle Web/Native":"切換 Web / Native","Total Devices":"所有裝置","translate":"翻譯","Try to reconnect":"嘗試重新連線","Type":"類型","Unauthorized":"未授權","Uninstall":"移除","Unknown":"未知","Unknown reason.":"未知原因","Unlock Rotation":"解除螢幕旋轉","Upload failed":"上傳失敗","Upload From Link":"從連結上傳","Upload unknown error":"未知的上傳錯誤","Uploaded file is not valid":"上傳的檔案無效","Uploading...":"上傳中...","Usable Devices":"可用的裝置","USB":"USB","Usb speed":"Usb 速度","Use":"使用","User":"使用者","Username":"帳號","Using":"使用中","Version":"版本","Version Update":"版本更新","Vibrate Mode":"震動模式","VNC":"VNC","Voltage":"電壓","Volume":"音量","Volume Down":"降低音量","Volume Up":"提高音量","Warning:":"警告:","Web":"網頁","Width":"寬度","WiFi":"WiFi","WiMAX":"WiMAX","You (or someone else) kicked the device.":"你 (或別人) 把裝置踢掉了."}}
\ No newline at end of file
diff --git a/crowdstf/res/common/lang/translations/stf.zh_CN.json b/crowdstf/res/common/lang/translations/stf.zh_CN.json
new file mode 100644
index 0000000..b0252b9
--- /dev/null
+++ b/crowdstf/res/common/lang/translations/stf.zh_CN.json
@@ -0,0 +1 @@
+{"zh_CN":{"-":"VNC(虚拟网络计算机远程工具)远程登录","A new version of STF is available":"STF有新版本可下载","A package is already installed with the same name.":"已经安装了一个相同名字的安装包","A previously installed package of the same name has a different signature than the new package (and the old package's data was not removed).":"新安装包同之前的某个同名安装包发生了签名冲突(老的安装包的数据没有移除)","A secure container mount point couldn't be accessed on external media.":"外部存储无法访问安全容器挂载点","ABI":"ABI","AC":"AC","Access Tokens":"访问令牌","Account":"帐户","Action":"动作","Actions":"更多动作","Activity":"活动","ADB Keys":"安卓调试桥密钥","Add":"添加","Add ADB Key":"添加ADB Key","Add Key":"添加Key","Add the following ADB Key to STF?":"添加以下的ADB Key到STF?","Admin mode has been disabled.":"管理员模式已关闭","Admin mode has been enabled.":"管理员模式已启用","Advanced":"高级","Advanced Input":"高级输入","Airplane Mode":"飞行模式","App Store":"应用商店","App Upload":"上传APP","Apps":"应用程序","Are you sure you want to reboot this device?":"你确定要重启这台设备么?","Automation":"自动化","Available":"可用","Back":"返回","Battery":"电池","Battery Health":"电池健康","Battery Level":"电池电量","Battery Source":"电池电源","Battery Status":"电池状态","Battery Temp":"电池温度","Bluetooth":"蓝牙","Browser":"浏览器","Busy":"繁忙","Busy Devices":"繁忙的设备","Camera":"相机","Cancel":"取消","Cannot access specified URL":"无法访问指定的URL","Carrier":"信号","Category":"分类","Charging":"充电中","Check errors below":"检查下列错误","Clear":"清除","Clipboard":"剪贴板","Cold":"冷却","Connected":"已连接","Connected successfully.":"连接成功","Control":"控制","Cookies":"Cookies","Cores":"核","CPU":"CPU","Current rotation:":"当前屏幕旋转","Customize":"自定义","D-pad Center":"模拟键--中间","D-pad Down":"模拟键--下","D-pad Left":"模拟键--左","D-pad Right":"模拟键--右","D-pad Up":"模拟键--上","Dashboard":"控制面板","Data":"数据","Date":"日期","Dead":"无效","Delete":"删除","Density":"密度","Details":"细节","Developer":"开发者","Device":"设备","Device cannot get kicked from the group":"设备无法从该组移出","Device is not present anymore for some reason.":"设备由于某些原因找不到","Device is present but offline.":"设备已找到但处于离线状态","Device Photo":"设备照片","Device Settings":"设备设置","Device was disconnected":"设备已断开连接","Device was kicked by automatic timeout.":"设备由于超时已被移出","Devices":"设备","Disable WiFi":"关闭WIFI","Discharging":"未充电","Disconnected":"断开连接","Display":"播放","Drop file to upload":"拖放文件到这里以上传","Dummy":"虚拟的","Enable notifications":"允许提醒","Enable WiFi":"启用WIFI","Encrypted":"加密的","Error":"错误","Error while getting data":"获取数据时发生错误","Error while reconnecting":"重新连接时发生错误","Ethernet":"以太网","Executes remote shell commands":"执行远程shell命令","Failed to download file":"文件下载失败","Fast Forward":"快进","File Explorer":"文件管理器","Filter":"过滤器","Find Device":"查找设备","Fingerprint":"指纹","FPS":"FPS","Frequency":"频率","Full":"全部","General":"通用","Generate Access Token":"生成访问令牌","Generate Login for VNC":"生成登录VNC","Generate New Token":"生成新令牌","Get":"获取","Get clipboard contents":"获取剪贴板内容","Go Back":"返回","Go Forward":"前进","Go to Device List":"跳转到设备列表","Good":"良好","Hardware":"硬件","Health":"健康","Height":"高度","Help":"帮助","Hide Screen":"隐藏屏幕","Home":"主屏界面","Host":"主机地址","Hostname":"主机名","ICCID":"集成电路卡识别码","ID":"ID","IMEI":"IMEI","Incorrect login details":"登录信息错误","Info":"信息","Inspect Device":"被检查设备","Inspecting is currently only supported in WebView":"检查目前只支持网页视图","Inspector":"检查器","Installation canceled by user.":"用户已取消安装。","Installation failed due to an unknown error.":"未知原因导致安装失败","Installation succeeded.":"安装成功","Installation timed out.":"安装超时","Installing app...":"安装 app...","Key":"密钥","Keys":"按键","Landscape":"横排","Language":"语言","Launch Activity":"启动活动","Launching activity...":"活动启动中...","Level":"等级","Local Settings":"本地设置","Location":"位置","Lock Rotation":"锁定屏幕旋转","Logs":"日志","Maintenance":"维护","Make sure to copy your access token now. You won't be able to see it again.":"请确保已备份您的身份验证凭证,此凭证后续将不再显示!","Manage Apps":"管理Apps","Manner Mode":"管理模式","Manufacturer":"制造商","Media":"媒体","Memory":"内存","Menu":"菜单","Mobile":"手机","Mobile DUN":"手机网络桥接","Mobile High Priority":"移动网络最高优先级","Mobile MMS":"手机彩信","Mobile SUPL":"平面定位特定移动数据连接","Model":"型号","More about Access Tokens":"关于身份验证凭证","More about ADB Keys":"更多关于安卓调试桥密钥","Mute":"静音","Name":"名称","Native":"本地","Navigation":"导航","Network":"网络","Next":"下一步","No":"否","No access tokens":"没有身份验证凭证","No ADB keys":"没有安卓调试桥的密钥","No clipboard data":"剪贴板没有数据","No cookies to show":"没有本地cookies缓存","No device screen":"没有设备画面","No devices connected":"无设备连接","No photo available":"沒有可用的照片","No Ports Forwarded":"没有端口转发","No screenshots taken":"未拍截图","Normal Mode":"正常模式\"","Not Charging":"未充电","Notes":"标注","Nothing to inspect":"无需检查","Notifications":"通知","Number":"数字","Offline":"离线","Oops!":"出错了!","Open":"打开","Orientation":"屏幕方向","OS":"操作系统","Over Voltage":"电压过高","Overheat":"过热","Package":"程序安装包","Password":"密码","Permissions":"权限","Phone":"手机","Phone ICCID":"手机集成电路卡识别码(手机SIM卡唯一识别码)","Phone IMEI":"手机国际移动设备标识","Physical Device":"物理设备","PID":"进程号","Place":"位置","Platform":"平台","Play/Pause":"播放/暂停","Please enter a valid email":"请输入正确格式的 email","Please enter your email":"请输入您的 email","Please enter your LDAP username":"请输入您的LDAP用户名","Please enter your name":"请输入您的姓名","Please enter your password":"请输入您的密码","Please enter your Store password":"请输入您所保存的密码","Please enter your Store username":"输入您所保存的用户名","Port":"端口","Port Forwarding":"转发端口","Portrait":"竖排","Power":"电源","Power Source":"电源来源","Preparing":"准备中","Press Back button":"按后退键","Press Home button":"按Home键","Press Menu button":"按菜单键","Previous":"先前的","Processing...":"处理中...","Product":"产品","Pushing app...":"正在推送 app...","RAM":"随机存取存储器","Ready":"就绪","Reconnected successfully.":"重新连接成功","Refresh":"刷新","Released":"释放","Reload":"重新加载","Remote debug":"远程调试","Remove":"移除","Reset":"重置","Reset all browser settings":"重置所有浏览器设置","Reset Settings":"重置设置","Restart Device":"重启设备","Retrieving the device screen has timed out.":"获取设备画面超时","Retry":"重试","Rewind":"回滚","Roaming":"漫游状态","ROM":"ROM","Rotate Left":"向左翻转","Rotate Right":"向右翻转","Run":"运行","Run JavaScript":"运行 JavaScript","Run the following on your command line to debug the device from your Browser":"运行下面的命令行从您的浏览器中调试设备","Run the following on your command line to debug the device from your IDE":"运行下面命令行从您的IDE调试设备","Run this command to copy the key to your clipboard":"运行此命令将密钥复制到剪贴板","Save ScreenShot":"保存屏幕截图","Save...":"正在保存","Screen":"屏幕","Screenshot":"屏幕截图","Screenshots":"更多屏幕截图","SD Card Mounted":"SD卡已加载","SDK":"SDK","Search":"搜索","Selects Next IME":"选择下一个输入法","Serial":"串行","Server":"服务器","Server error. Check log output.":"服务器错误,请检查输出的日志纪录。","Set":"设置","Set Cookie":"设置cookies缓存","Settings":"设置","Shell":"Shell脚本","Show Screen":"显示屏幕","Sign In":"登录","Sign Out":"注销","Silent Mode":"静音模式","SIM":"SIM","Size":"大小","Socket connection was lost":"双向的通信连接丢失","Someone stole your device.":"有人占用了你的设备","Special Keys":"特殊要点","Start/Stop Logging":"开始 / 停止 日志纪录","Status":"状态","Stop":"停止","Stop Using":"停止使用","Store Account":"保存账号","Sub Type":"子类型","Switch Charset":"切换字符集","Tag":"标签","Take Pageshot (Needs WebView running)":"使用截图(需要WebView 运行)","Take Screenshot":"屏幕截图","Temperature":"温度","Text":"文字","The current view is marked secure and cannot be viewed remotely.":"当前视图有安全标记,并且不能远程查看","The device will be unavailable for a moment.":"此设备将暂时无法使用","The existing package could not be deleted.":"现有的程序包可能不会被删除","The new package couldn't be installed because the verification did not succeed.":"新包不能安装,因为验证没有成功。","The new package couldn't be installed because the verification timed out.":"新包不能安装,因为验证超时。","The new package couldn't be installed in the specified install location because the media is not available.":"新包不能安装在指定的安装位置,因为媒体是不可用。","The new package couldn't be installed in the specified install location.":"新包不能安装在指定的安装位置。","The new package failed because it contains a content provider with thesame authority as a provider already installed in the system.":"新的包安装失败,因为它包含了相同名字的认证已经安装在系统中提供一个内容提供商。","The new package failed because it has specified that it is a test-only package and the caller has not supplied the INSTALL_ALLOW_TEST flag.":"新包安装失败,因为它已被指定,它是一个用于测试的包和调用者并没有给它提供INSTALL_ALLOW_TEST标志。","The new package failed because the current SDK version is newer than that required by the package.":"新的包安装失败,因为当前的SDK版本比程序包所依赖的版本高。","The new package failed because the current SDK version is older than that required by the package.":"新的包安装失败,因为当前的SDK比安装包所依赖的版本低。","The new package failed while optimizing and validating its dex files, either because there was not enough storage or the validation failed.":"新的包安装失败,可能没有验证dex文件或者没有足够的存储导致验证失败。","The new package has an older version code than the currently installed package.":"新的软件包比目前安装的软件包代码版本旧。","The new package is assigned a different UID than it previously held.":"新包分配置的UID与它以前持有的不同。","The new package uses a feature that is not available.":"新包使用功能不可用。","The new package uses a shared library that is not available.":"新包使用的共享库不可用","The package archive file is invalid.":"包存档文件无效。","The package being installed contains native code, but none that is compatible with the device's CPU_ABI.":"正在安装软件包中包含源生内核,但都没有与该设备的CPU_ABI兼容。","The package changed from what the calling program expected.":"程序包被预期调用的程序所修改。","The package is already installed.":"程序包已经安装。","The package manager service found that the device didn't have enough storage space to install the app.":"包管理器服务发现该设备没有足够的存储空间来安装应用程序。","The parser did not find any actionable tags (instrumentation or application) in the manifest.":"解析器没有发现清单任何可操作的标签(工具或应用程序) 。","The parser did not find any certificates in the .apk.":"解析器没有发现在.apk文件的任何证书。","The parser encountered a bad or missing package name in the manifest.":"分析器在清单中遇到损坏或丢失的包名。","The parser encountered a bad shared user id name in the manifest.":"分析器在清单中遇到坏共享的用户ID名称。","The parser encountered a CertificateEncodingException in one of the files in the .apk.":"解析器在的apk的一个文件时遇到证书编码异常。","The parser encountered an unexpected exception.":"解析器遇到意外的异常。","The parser encountered some structural problem in the manifest.":"分析器在清单中遇到的一些结构性问题。","The parser found inconsistent certificates on the files in the .apk.":"分析器发现在的apk文件不一致的证书。","The parser was given a path that is not a file, or does not end with the expected '.apk' extension.":"解析器所获得的是一个路径而不是一个文件,或者文件没有以‘.apk’结尾。","The parser was unable to retrieve the AndroidManifest.xml file.":"分析器无法检索AndroidManifest.xml文件。","The requested shared user does not exist.":"所请求的共享用户不存在","The system failed to install the package because its packaged native code did not match any of the ABIs supported by the system.":"该系统无法安装此软件包,因为其包装原生内核没有匹配到应系统所支持的的ABI。","The system failed to install the package because of system issues.":"因为系统问题无法安装程序。","The system failed to install the package because the user is restricted from installing apps.":"程序安装失败,因为用户被限制安装该应用软件","The URI passed in is invalid.":"该URI传递无效","TID":"线程号","Time":"时间","Tip:":"提示:","Title":"标题","Toggle Web/Native":"切换网络/本地","Total Devices":"设备总数","translate":"翻译","Try to reconnect":"尝试重新连接","Type":"类型","Unauthorized":"未授权的","Uninstall":"卸载","Unknown":"未知","Unknown reason.":"未知的原因。","Unlock Rotation":"旋转解锁","Unspecified Failure":"未指定的故障","Upload failed":"上传失败","Upload From Link":"从超链接上传","Upload unknown error":"未知的上传错误","Uploaded file is not valid":"上传的文件是无效的","Uploading...":"上传中...","Usable Devices":"可用的设备","USB":"USB","Usb speed":"USB速度","Use":"使用","User":"用户","Username":"用户名","Using":"使用中","Using Fallback":"使用回退","Version":"版本","Version Update":"版本更新","Vibrate Mode":"振动模式","VNC":"VNC","Voltage":"电压","Volume":"音量","Volume Down":"减小音量","Volume Up":"增加音量","Warning:":"警告:","Web":"Web","Width":"宽度","WiFi":"WiFi","WiMAX":"WiMAX","Wireless":"无线","X DPI":"X DPI","Y DPI":"Y DPI","Yes":"是","You (or someone else) kicked the device.":"你 (或別人) 已移出了该设备。"}}
\ No newline at end of file
diff --git a/crowdstf/res/common/logo/STF.ai b/crowdstf/res/common/logo/STF.ai
new file mode 100644
index 0000000..53799bb
--- /dev/null
+++ b/crowdstf/res/common/logo/STF.ai
Binary files differ
diff --git a/crowdstf/res/common/logo/exports/STF-128.png b/crowdstf/res/common/logo/exports/STF-128.png
new file mode 100644
index 0000000..9febec1
--- /dev/null
+++ b/crowdstf/res/common/logo/exports/STF-128.png
Binary files differ
diff --git a/crowdstf/res/common/logo/exports/STF-512.png b/crowdstf/res/common/logo/exports/STF-512.png
new file mode 100644
index 0000000..99e8787
--- /dev/null
+++ b/crowdstf/res/common/logo/exports/STF-512.png
Binary files differ
diff --git a/crowdstf/res/common/logo/exports/STF-outlined.ai b/crowdstf/res/common/logo/exports/STF-outlined.ai
new file mode 100644
index 0000000..165ac90
--- /dev/null
+++ b/crowdstf/res/common/logo/exports/STF-outlined.ai
Binary files differ
diff --git a/crowdstf/res/common/status/scripts/entry.js b/crowdstf/res/common/status/scripts/entry.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crowdstf/res/common/status/scripts/entry.js
diff --git a/crowdstf/res/common/status/views/404.jade b/crowdstf/res/common/status/views/404.jade
new file mode 100644
index 0000000..de3aa98
--- /dev/null
+++ b/crowdstf/res/common/status/views/404.jade
@@ -0,0 +1,13 @@
+doctype html
+html
+  head
+    title STF Not Found
+    meta(charset='utf-8')
+    meta(name='viewport', content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui')
+    include partials/styles
+  body(ng-cloak).forofour.bg-danger
+    div(ng-view)
+    .forofour-container
+    h1
+      i.fa.fa-unlink.fa-4x
+    h2 Page not available
diff --git a/crowdstf/res/common/status/views/maintenance.jade b/crowdstf/res/common/status/views/maintenance.jade
new file mode 100644
index 0000000..95c339d
--- /dev/null
+++ b/crowdstf/res/common/status/views/maintenance.jade
@@ -0,0 +1,14 @@
+doctype html
+html
+  head
+    title STF Maintenance
+    meta(charset='utf-8')
+    meta(name='viewport', content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui')
+    include partials/styles
+  body(ng-cloak).forofour.bg-danger
+    div(ng-view)
+    .forofour-container
+    h1
+      i.fa.fa-unlink.fa-4x
+    h2 STF is in maintenance mode.
+    h3 Please wait, the system will be back up shortly.
diff --git a/crowdstf/res/common/status/views/partials/styles.jade b/crowdstf/res/common/status/views/partials/styles.jade
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crowdstf/res/common/status/views/partials/styles.jade
diff --git a/crowdstf/res/common/status/webpack.config.js b/crowdstf/res/common/status/webpack.config.js
new file mode 100644
index 0000000..df7f8c1
--- /dev/null
+++ b/crowdstf/res/common/status/webpack.config.js
@@ -0,0 +1,12 @@
+var pathutil = require('./../../../lib/util/pathutil')
+var options = require('./../../../webpack.config').webpack
+var _ = require('lodash')
+
+module.exports = _.defaults(options, {
+  entry: pathutil.resource('common/status/scripts/entry.js'),
+  output: {
+    path: pathutil.resource('build'),
+    publicPath: '/static/build/',
+    filename: 'bundle-status.js'
+  }
+})
diff --git a/crowdstf/res/test/.eslintrc b/crowdstf/res/test/.eslintrc
new file mode 100644
index 0000000..d98018f
--- /dev/null
+++ b/crowdstf/res/test/.eslintrc
@@ -0,0 +1,9 @@
+// TODO: Some day use eslint-plugin-angular
+// https://github.com/Gillespie59/eslint-plugin-angular
+{
+  "env": {
+    "browser": true,
+    "node": true,
+    "protractor": true
+  }
+}
diff --git a/crowdstf/res/test/e2e/control/control-spec.js b/crowdstf/res/test/e2e/control/control-spec.js
new file mode 100644
index 0000000..460cbed
--- /dev/null
+++ b/crowdstf/res/test/e2e/control/control-spec.js
@@ -0,0 +1,146 @@
+describe('Control Page', function() {
+  var DeviceListPage = require('../devices')
+  var deviceListPage = new DeviceListPage()
+
+  var ControlPage = function() {
+    this.get = function() {
+      browser.get(protractor.getInstance().baseUrl + 'control')
+    }
+    this.kickDeviceButton = element.all(by.css('.kick-device')).first()
+    this.kickDevice = function() {
+      this.openDevicesDropDown()
+      this.kickDeviceButton.click()
+    }
+    this.devicesDropDown = element(by.css('.device-name-text'))
+    this.openDevicesDropDown = function() {
+      this.devicesDropDown.click()
+    }
+  }
+
+  var controlPage = new ControlPage()
+
+  it('should control an usable device', function() {
+    deviceListPage.controlAvailableDevice()
+
+    waitUrl(/control/)
+
+    browser.sleep(500)
+
+    browser.getLocationAbsUrl().then(function(newUrl) {
+      expect(newUrl).toMatch(protractor.getInstance().baseUrl + 'control')
+    })
+  })
+
+  it('should have a kick button', function() {
+    expect(controlPage.kickDeviceButton.isPresent()).toBeTruthy()
+  })
+
+  describe('Remote Control', function() {
+    //var RemoteCtrl = function () {
+    //  this.paneHandleHorizontal = element(by.css('.fa-pane-handle.horizontal'))
+    //}
+    it('should resize panel to the right', function() {
+
+    })
+    it('should rotate device', function() {
+
+    })
+  })
+
+
+  describe('Dashboard Tab', function() {
+
+    describe('Shell', function() {
+      var ShellCtrl = function() {
+        this.commandInput = element(by.model('command'))
+        this.results = element.all(by.css('.shell-results')).first()
+
+        this.helloString = 'hello adb'
+        this.echoCommand = 'echo "' + this.helloString + '"'
+        this.clearCommand = 'clear'
+        this.openMenuCommand = 'input keyevent 3'
+
+        this.execute = function(command) {
+          this.commandInput.sendKeys(command, protractor.Key.ENTER)
+        }
+      }
+      var shell = new ShellCtrl()
+
+      it('should echo "hello adb" to the adb shell', function() {
+        expect(shell.commandInput.isPresent()).toBe(true)
+
+        shell.execute(shell.echoCommand)
+
+        expect(shell.results.getText()).toBe(shell.helloString)
+      })
+
+      it('should clear adb shell input', function() {
+        shell.execute(shell.clearCommand)
+        expect(shell.results.getText()).toBeFalsy()
+      })
+
+      it('should open and close the menu button trough adb shell', function() {
+        shell.execute(shell.openMenuCommand)
+        shell.execute(shell.openMenuCommand)
+      })
+
+    })
+
+    describe('Navigation', function() {
+      var NavigationCtrl = function() {
+        this.urlInput = element(by.model('textURL'))
+        this.goToUrl = function(url) {
+          this.urlInput.sendKeys(url, protractor.Key.ENTER)
+        }
+        this.resetButton = element(by.css('[ng-click="clearSettings()"]'))
+      }
+      var navigation = new NavigationCtrl()
+
+      it('should go to an URL', function() {
+        var url = 'google.com'
+        navigation.goToUrl(url)
+        expect(navigation.urlInput.getAttribute('value')).toBe(url)
+
+        browser.sleep(500)
+      })
+
+      it('should clear the URL input', function() {
+        navigation.urlInput.clear()
+        expect(navigation.urlInput.getAttribute('value')).toBeFalsy()
+      })
+
+      it('should reset browser settings', function() {
+        navigation.resetButton.click()
+      })
+    })
+
+  })
+
+  describe('Screenshots Tab', function() {
+
+  })
+
+  describe('Automation Tab', function() {
+
+  })
+
+  describe('Advanced Tab', function() {
+
+  })
+
+  describe('Logs Tab', function() {
+
+  })
+
+  it('should stop controlling an usable device', function() {
+    controlPage.kickDevice()
+
+    waitUrl(/devices/)
+
+    browser.getLocationAbsUrl().then(function(newUrl) {
+      expect(newUrl).toBe(protractor.getInstance().baseUrl + 'devices')
+    })
+  })
+
+
+})
diff --git a/crowdstf/res/test/e2e/devices/devices-spec.js b/crowdstf/res/test/e2e/devices/devices-spec.js
new file mode 100644
index 0000000..30ce796
--- /dev/null
+++ b/crowdstf/res/test/e2e/devices/devices-spec.js
@@ -0,0 +1,36 @@
+describe('Device Page', function() {
+  describe('Icon View', function() {
+
+    var DeviceListPage = require('./')
+    var deviceListPage = new DeviceListPage()
+
+    it('should go to Devices List page', function() {
+      deviceListPage.get()
+      browser.getLocationAbsUrl().then(function(newUrl) {
+        expect(newUrl).toBe(protractor.getInstance().baseUrl + 'devices')
+      })
+    })
+
+    it('should have more than 1 device in the list', function() {
+      expect(deviceListPage.numberOfDevices()).toBeGreaterThan(0)
+    })
+
+    it('should filter available devices', function() {
+      deviceListPage.filterAvailableDevices()
+      expect(deviceListPage.searchInput.getAttribute('value')).toBe('state: "available"')
+    })
+
+    it('should have more than 1 device available', function() {
+      expect(deviceListPage.devicesUsable.count()).toBeGreaterThan(0)
+    })
+
+    it('should have one device usable', function() {
+      expect(deviceListPage.availableDevice().getAttribute('class')).toMatch('state-available')
+    })
+
+  })
+
+  describe('List View', function() {
+
+  })
+})
diff --git a/crowdstf/res/test/e2e/devices/index.js b/crowdstf/res/test/e2e/devices/index.js
new file mode 100644
index 0000000..92529a9
--- /dev/null
+++ b/crowdstf/res/test/e2e/devices/index.js
@@ -0,0 +1,22 @@
+module.exports = function DeviceListPage() {
+  this.get = function() {
+    // TODO: Let's get rid off the login first
+    browser.get(protractor.getInstance().baseUrl + 'devices')
+  }
+  this.devices = element(by.model('tracker.devices'))
+  this.devicesByCss = element.all(by.css('ul.devices-icon-view > li'))
+  this.devicesUsable = element.all(by.css('.state-available'))
+  this.searchInput = element(by.model('search.deviceFilter'))
+  this.filterAvailableDevices = function() {
+    return this.searchInput.sendKeys('state: "available"')
+  }
+  this.numberOfDevices = function() {
+    return this.devicesByCss.count()
+  }
+  this.availableDevice = function() {
+    return this.devicesUsable.first()
+  }
+  this.controlAvailableDevice = function() {
+    return this.availableDevice().click()
+  }
+}
diff --git a/crowdstf/res/test/e2e/help/help-spec.js b/crowdstf/res/test/e2e/help/help-spec.js
new file mode 100644
index 0000000..8366a99
--- /dev/null
+++ b/crowdstf/res/test/e2e/help/help-spec.js
@@ -0,0 +1,7 @@
+describe('Help Page', function() {
+  //var HelpPage = function () {
+  //  this.get = function () {
+  //    browser.get(protractor.getInstance().baseUrl + 'help')
+  //  }
+  //}
+})
diff --git a/crowdstf/res/test/e2e/helpers/browser-logs.js b/crowdstf/res/test/e2e/helpers/browser-logs.js
new file mode 100644
index 0000000..989e8b6
--- /dev/null
+++ b/crowdstf/res/test/e2e/helpers/browser-logs.js
@@ -0,0 +1,37 @@
+var chalk = require('chalk')
+/* eslint no-console:0 */
+
+// http://stackoverflow.com/questions/7157999/output-jasmine-test-results-to-the-console
+// https://github.com/pivotal/jasmine/blob/master/src/console/ConsoleReporter.js
+
+module.exports = function BrowserLogs(opts) {
+  var options = opts || {}
+
+  if (typeof options.expectNoLogs === 'undefined') {
+    options.expectNoLogs = false
+  }
+  if (typeof options.outputLogs === 'undefined') {
+    options.outputLogs = true
+  }
+
+  browser.getCapabilities().then(function(cap) {
+    var browserName = ' ' + cap.caps_.browserName + ' log '
+    var browserStyled = chalk.bgBlue.white.bold(browserName) + ' '
+
+    browser.manage().logs().get('browser').then(function(browserLogs) {
+      if (options.expectNoLogs) {
+        expect(browserLogs.length).toEqual(0)
+      }
+
+      if (options.outputLogs && browserLogs.length) {
+        browserLogs.forEach(function(log) {
+          if (log.level.value > 900) {
+            console.error(browserStyled + chalk.white.bold(log.message))
+          } else {
+            console.log(browserStyled + chalk.white.bold(log.message))
+          }
+        })
+      }
+    })
+  })
+}
diff --git a/crowdstf/res/test/e2e/helpers/fail-fast.js b/crowdstf/res/test/e2e/helpers/fail-fast.js
new file mode 100644
index 0000000..acfb6fd
--- /dev/null
+++ b/crowdstf/res/test/e2e/helpers/fail-fast.js
@@ -0,0 +1,10 @@
+// https://github.com/angular/protractor/issues/499
+
+module.exports = function FailFast() {
+  var passed = jasmine.getEnv().currentSpec.results().passed()
+  if (!passed) {
+    jasmine.getEnv().specFilter = function() {
+      return false
+    }
+  }
+}
diff --git a/crowdstf/res/test/e2e/helpers/gulp-protractor-adv.js b/crowdstf/res/test/e2e/helpers/gulp-protractor-adv.js
new file mode 100644
index 0000000..7f73cec
--- /dev/null
+++ b/crowdstf/res/test/e2e/helpers/gulp-protractor-adv.js
@@ -0,0 +1,248 @@
+/* This is a fork of https://github.com/mllrsohn/gulp-protractor
+
+ Changes:
+ - Added debug support
+ - Added suites support
+ - Added element explorer support
+ - Added feature to detect if selenium is running or not
+ */
+
+var es = require('event-stream')
+var path = require('path')
+var childProcess = require('child_process')
+var PluginError = require('gulp-util').PluginError
+var winExt = /^win/.test(process.platform) ? '.cmd' : ''
+var http = require('http')
+var Promise = require('bluebird')
+
+// optimization: cache for protractor binaries directory
+var protractorDir = null
+
+function getProtractorDir() {
+  if (protractorDir) {
+    return protractorDir
+  }
+  var result = require.resolve('protractor')
+  if (result) {
+    // result is now something like
+    // c:\\Source\\gulp-protractor\\node_modules\\protractor\\lib\\protractor.js
+    protractorDir =
+      path.resolve(path.join(path.dirname(result), '..', '..', '.bin'))
+    return protractorDir
+  }
+  throw new Error('No protractor installation found.')
+}
+
+var protractor = function(opts) {
+  var files = []
+  var options = opts || {}
+  var args = options.args || []
+  var child
+
+  if (!options.configFile) {
+    this.emit('error', new PluginError('gulp-protractor',
+      'Please specify the protractor config file'))
+  }
+  return es.through(function(file) {
+    files.push(file.path)
+  }, function() {
+    var that = this
+
+    // Enable debug mode
+    if (options.debug) {
+      args.push('debug')
+    }
+
+    // Enable test suits
+    if (options.suite) {
+      args.push('--suite')
+      args.push(options.suite)
+    }
+
+    // Attach Files, if any
+    if (files.length) {
+      args.push('--specs')
+      args.push(files.join(','))
+    }
+
+    // Pass in the config file
+    args.unshift(options.configFile)
+
+    child =
+      childProcess.spawn(path.resolve(getProtractorDir() + '/protractor' +
+      winExt), args, {
+        stdio: 'inherit',
+        env: process.env
+      }).on('exit', function(code) {
+        if (child) {
+          child.kill()
+        }
+        if (that) {
+          if (code) {
+            that.emit('error', new PluginError('gulp-protractor',
+              'protractor exited with code ' + code))
+          }
+          else {
+            that.emit('end')
+          }
+        }
+      })
+  })
+}
+
+var webdriverUpdate = function(opts, cb) {
+  var callback = cb || opts
+  var options = (cb ? opts : null)
+  var args = ['update', '--standalone']
+  if (options) {
+    if (options.browsers) {
+      options.browsers.forEach(function(element) {
+        args.push('--' + element)
+      })
+    }
+  }
+  childProcess.spawn(path.resolve(getProtractorDir() + '/webdriver-manager' +
+  winExt), args, {
+    stdio: 'inherit'
+  }).once('close', callback)
+}
+
+var webdriverUpdateSpecific = function(opts) {
+  return webdriverUpdate.bind(this, opts)
+}
+
+webdriverUpdate.bind(null, ['ie', 'chrome'])
+
+var webdriverStandalone = function(opts, cb) {
+  var callback = cb || opts
+  var options = (cb ? opts : null)
+  var stdio = 'inherit'
+
+  if (options) {
+    if (options.stdio) {
+      stdio = options.stdio
+    }
+  }
+
+  var child = childProcess.spawn(path.resolve(getProtractorDir() +
+  '/webdriver-manager' + winExt), ['start'], {
+    stdio: stdio
+  })
+    .once('close', callback)
+    .on('exit', function(code) {
+      if (child) {
+        child.kill()
+      }
+    })
+}
+
+var protractorExplorerDir = null
+function getProtractorExplorerDir() {
+  if (protractorExplorerDir) {
+    return protractorExplorerDir
+  }
+  var result = require.resolve('protractor')
+  if (result) {
+    // result is now something like
+    // c:\\Source\\gulp-protractor\\node_modules\\protractor\\lib\\protractor.js
+    protractorExplorerDir =
+      path.resolve(path.join(path.dirname(result), '..', 'bin'))
+    return protractorExplorerDir
+  }
+  throw new Error('No protractor installation found.')
+}
+
+var isWebDriverRunning = function() {
+  return new Promise(function(resolve) {
+    var options = {
+      hostname: 'localhost',
+      port: 4444,
+      path: '/wd/hub/status'
+    }
+
+    var req = http.request(options, function(res) {
+      if (res.statusCode !== 200) {
+        throw new Error('Selenium is running but status code is' +
+        res.statusCode)
+      }
+      resolve(true)
+    })
+    req.on('error', function() {
+      resolve(false)
+    })
+    req.write('data\n')
+    req.end()
+    resolve(false)
+  })
+}
+
+//var ensureWebDriverRunning = function () {
+//  return new Promise(function (resolve) {
+//    isWebDriverRunning().then(function (running) {
+//      if (running) {
+//        resolve()
+//      }
+//    })
+//  })
+//}
+
+
+var protractorExplorer = function(opts, cb) {
+  var callback = cb || opts
+  var options = (cb ? opts : null)
+  var url = 'https://angularjs.org/'
+
+  if (options) {
+    if (options.configFile) {
+      var configFile = require(options.configFile)
+      if (configFile.config && configFile.config.baseUrl) {
+        url = configFile.config.baseUrl
+      }
+    }
+
+    if (options.url) {
+      url = options.url
+    }
+  }
+
+  function runElementExplorer(callback) {
+    var child = childProcess.spawn(path.resolve(getProtractorExplorerDir() +
+    '/elementexplorer.js'), [url], {
+      stdio: 'inherit'
+    })
+      .on('exit', function() {
+        if (child) {
+          child.kill()
+        }
+      })
+      .once('close', callback)
+  }
+
+  function runWebDriver() {
+    isWebDriverRunning().then(function(running) {
+      if (running) {
+        runElementExplorer(callback)
+      } else {
+        webdriverStandalone({stdio: ['pipe', 'pipe', process.stderr]},
+          function() {
+
+          })
+
+        setTimeout(function() {
+          runElementExplorer(callback)
+        }, 2000)
+      }
+    })
+  }
+  runWebDriver()
+}
+
+module.exports = {
+  getProtractorDir: getProtractorDir,
+  protractor: protractor,
+  webdriverStandalone: webdriverStandalone,
+  webdriverUpdate: webdriverUpdate,
+  webdriverUpdateSpecific: webdriverUpdateSpecific,
+  protractorExplorer: protractorExplorer,
+  isWebDriverRunning: isWebDriverRunning
+}
diff --git a/crowdstf/res/test/e2e/helpers/wait-url.js b/crowdstf/res/test/e2e/helpers/wait-url.js
new file mode 100644
index 0000000..dc8ad64
--- /dev/null
+++ b/crowdstf/res/test/e2e/helpers/wait-url.js
@@ -0,0 +1,22 @@
+/**
+ * @name waitUrl
+ *
+ * @description Wait until the URL changes to match a provided regex
+ * @param {RegExp} urlRegex wait until the URL changes to match this regex
+ * @returns {!webdriver.promise.Promise} Promise
+ */
+module.exports = function waitUrl(urlRegex) {
+  var currentUrl
+
+  return browser.getCurrentUrl().then(function storeCurrentUrl(url) {
+      currentUrl = url
+    }
+  ).then(function waitForUrlToChangeTo() {
+      return browser.wait(function waitForUrlToChangeTo() {
+        return browser.getCurrentUrl().then(function compareCurrentUrl(url) {
+          return urlRegex.test(url)
+        })
+      })
+    }
+  )
+}
diff --git a/crowdstf/res/test/e2e/login/index.js b/crowdstf/res/test/e2e/login/index.js
new file mode 100644
index 0000000..cb6389c
--- /dev/null
+++ b/crowdstf/res/test/e2e/login/index.js
@@ -0,0 +1,51 @@
+module.exports = function LoginPage() {
+  this.login = protractor.getInstance().params.login
+
+  this.get = function() {
+    return browser.get(this.login.url)
+  }
+
+  this.username = element(by.model('username'))
+
+  if (this.login.method === 'ldap') {
+    this.password = element(by.model('password'))
+  } else {
+    this.email = element(by.model('email'))
+  }
+
+
+  this.setName = function(username) {
+    return this.username.sendKeys(username)
+  }
+  this.setEmail = function(email) {
+    return this.email.sendKeys(email)
+  }
+  this.setPassword = function(password) {
+    return this.password.sendKeys(password)
+  }
+  this.submit = function() {
+    return this.username.submit()
+  }
+  this.doLogin = function() {
+    this.get()
+    this.setName(this.login.username)
+    if (this.login.method === 'ldap') {
+      this.setPassword(this.login.password)
+    } else {
+      this.setEmail(this.login.email)
+    }
+
+    this.submit()
+
+    return browser.driver.wait(function() {
+      return browser.driver.getCurrentUrl().then(function(url) {
+        return /devices/.test(url)
+      })
+    })
+  }
+  this.cleanUp = function() {
+    this.username = null
+    this.password = null
+    this.email = null
+  }
+}
diff --git a/crowdstf/res/test/e2e/login/login-spec.js b/crowdstf/res/test/e2e/login/login-spec.js
new file mode 100644
index 0000000..1a89644
--- /dev/null
+++ b/crowdstf/res/test/e2e/login/login-spec.js
@@ -0,0 +1,16 @@
+describe('Login Page', function() {
+  var LoginPage = require('./')
+  var loginPage = new LoginPage()
+
+  it('should have an url to login', function() {
+    expect(loginPage.login.url).toMatch('http')
+  })
+
+  it('should login with method: "' + loginPage.login.method + '"', function() {
+    loginPage.doLogin().then(function() {
+      browser.getLocationAbsUrl().then(function(newUrl) {
+        expect(newUrl).toBe(protractor.getInstance().baseUrl + 'devices')
+      })
+    })
+  })
+})
diff --git a/crowdstf/res/test/e2e/settings/settings-spec.js b/crowdstf/res/test/e2e/settings/settings-spec.js
new file mode 100644
index 0000000..973fe2b
--- /dev/null
+++ b/crowdstf/res/test/e2e/settings/settings-spec.js
@@ -0,0 +1,7 @@
+describe('Settings Page', function() {
+  //var SettingsPage = function () {
+  //  this.get = function () {
+  //    browser.get(protractor.getInstance().baseUrl + 'settings')
+  //  }
+  //}
+})
diff --git a/crowdstf/res/test/helpers/helper.js b/crowdstf/res/test/helpers/helper.js
new file mode 100644
index 0000000..8f2f9ed
--- /dev/null
+++ b/crowdstf/res/test/helpers/helper.js
@@ -0,0 +1,2 @@
+require('angular')
+require('angular-mocks')
diff --git a/crowdstf/res/test/karma.conf.js b/crowdstf/res/test/karma.conf.js
new file mode 100644
index 0000000..43d0958
--- /dev/null
+++ b/crowdstf/res/test/karma.conf.js
@@ -0,0 +1,80 @@
+var webpackConfig = require('./../../webpack.config')
+
+var webpack = require('webpack')
+
+module.exports = function(config) {
+  config.set({
+    frameworks: ['jasmine'],
+    files: [
+      'helpers/**/*.js',
+      '../app/**/*-spec.js'
+//      '../app/components/stf/common-ui/clear-button/*-spec.js'
+    ],
+
+    preprocessors: {
+      'helpers/**/*.js': ['webpack'],
+      '../**/*.js': ['webpack']
+    },
+    exclude: [
+
+    ],
+
+//    webpack: webpackConfig.webpack,
+    webpack: {
+      cache: true,
+      module: webpackConfig.webpack.module,
+      resolve: webpackConfig.webpack.resolve,
+      plugins: [
+        new webpack.ResolverPlugin(
+          new webpack.ResolverPlugin.DirectoryDescriptionFilePlugin(
+            'bower.json'
+            , ['main']
+          )
+        )
+      ]
+    },
+    webpackServer: {
+      debug: true,
+      devtool: 'inline-source-map',
+//      devtool: 'eval',
+      stats: false
+//      stats: {
+//        colors: true
+//      }
+    },
+
+    // test results reporter to use
+    // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage'
+    reporters: ['progress', 'junit'],
+
+    junitReporter: {
+      outputFile: 'test_out/junit.xml',
+      suite: 'jqLite'
+    },
+
+    // level of logging
+    // possible values: config.LOG_DISABLE || config.LOG_ERROR ||
+    // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+    //logLevel: config.LOG_INFO,
+
+    // enable / disable watching file and executing tests whenever any file changes
+    autoWatch: true,
+
+    // Start these browsers, currently available:
+    // Chrome, ChromeCanary, Firefox, Opera, Safari, PhantomJS, IE
+    browsers: ['Chrome'],
+    //browsers: ['PhantomJS'],
+
+    plugins: [
+      require('karma-jasmine'),
+      require('karma-webpack'),
+      require('karma-chrome-launcher'),
+      require('karma-firefox-launcher'),
+      require('karma-phantomjs-launcher'),
+      require('karma-junit-reporter'),
+      require('karma-ie-launcher'),
+      require('karma-safari-launcher')
+      //require('karma-opera-launcher')
+    ]
+  })
+}
diff --git a/crowdstf/res/test/protractor-appium.conf.js b/crowdstf/res/test/protractor-appium.conf.js
new file mode 100644
index 0000000..ef3dbdf
--- /dev/null
+++ b/crowdstf/res/test/protractor-appium.conf.js
@@ -0,0 +1,17 @@
+var config = require('./protractor.conf').config
+
+config.seleniumAddress = 'http://localhost:4723/wd/hub'
+config.chromeOnly = false
+//config.capabilities = {
+//  platformName: 'iOS',
+//  deviceName: 'iPhone Simulator',
+//  browserName: 'Safari'
+//}
+
+config.capabilities = {
+  platformName: 'Android',
+  deviceName: '',
+  browserName: 'Chrome'
+}
+
+module.exports.config = config
diff --git a/crowdstf/res/test/protractor-multi.conf.js b/crowdstf/res/test/protractor-multi.conf.js
new file mode 100644
index 0000000..fbe7ea2
--- /dev/null
+++ b/crowdstf/res/test/protractor-multi.conf.js
@@ -0,0 +1,37 @@
+var config = require('./protractor.conf').config
+//var LoginPage = require('./e2e/login')
+//var HtmlReporter = require('protractor-html-screenshot-reporter')
+//var WaitUrl = require('./e2e/helpers/wait-url')
+
+config.chromeOnly = false
+config.capabilities = null
+config.multiCapabilities = [
+  {
+    browserName: 'chrome',
+    chromeOptions: {
+      args: ['--test-type'] // Prevent security warning bug in ChromeDriver
+    }
+  },
+  {
+    browserName: 'firefox'
+  }
+  //{
+  //  browserName: 'safari'
+  //}
+  // add appium/sauce labs
+]
+
+//config.onPrepare = function () {
+//  var loginPage = new LoginPage()
+//  loginPage.doLogin()
+//  loginPage.cleanUp()
+//
+//  this.waitUrl = WaitUrl
+//
+//  jasmine.getEnv().addReporter(new HtmlReporter({
+//    baseDirectory: './res/test/test_out/screenshots'
+//  }))
+//}
+
+
+module.exports.config = config
diff --git a/crowdstf/res/test/protractor.conf.js b/crowdstf/res/test/protractor.conf.js
new file mode 100644
index 0000000..9c2610c
--- /dev/null
+++ b/crowdstf/res/test/protractor.conf.js
@@ -0,0 +1,60 @@
+// Reference: https://github.com/angular/protractor/blob/master/referenceConf.js
+var LoginPage = require('./e2e/login')
+var BrowserLogs = require('./e2e/helpers/browser-logs')
+//var FailFast = require('./e2e/helpers/fail-fast')
+var HtmlReporter = require('protractor-html-screenshot-reporter')
+var WaitUrl = require('./e2e/helpers/wait-url')
+
+module.exports.config = {
+  baseUrl: process.env.STF_URL || 'http://localhost:7100/#!/',
+  suites: {
+    control: 'e2e/control/**/*-spec.js',
+    devices: 'e2e/devices/**/*-spec.js',
+    help: 'e2e/help/**/*-spec.js',
+    login: 'e2e/login/**/*-spec.js',
+    settings: 'e2e/settings/**/*-spec.js'
+  },
+  params: {
+    login: {
+      url: process.env.STF_LOGINURL || process.env.STF_URL ||
+      'http://localhost:7120',
+      username: process.env.STF_USERNAME || 'test_user',
+      email: process.env.STF_EMAIL || 'test_user@login.local',
+      password: process.env.STF_PASSWORD,
+      method: process.env.STF_METHOD || process.env.STF_PASSWORD ? 'ldap' :
+        'mock'
+    }
+  },
+  jasmineNodeOpts: {
+    showColors: true,
+    defaultTimeoutInterval: 30000,
+    isVerbose: true,
+    includeStackTrace: true
+  },
+  capabilities: {
+    browserName: 'chrome',
+    chromeOptions: {
+      args: ['--test-type'] // Prevent security warning bug in ChromeDriver
+    }
+  },
+  chromeOnly: true,
+  onPrepare: function() {
+    var loginPage = new LoginPage()
+    loginPage.doLogin()
+    loginPage.cleanUp()
+
+    this.waitUrl = WaitUrl
+
+    jasmine.getEnv().addReporter(new HtmlReporter({
+      baseDirectory: './res/test/test_out/screenshots'
+    }))
+
+    afterEach(function() {
+      BrowserLogs({expectNoLogs: true})
+      //FailFast()
+    })
+  },
+  onComplete: function() {
+
+  }
+}
diff --git a/crowdstf/res/web_modules/angular-borderlayout/index.js b/crowdstf/res/web_modules/angular-borderlayout/index.js
new file mode 100644
index 0000000..6330a28
--- /dev/null
+++ b/crowdstf/res/web_modules/angular-borderlayout/index.js
@@ -0,0 +1,7 @@
+require('angular-borderlayout/src/borderLayout.js')
+require('angular-borderlayout/src/borderLayout.css')
+require('./style.css')
+
+module.exports = {
+  name: 'fa.directive.borderLayout'
+}
diff --git a/crowdstf/res/web_modules/angular-borderlayout/style.css b/crowdstf/res/web_modules/angular-borderlayout/style.css
new file mode 100644
index 0000000..117d129
--- /dev/null
+++ b/crowdstf/res/web_modules/angular-borderlayout/style.css
@@ -0,0 +1,36 @@
+.fa-pane-handle {
+  background: rgba(153, 153, 153, 0.5);
+}
+
+.fa-pane:hover > .fa-pane-handle > .fa-pane-toggle {
+  background: rgba(29, 132, 223, 0.8);
+}
+
+.fa-pane-resizing > .fa-pane-handle,
+.fa-pane-handle:hover {
+  background-color: rgba(0, 0, 0, 0.3);
+}
+
+.fa-pane-scroller {
+  /*border: 1px solid #999;*/
+}
+
+.fa-pane-parent > .fa-pane-scroller {
+  border: none;
+}
+
+.fa-pane-constrained.fa-pane-resizing > .fa-pane-overlay {
+  background-color: rgba(0, 0, 0, 0.2);
+}
+
+/* TODO: CHECK THIS */
+.pane-top-bar {
+  box-shadow: 0 1px 10px rgba(0, 0, 0, 0.1);
+  z-index: 500;
+}
+
+.navbar {
+  height: 46px;
+  margin: 0;
+  border-radius: 0;
+}
diff --git a/crowdstf/res/web_modules/angular-growl/index.js b/crowdstf/res/web_modules/angular-growl/index.js
new file mode 100644
index 0000000..612fc1a
--- /dev/null
+++ b/crowdstf/res/web_modules/angular-growl/index.js
@@ -0,0 +1,7 @@
+require('angular-growl-v2/build/angular-growl.js')
+// Using custom CSS
+//require('angular-growl-v2/build/angular-growl.min.css')
+
+module.exports = {
+  name: 'angular-growl'
+}
diff --git a/crowdstf/res/web_modules/angular-hotkeys/hotkeys.css b/crowdstf/res/web_modules/angular-hotkeys/hotkeys.css
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crowdstf/res/web_modules/angular-hotkeys/hotkeys.css
diff --git a/crowdstf/res/web_modules/angular-hotkeys/index.js b/crowdstf/res/web_modules/angular-hotkeys/index.js
new file mode 100644
index 0000000..c5efade
--- /dev/null
+++ b/crowdstf/res/web_modules/angular-hotkeys/index.js
@@ -0,0 +1,7 @@
+require('angular-hotkeys/build/hotkeys.css')
+//require('./hotkeys.css')
+require('angular-hotkeys/build/hotkeys.js')
+
+module.exports = {
+  name: 'cfp.hotkeys'
+}
diff --git a/crowdstf/res/web_modules/angular-ladda/index.js b/crowdstf/res/web_modules/angular-ladda/index.js
new file mode 100644
index 0000000..9e1427c
--- /dev/null
+++ b/crowdstf/res/web_modules/angular-ladda/index.js
@@ -0,0 +1,8 @@
+
+//window.Ladda = require('ladda/js/ladda')
+require('angular-ladda/src/angular-ladda')
+
+module.exports = {
+  //Ladda: window.Ladda,
+  name: 'angular-ladda'
+}
diff --git a/crowdstf/res/web_modules/angular-xeditable/index.js b/crowdstf/res/web_modules/angular-xeditable/index.js
new file mode 100644
index 0000000..933f658
--- /dev/null
+++ b/crowdstf/res/web_modules/angular-xeditable/index.js
@@ -0,0 +1,7 @@
+require('angular-xeditable/dist/js/xeditable.js')
+require('angular-xeditable/dist/css/xeditable.css')
+require('./style.css')
+
+module.exports = {
+  name: 'xeditable'
+}
diff --git a/crowdstf/res/web_modules/angular-xeditable/style.css b/crowdstf/res/web_modules/angular-xeditable/style.css
new file mode 100644
index 0000000..39001ba
--- /dev/null
+++ b/crowdstf/res/web_modules/angular-xeditable/style.css
@@ -0,0 +1,52 @@
+/*
+  Currently angular-xeditable does not support popover
+  This is a work around to make angular-xeditable look like
+  popover
+*/
+
+.xeditable-wrapper {
+  position: relative;
+}
+
+.xeditable-wrapper form {
+  background: #FFF;
+  border: 1px solid #AAA;
+  border-radius: 5px;
+  display: inline-block;
+  left: 50%;
+  margin-left: -190px;
+  padding: 7px;
+  position: absolute;
+  top: -55px;
+  width: 380px;
+  z-index: 101;
+}
+
+.xeditable-wrapper form:before {
+  content: '';
+  width: 0;
+  height: 0;
+  border-left: 10px solid transparent;
+  border-right: 10px solid transparent;
+  border-top: 10px solid #AAA;
+  position: absolute;
+  bottom: -10px;
+  left: 190px;
+}
+
+.xeditable-wrapper form:after {
+  content: '';
+  width: 0;
+  height: 0;
+  border-left: 9px solid transparent;
+  border-right: 9px solid transparent;
+  border-top: 9px solid #FFF;
+  position: absolute;
+  bottom: -9px;
+  left: 191px;
+}
+
+.xeditable-wrapper .editable-buttons .btn {
+  margin-left: 6px !important;
+  cursor: pointer;
+}
diff --git a/crowdstf/res/web_modules/epoch/index.js b/crowdstf/res/web_modules/epoch/index.js
new file mode 100644
index 0000000..c181a66
--- /dev/null
+++ b/crowdstf/res/web_modules/epoch/index.js
@@ -0,0 +1,10 @@
+require('jquery')
+
+require('d3')
+require('epoch/dist/css/epoch.min.css')
+require('epoch/dist/js/epoch.min.js')
+require('ng-epoch')
+
+module.exports = {
+  name: 'ng.epoch'
+}
diff --git a/crowdstf/res/web_modules/gettext/index.js b/crowdstf/res/web_modules/gettext/index.js
new file mode 100644
index 0000000..70e9a2d
--- /dev/null
+++ b/crowdstf/res/web_modules/gettext/index.js
@@ -0,0 +1,5 @@
+require('angular-gettext')
+
+module.exports = {
+  name: 'gettext'
+}
diff --git a/crowdstf/res/web_modules/ladda/index.js b/crowdstf/res/web_modules/ladda/index.js
new file mode 100644
index 0000000..c651788
--- /dev/null
+++ b/crowdstf/res/web_modules/ladda/index.js
@@ -0,0 +1,3 @@
+require('ladda/dist/ladda-themeless.min.css')
+require('ladda/dist/spin.min.js')
+require('ladda/dist/ladda.min.js')
diff --git a/crowdstf/res/web_modules/ng-context-menu/context-menu.css b/crowdstf/res/web_modules/ng-context-menu/context-menu.css
new file mode 100644
index 0000000..53e23ef
--- /dev/null
+++ b/crowdstf/res/web_modules/ng-context-menu/context-menu.css
@@ -0,0 +1,4 @@
+.context-menu {
+  position: fixed;
+  z-index: 1000;
+}
diff --git a/crowdstf/res/web_modules/ng-context-menu/index.js b/crowdstf/res/web_modules/ng-context-menu/index.js
new file mode 100644
index 0000000..63220da
--- /dev/null
+++ b/crowdstf/res/web_modules/ng-context-menu/index.js
@@ -0,0 +1,6 @@
+require('ng-context-menu/dist/ng-context-menu')
+require('./context-menu.css')
+
+module.exports = {
+  name: 'ng-context-menu'
+}
diff --git a/crowdstf/res/web_modules/ng-file-upload/index.js b/crowdstf/res/web_modules/ng-file-upload/index.js
new file mode 100644
index 0000000..0ba47c8
--- /dev/null
+++ b/crowdstf/res/web_modules/ng-file-upload/index.js
@@ -0,0 +1,6 @@
+require('ng-file-upload/angular-file-upload-shim')
+require('ng-file-upload/angular-file-upload')
+
+module.exports = {
+  name: 'angularFileUpload'
+}
diff --git a/crowdstf/res/web_modules/nine-bootstrap/README.md b/crowdstf/res/web_modules/nine-bootstrap/README.md
new file mode 100644
index 0000000..33d3d0b
--- /dev/null
+++ b/crowdstf/res/web_modules/nine-bootstrap/README.md
@@ -0,0 +1,19 @@
+nine-bootstrap
+==============
+
+Bootstrap 3 theme inspired on iOS 9 flat design.
+It's being used by [STF](https://github.com/openstf/stf).
+
+## Building and Usage
+
+Compile to CSS using [sass](http://sass-lang.com/documentation/).
+
+```
+sass nine-bootstrap.scss
+```
+
+Or use with a module bundler like [Webpack](http://webpack.github.io) directly.
+
+```
+require('nine-bootstrap')
+```
diff --git a/crowdstf/res/web_modules/nine-bootstrap/index.js b/crowdstf/res/web_modules/nine-bootstrap/index.js
new file mode 100644
index 0000000..139ef38
--- /dev/null
+++ b/crowdstf/res/web_modules/nine-bootstrap/index.js
@@ -0,0 +1,7 @@
+require('bootstrap/dist/css/bootstrap.css')
+
+require('nine-bootstrap/nine-bootstrap.scss')
+
+require('components-font-awesome/css/font-awesome.css')
+
+require('font-lato-2-subset')
diff --git a/crowdstf/res/web_modules/nine-bootstrap/nine-bootstrap.scss b/crowdstf/res/web_modules/nine-bootstrap/nine-bootstrap.scss
new file mode 100644
index 0000000..614d390
--- /dev/null
+++ b/crowdstf/res/web_modules/nine-bootstrap/nine-bootstrap.scss
@@ -0,0 +1,1110 @@
+$main: #167FFC;
+$gray: #757575;
+$success: #60c561;
+$info: #5bc0df;
+$warning: #f0ad4f;
+$danger: #ff4b2b;
+$muted: #bebebe;
+
+$dark-text: #555;
+$orange: #FFA101;
+$red: #FF2D55;
+$red2: #FF3B30;
+$pink: #FF5287;
+$blue: #0D7AFF;
+$skyblue: #56B7F1;
+$skyblue2: #34aadc;
+$green: #42CD00;
+$green2: #65db39;
+$darkgreen: #369c00;
+$darkgray: #8C8C91;
+$yellow: #ffcc00;
+$violet: #7a367a;
+$lila: #cc73e1;
+$brown: #b1440e;
+$black: #232323;
+
+.color-orange {
+  color: $orange;
+}
+
+.color-red {
+  color: $red;
+}
+
+.color-pink {
+  color: $pink;
+}
+
+.color-blue {
+  color: $blue
+}
+
+.color-skyblue {
+  color: $skyblue;
+}
+
+.color-green {
+  color: $green;
+}
+
+.color-darkgreen {
+  color: $darkgreen;
+}
+
+.color-darkgray {
+  color: $darkgray;
+}
+
+.color-yellow {
+  color: $yellow;
+}
+
+.color-violet {
+  color: $violet;
+}
+
+.color-lila {
+  color: $lila;
+}
+
+.color-brown {
+  color: $brown;
+}
+
+.color-black {
+  color: $black;
+}
+
+.bg-orange {
+  background-color: $orange;
+}
+
+.bg-red {
+  background-color: $red;
+}
+
+.bg-pink {
+  background-color: $pink;
+}
+
+.bg-blue {
+  background-color: $blue;
+}
+
+.bg-skyblue {
+  background-color: $skyblue;
+}
+
+.bg-green {
+  background-color: $green;
+}
+
+.bg-darkgreen {
+  background-color: $darkgreen;
+}
+
+.bg-darkgray {
+  background-color: $darkgray;
+}
+
+.bg-yellow {
+  background-color: $yellow;
+}
+
+.bg-violet {
+  background-color: $violet;
+}
+
+.bg-lila {
+  background-color: $lila;
+}
+
+.bg-brown {
+  background-color: $brown;
+}
+
+.bg-black {
+  background-color: $black;
+}
+
+@mixin transition($props) {
+  -webkit-transition: $props;
+  -moz-transition: $props;
+  -o-transition: $props;
+  transition: $props;
+}
+
+@mixin transform($props) {
+  -webkit-transform: $props;
+  -moz-transform: $props;
+  -o-transform: $props;
+  transform: $props;
+}
+
+* {
+  outline: 0 !important;
+}
+
+html, body {
+  width: 100%;
+}
+
+html {
+  height: 100%;
+}
+
+body {
+  font-family: "Lato", "Helvetica Neue", Helvetica, sans-serif;
+  color: $gray;
+  min-height: 100%;
+
+  &.bg-1 {
+    background: #e8e8e8;
+  }
+}
+
+.main-content {
+  padding-bottom: 20px;
+}
+
+h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
+  margin: 0 0 16px;
+  font-weight: 300;
+}
+
+h3 {
+  line-height: 1.4em;
+}
+
+strong {
+  font-weight: bolder;
+}
+
+a {
+  color: $main;
+  &:hover {
+    color: $gray;
+    text-decoration: none;
+  }
+}
+
+li {
+  margin-bottom: 5px;
+}
+
+blockquote {
+  margin-bottom: 25px;
+  p {
+    margin-bottom: 10px;
+  }
+  &.pull-right {
+    text-align: right;
+  }
+}
+
+dl {
+  margin: 0 0 18px;
+  dd {
+    margin-bottom: 14px;
+  }
+}
+
+::-moz-selection {
+  background: $main;
+  color: white;
+  text-shadow: none;
+}
+
+::selection {
+  background: $main;
+  color: white;
+  text-shadow: none;
+}
+
+::-webkit-scrollbar {
+  width: 8px;
+  height: 8px;
+}
+
+::-webkit-scrollbar-thumb {
+  background: $muted;
+  background: rgba(0, 0, 0, 0.15);
+  border-radius: 5px;
+}
+
+::-webkit-scrollbar-track {
+  background: #dddddd;
+  background: rgba(0, 0, 0, 0.05);
+}
+
+.bg-primary, .bg-success, .bg-info, .bg-warning, .bg-danger, .bg-muted {
+  color: white;
+}
+
+.bg-primary {
+  background-color: $main;
+}
+
+.bg-success {
+  background-color: $success;
+}
+
+.bg-info {
+  background-color: $info;
+}
+
+.bg-warning {
+  background-color: $warning;
+}
+
+.bg-danger {
+  background-color: $danger;
+}
+
+.bg-muted {
+  background-color: $muted;
+}
+
+.bg-white {
+  background-color: white;
+}
+
+.navbar {
+  height: 112px;
+  width: 100%;
+  border: 0;
+  background: rgba(255, 255, 255, 0.92);
+  padding: 0;
+  min-height: 45px;
+  margin-bottom: 28px;
+  @include transition(all 0.3s);
+  .nav .open > a,
+  .nav .open > a:hover,
+  .nav .open > a:focus {
+    background-color: transparent !important;
+  }
+
+  .nav > li > a {
+    padding: 0;
+  }
+  .nav > li > .dropdown-menu:before,
+  .nav > li > .dropdown-menu:after {
+    display: none;
+  }
+}
+
+.page-title {
+  padding: 0 22px;
+  h1 {
+    margin-bottom: 25px;
+    color: #5e5e5e;
+    font-weight: 300;
+  }
+}
+
+.well {
+  padding: 15px 15px 1px;
+  border: 0;
+  background-color: #dadada;
+  background-color: rgba(0, 0, 0, 0.06);
+  margin-bottom: 0;
+  box-shadow: none;
+  color: #555555;
+}
+
+.modal {
+  overflow-y: hidden;
+}
+
+.modal-open .modal {
+  overflow-y: scroll;
+  -webkit-backdrop-filter: blur(8px);
+  backdrop-filter: blur(8px);
+}
+
+.list-group {
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+  list-style: none;
+  padding: 0;
+  .list-group-item.active,
+  .list-group-item.active:hover,
+  .list-group-item.active:focus {
+    a {
+      color: white;
+      background-color: $main;
+    }
+    p {
+      border-bottom: 0;
+    }
+    .badge {
+      color: $main;
+      background-color: white;
+    }
+  }
+}
+
+.list-group-item {
+  border: 0;
+  margin-bottom: 0;
+  padding: 0;
+  a {
+    color: $gray;
+    display: block;
+    padding: 0 0 0 15px;
+    &:hover {
+      background-color: #f2f2f2;
+    }
+  }
+  p {
+    margin-bottom: 0;
+    padding: 10px 15px 10px 0;
+    border-bottom: 1px solid #dddddd;
+  }
+  .badge {
+    float: right;
+  }
+  &:first-child {
+    border-radius: 0;
+  }
+  &:last-child {
+    border-radius: 0;
+    p {
+      border-bottom: 0;
+    }
+  }
+}
+
+.btn {
+  font-weight: 500;
+  margin: 0 5px 5px 0;
+  @include transition(all 100ms);
+  &.active {
+    box-shadow: none;
+  }
+  .caret {
+    margin: -2px 0 0 6px;
+  }
+  [class^="fa"],
+  &[class*="fa"] {
+    display: inline-block;
+    margin-right: 8px;
+  }
+}
+
+.btn {
+  &.disabled,
+  &[disabled] {
+    font-weight: 300;
+  }
+}
+
+@mixin btn-base($primary, $secondary) {
+  background: $primary;
+  border-color: $primary;
+  &:hover, &.active {
+    background: transparent;
+    color: $primary;
+    border-color: $primary;
+    .caret {
+      border-top-color: $secondary;
+    }
+  }
+}
+
+.btn-default {
+  background: $muted;
+  border-color: $muted;
+  color: white;
+  .caret {
+    border-top-color: white;
+  }
+  &:hover, &.active {
+    background: transparent;
+    color: #aaaaaa;
+    border-color: $muted;
+    .caret {
+      border-top-color: $muted;
+    }
+  }
+  &.disabled, &[disabled] {
+    @extend .btn-default;
+  }
+}
+
+.btn-primary {
+  @include btn-base($main, $main);
+}
+
+.btn-success {
+  @include btn-base($success, $muted);
+}
+
+.btn-info {
+  border-color: transparent !important;
+  &:hover, &.active {
+    background: transparent;
+    color: $info;
+    border-color: $info;
+    .caret {
+      border-top-color: $info;
+    }
+  }
+}
+
+.btn-warning {
+  &:hover, &.active {
+    background: transparent;
+    color: $warning;
+    border-color: $warning;
+    .caret {
+      border-top-color: $warning;
+    }
+  }
+}
+
+.btn-danger {
+  &:hover, &.active {
+    background: transparent;
+    color: $danger;
+    border-color: $danger;
+    .caret {
+      border-top-color: $danger;
+    }
+  }
+}
+
+.btn-default-outline, .btn-primary-outline, .btn-success-outline,
+.btn-info-outline, .btn-warning-outline, .btn-danger-outline {
+  background: transparent;
+}
+
+.btn-default-outline {
+  color: #999999;
+  &:hover, &.active {
+    background: $muted;
+    color: white;
+    .caret {
+      border-top-color: white;
+    }
+  }
+}
+
+.btn-primary-outline {
+  color: $main;
+  &:hover, &.active {
+    background: $main;
+    color: white;
+    .caret {
+      border-top-color: white;
+    }
+  }
+}
+
+.btn-success-outline {
+  color: $success;
+  &:hover, &.active {
+    background: $success;
+    color: white;
+    .caret {
+      border-top-color: white;
+    }
+  }
+}
+
+.btn-info-outline {
+  color: $info;
+  &:hover, &.active {
+    background: $info;
+    color: white;
+    .caret {
+      border-top-color: white;
+    }
+  }
+}
+
+.btn-warning-outline {
+  color: $warning;
+  &:hover, &.active {
+    background: $warning;
+    color: white;
+    .caret {
+      border-top-color: white;
+    }
+  }
+}
+
+.btn-danger-outline {
+  color: $danger;
+  &:hover, &.active {
+    background: $danger;
+    color: white;
+    .caret {
+      border-top-color: white;
+    }
+  }
+}
+
+.btn-group {
+  margin: 0 5px 5px 0;
+  &.pull-right {
+    margin-right: 0;
+  }
+  > .btn {
+    margin: 0;
+    & + .dropdown-toggle {
+      margin-left: 1px;
+      .caret {
+        margin-left: 0;
+      }
+      &[class^="-outline"], &[class*="-outline"] {
+        margin-left: -1px;
+      }
+    }
+  }
+
+}
+
+.dropdown-menu {
+  border: 1px solid #dddddd;
+  padding: 0;
+  min-width: 120px;
+  max-width: 300px;
+  margin-top: 0;
+  border-radius: 0;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+  background: rgba(255, 255, 255, 0.93);
+  > li {
+    margin: 0;
+    > a {
+      padding: 9px 15px 9px 2px;
+      border-bottom: 1px solid #e2e2e2;
+      color: #999999;
+      font-size: 12px;
+      margin-left: 14px;
+      &:hover, &.current {
+        border-bottom-color: $main;
+        color: $main;
+        background: transparent;
+      }
+      i {
+        margin-right: 10px;
+        font-size: 18px;
+        vertical-align: middle;
+      }
+    }
+    &:last-child {
+      > a {
+        border: 0;
+      }
+    }
+    .notifications {
+      margin: -1px -4px 0 0 !important;
+      float: right;
+    }
+    p {
+      margin: 0 60px 0 0;
+    }
+  }
+}
+
+.table {
+  margin-bottom: 10px;
+  th {
+    font-size: 14px;
+  }
+  td {
+    font-size: 14px;
+  }
+
+  > thead > tr > th {
+    border-bottom: 2px solid #dddddd;
+  }
+
+  thead > tr > th,
+  tbody > tr > th,
+  tfoot > tr > th,
+  thead > tr > td,
+  tbody > tr > td,
+  tfoot > tr > td {
+    vertical-align: middle;
+  }
+}
+
+.label {
+  font-size: 85%;
+  line-height: 1.4;
+  font-weight: 300;
+  border-radius: 0;
+  margin: 0 5px;
+  display: inline-block;
+}
+
+.widget-container {
+  min-height: 320px;
+  background: white;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+  &.fluid-height {
+    height: auto;
+    min-height: 0;
+  }
+  .heading {
+    background: rgba(255, 255, 255, 0.94);
+    height: 50px;
+    padding: 15px 15px;
+    color: $dark-text;
+    font-size: 15px;
+    width: 100%;
+    font-weight: 400;
+    margin: 0;
+    [class^="fa"],
+    [class*="fa"] {
+      font-size: 14px;
+      margin-right: 6px;
+      &.pull-right {
+        margin-right: 0;
+        margin-left: 15px;
+        opacity: 0.35;
+        font-size: 1.1em;
+      }
+      &:hover {
+        cursor: pointer;
+        opacity: 1;
+      }
+    }
+  }
+  .tabs {
+    background: #f6f6f6;
+    border-bottom: 1px solid #dddddd;
+  }
+  .widget-content {
+    width: 100%;
+  }
+}
+
+.padded {
+  padding: 15px;
+}
+
+fieldset {
+  border: 0;
+  margin: 0;
+  padding: 0;
+}
+
+label {
+  font-weight: normal;
+  &.error {
+    color: $danger;
+    margin-top: 5px;
+  }
+}
+
+input[type="text"] {
+  box-shadow: none !important;
+}
+
+.form-horizontal .form-group {
+  margin-left: 0;
+  margin-right: 0;
+}
+
+.has-warning {
+  .help-block, .control-label {
+    color: $warning;
+  }
+  .form-control {
+    border-color: $warning;
+  }
+}
+
+.has-error {
+  .help-block, .control-label {
+    color: $danger;
+  }
+  .form-control {
+    border-color: $danger;
+  }
+}
+
+.has-success {
+  .help-block, .control-label {
+    color: $success;
+  }
+  .form-control {
+    border-color: $success;
+  }
+}
+
+.form-control {
+  @include transition(all 0.5s);
+  &:focus {
+    border-color: $main;
+    box-shadow: none;
+  }
+  &.has-error, &.error {
+    border-color: $danger;
+  }
+}
+
+.form-group {
+  [class^="col-"],
+  [class*="col-"] {
+    margin-bottom: 0;
+  }
+  label {
+    margin-bottom: 5px;
+  }
+}
+
+.input-group-btn > .btn {
+  margin-left: -1px;
+  & + .btn {
+    margin-left: -1px;
+  }
+}
+
+.radio + .radio:last-child,
+.checkbox + .checkbox:last-child {
+  margin-bottom: 0;
+}
+
+/* TODO */
+.btn-file {
+  position: relative;
+  overflow: hidden;
+  vertical-align: middle;
+  > input {
+    position: absolute;
+    top: 0;
+    right: 0;
+    margin: 0;
+    font-size: 23px;
+    cursor: pointer;
+    opacity: 0;
+    transform: translate(-300px, 0) scale(4);
+    direction: ltr;
+  }
+}
+
+.text-primary {
+  color: $main;
+}
+
+.text-success {
+  color: $success;
+}
+
+.text-info {
+  color: $info;
+}
+
+.text-warning {
+  color: $warning;
+}
+
+.text-danger {
+  color: $danger;
+}
+
+.progress {
+  height: 12px;
+  box-shadow: none;
+  border-radius: 10px;
+  background-color: #f2f2f2;
+  .progress-bar {
+    box-shadow: none;
+    background-color: $main;
+  }
+  .progress-bar-success {
+    background-color: $success;
+  }
+  .progress-bar-warning {
+    background-color: $warning;
+  }
+  .progress-bar-danger {
+    background-color: $danger;
+  }
+}
+
+.panel {
+  box-shadow: none;
+}
+
+.panel-heading {
+  padding: 0;
+  border-radius: 0;
+  background-color: #f8f8f8;
+  .panel-toggle {
+    background: #f9fafa;
+  }
+}
+
+.panel-title {
+  font-size: 16px;
+  > a {
+    font-weight: 300;
+    padding: 8px 15px 10px;
+    display: block;
+    .caret {
+      margin-top: 5px;
+      border-bottom: 5px solid #999999;
+      border-top: 5px solid transparent;
+      border-right: 5px solid transparent;
+      border-left: 5px solid transparent;
+    }
+    &.collapsed {
+      .caret {
+        border-top: 5px solid #999999;
+        border-bottom: 5px solid transparent;
+        margin-top: 10px;
+      }
+    }
+  }
+  &:hover {
+    color: $main;
+  }
+}
+
+.modal-content {
+  border-radius: 10px;
+  box-shadow: none;
+  background: rgba(255, 255, 255, 0.94);
+  border: 0;
+}
+
+.modal-body {
+  border-radius: 10px;
+}
+
+.modal-footer {
+  .btn {
+    margin: 0 10px 0 0;
+  }
+  border: 0;
+  padding: 15px;
+  border-bottom-left-radius: 10px;
+  border-bottom-right-radius: 10px;
+}
+
+.modal-header {
+  border-top-left-radius: 10px;
+  border-top-right-radius: 10px;
+  background: #f1f1f1;
+}
+
+.nav-tabs > li > a {
+  border-radius: 0;
+  color: $dark-text;
+}
+
+.nav-tabs {
+  margin-top: -8px;
+}
+
+.popover {
+  border-radius: 0;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+  background: rgba(255, 255, 255, 0.92);
+  padding: 0;
+}
+
+.popover-title {
+  border-radius: 0;
+  color: $main;
+  margin-left: 14px;
+  background: none;
+  padding-left: 0;
+  border-bottom: 1px solid rgba(0, 0, 0, 0.2);
+}
+
+.popover-content {
+  margin-left: 14px;
+  padding: 12px 15px 12px 0;
+}
+
+button.close {
+  margin-left: 18px;
+}
+
+.badge {
+  background-color: #aaaaaa;
+  font-weight: 400;
+}
+
+.alert {
+  border-radius: 0;
+  padding: 15px;
+  .close {
+    line-height: 17px;
+  }
+}
+
+.btn.disabled,
+.btn[disabled],
+fieldset[disabled] .btn {
+  color: #a6a6a6;
+}
+
+.btn.disabled:hover,
+.btn[disabled]:hover,
+fieldset[disabled] .btn:hover {
+  background: transparent;
+}
+
+.checkbox, .radio {
+  padding-left: 20px;
+}
+
+.widget-container .heading {
+  //  text-transform: uppercase;
+  font-weight: 400;
+}
+
+.row {
+  margin: 0 10px 0;
+  .row {
+    margin: 0 -10px 0;
+  }
+  & + .row {
+    margin-top: 20px;
+  }
+}
+
+.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7,
+.col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12, .col-sm-1, .col-sm-2,
+.col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9,
+.col-sm-10, .col-sm-11, .col-sm-12, .col-md-1, .col-md-2, .col-md-3, .col-md-4,
+.col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11,
+.col-md-12, .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6,
+.col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 {
+  padding: 0 10px;
+}
+
+input[type="search"].input-sm {
+  padding: 5px;
+}
+
+.pane-center {
+  overflow: inherit !important;
+}
+
+/* FIX Scrolling bug that only happens on latest Chrome + Macbook Trackpad */
+.pane-center.fa-pane-scroller {
+  z-index: 10;
+}
+
+.input-group-btn .btn {
+  margin-right: 0;
+}
+
+.widget-container .heading .checkbox-inline {
+  margin-left: 5px;
+  margin-right: 10px;
+}
+
+.padded-small {
+  padding: 5px;
+}
+
+input[type="range"] {
+  display: inline-block;
+}
+
+@media (max-width: 1200px) {
+  [class*="col-sm"], [class*="col-md"],
+  [class*="col-lg"], [class*="col-xs"] {
+    margin-bottom: 20px;
+  }
+
+  .row + .row {
+    margin-top: 0;
+  }
+}
+
+@media (max-width: 767px) {
+
+  body {
+    padding: 0 !important;
+    &.nav-open {
+      overflow: hidden;
+      .navbar,
+      .container-fluid.main-content {
+        left: 220px;
+      }
+
+    }
+  }
+  .container-fluid.main-content {
+    position: relative;
+    left: 0;
+    @include transition(left 0.3s);
+  }
+  .navbar {
+    height: 45px !important;
+    position: relative;
+    @include transition(left 0.3s);
+    .container-fluid.top-bar {
+      border-bottom: 0;
+      .logo {
+        float: right;
+        margin: 13px 5px 0 0;
+      }
+    }
+  }
+  .widget-container > div .heading {
+    padding-left: 5px;
+  }
+
+}
+
+@media (max-width: 600px) {
+  body.login2 {
+    padding-top: 0;
+    .login-wrapper {
+      padding: 15px;
+      img {
+        margin: 30px auto;
+      }
+      input[type="submit"] {
+        margin-bottom: 20px;
+      }
+    }
+  }
+  .style-selector .style-selector-container .style-toggle {
+    display: none;
+  }
+  .page-title {
+    padding: 0 10px;
+    h1 {
+      margin-bottom: 12px;
+    }
+  }
+  .row {
+    margin: 0;
+  }
+  .fc-header-right {
+    display: none;
+  }
+  .task-widget {
+    li {
+      &.label {
+        display: none;
+      }
+    }
+  }
+  .padded {
+    padding: 10px;
+  }
+  .table th {
+    font-size: 13px;
+  }
+
+  .nav-tabs > li > a > [class*="fa"] {
+    margin-right: 0 !important;
+    & + span {
+      display: none;
+    }
+  }
+
+}
diff --git a/crowdstf/res/web_modules/ui-bootstrap/index.js b/crowdstf/res/web_modules/ui-bootstrap/index.js
new file mode 100644
index 0000000..d2a17c4
--- /dev/null
+++ b/crowdstf/res/web_modules/ui-bootstrap/index.js
@@ -0,0 +1,5 @@
+require('script!angular-bootstrap/ui-bootstrap-tpls')
+
+module.exports = {
+  name: 'ui.bootstrap'
+}
diff --git a/crowdstf/test/.eslintrc b/crowdstf/test/.eslintrc
new file mode 100644
index 0000000..19e6180
--- /dev/null
+++ b/crowdstf/test/.eslintrc
@@ -0,0 +1,8 @@
+{
+  "env": {
+    "mocha": true
+  },
+  "rules": {
+    "no-unused-expressions": 0
+  }
+}
diff --git a/crowdstf/test/fixt/Virtual.kcm b/crowdstf/test/fixt/Virtual.kcm
new file mode 100644
index 0000000..d90b790
--- /dev/null
+++ b/crowdstf/test/fixt/Virtual.kcm
@@ -0,0 +1,601 @@
+# Copyright (C) 2010 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+#
+# Key character map for a built-in generic virtual keyboard primarily used
+# for instrumentation and testing purposes.
+#
+
+type FULL
+
+### Basic QWERTY keys ###
+
+key A {
+    label:                              'A'
+    base:                               'a'
+    shift, capslock:                    'A'
+}
+
+key B {
+    label:                              'B'
+    base:                               'b'
+    shift, capslock:                    'B'
+}
+
+key C {
+    label:                              'C'
+    base:                               'c'
+    shift, capslock:                    'C'
+    alt:                                '\u00e7'
+    shift+alt:                          '\u00c7'
+}
+
+key D {
+    label:                              'D'
+    base:                               'd'
+    shift, capslock:                    'D'
+}
+
+key E {
+    label:                              'E'
+    base:                               'e'
+    shift, capslock:                    'E'
+    alt:                                '\u0301'
+}
+
+key F {
+    label:                              'F'
+    base:                               'f'
+    shift, capslock:                    'F'
+}
+
+key G {
+    label:                              'G'
+    base:                               'g'
+    shift, capslock:                    'G'
+}
+
+key H {
+    label:                              'H'
+    base:                               'h'
+    shift, capslock:                    'H'
+}
+
+key I {
+    label:                              'I'
+    base:                               'i'
+    shift, capslock:                    'I'
+    alt:                                '\u0302'
+}
+
+key J {
+    label:                              'J'
+    base:                               'j'
+    shift, capslock:                    'J'
+}
+
+key K {
+    label:                              'K'
+    base:                               'k'
+    shift, capslock:                    'K'
+}
+
+key L {
+    label:                              'L'
+    base:                               'l'
+    shift, capslock:                    'L'
+}
+
+key M {
+    label:                              'M'
+    base:                               'm'
+    shift, capslock:                    'M'
+}
+
+key N {
+    label:                              'N'
+    base:                               'n'
+    shift, capslock:                    'N'
+    alt:                                '\u0303'
+}
+
+key O {
+    label:                              'O'
+    base:                               'o'
+    shift, capslock:                    'O'
+}
+
+key P {
+    label:                              'P'
+    base:                               'p'
+    shift, capslock:                    'P'
+}
+
+key Q {
+    label:                              'Q'
+    base:                               'q'
+    shift, capslock:                    'Q'
+}
+
+key R {
+    label:                              'R'
+    base:                               'r'
+    shift, capslock:                    'R'
+}
+
+key S {
+    label:                              'S'
+    base:                               's'
+    shift, capslock:                    'S'
+    alt:                                '\u00df'
+}
+
+key T {
+    label:                              'T'
+    base:                               't'
+    shift, capslock:                    'T'
+}
+
+key U {
+    label:                              'U'
+    base:                               'u'
+    shift, capslock:                    'U'
+    alt:                                '\u0308'
+}
+
+key V {
+    label:                              'V'
+    base:                               'v'
+    shift, capslock:                    'V'
+}
+
+key W {
+    label:                              'W'
+    base:                               'w'
+    shift, capslock:                    'W'
+}
+
+key X {
+    label:                              'X'
+    base:                               'x'
+    shift, capslock:                    'X'
+}
+
+key Y {
+    label:                              'Y'
+    base:                               'y'
+    shift, capslock:                    'Y'
+}
+
+key Z {
+    label:                              'Z'
+    base:                               'z'
+    shift, capslock:                    'Z'
+}
+
+key 0 {
+    label:                              '0'
+    base:                               '0'
+    shift:                              ')'
+}
+
+key 1 {
+    label:                              '1'
+    base:                               '1'
+    shift:                              '!'
+}
+
+key 2 {
+    label:                              '2'
+    base:                               '2'
+    shift:                              '@'
+}
+
+key 3 {
+    label:                              '3'
+    base:                               '3'
+    shift:                              '#'
+}
+
+key 4 {
+    label:                              '4'
+    base:                               '4'
+    shift:                              '$'
+}
+
+key 5 {
+    label:                              '5'
+    base:                               '5'
+    shift:                              '%'
+}
+
+key 6 {
+    label:                              '6'
+    base:                               '6'
+    shift:                              '^'
+    alt+shift:                          '\u0302'
+}
+
+key 7 {
+    label:                              '7'
+    base:                               '7'
+    shift:                              '&'
+}
+
+key 8 {
+    label:                              '8'
+    base:                               '8'
+    shift:                              '*'
+}
+
+key 9 {
+    label:                              '9'
+    base:                               '9'
+    shift:                              '('
+}
+
+key SPACE {
+    label:                              ' '
+    base:                               ' '
+    alt, meta:                          fallback SEARCH
+    ctrl:                               fallback LANGUAGE_SWITCH
+}
+
+key ENTER {
+    label:                              '\n'
+    base:                               '\n'
+}
+
+key TAB {
+    label:                              '\t'
+    base:                               '\t'
+}
+
+key COMMA {
+    label:                              ','
+    base:                               ','
+    shift:                              '<'
+}
+
+key PERIOD {
+    label:                              '.'
+    base:                               '.'
+    shift:                              '>'
+}
+
+key SLASH {
+    label:                              '/'
+    base:                               '/'
+    shift:                              '?'
+}
+
+key GRAVE {
+    label:                              '`'
+    base:                               '`'
+    shift:                              '~'
+    alt:                                '\u0300'
+    alt+shift:                          '\u0303'
+}
+
+key MINUS {
+    label:                              '-'
+    base:                               '-'
+    shift:                              '_'
+}
+
+key EQUALS {
+    label:                              '='
+    base:                               '='
+    shift:                              '+'
+}
+
+key LEFT_BRACKET {
+    label:                              '['
+    base:                               '['
+    shift:                              '{'
+}
+
+key RIGHT_BRACKET {
+    label:                              ']'
+    base:                               ']'
+    shift:                              '}'
+}
+
+key BACKSLASH {
+    label:                              '\\'
+    base:                               '\\'
+    shift:                              '|'
+}
+
+key SEMICOLON {
+    label:                              ';'
+    base:                               ';'
+    shift:                              ':'
+}
+
+key APOSTROPHE {
+    label:                              '\''
+    base:                               '\''
+    shift:                              '"'
+}
+
+### Numeric keypad ###
+
+key NUMPAD_0 {
+    label:                              '0'
+    base:                               fallback INSERT
+    numlock:                            '0'
+}
+
+key NUMPAD_1 {
+    label:                              '1'
+    base:                               fallback MOVE_END
+    numlock:                            '1'
+}
+
+key NUMPAD_2 {
+    label:                              '2'
+    base:                               fallback DPAD_DOWN
+    numlock:                            '2'
+}
+
+key NUMPAD_3 {
+    label:                              '3'
+    base:                               fallback PAGE_DOWN
+    numlock:                            '3'
+}
+
+key NUMPAD_4 {
+    label:                              '4'
+    base:                               fallback DPAD_LEFT
+    numlock:                            '4'
+}
+
+key NUMPAD_5 {
+    label:                              '5'
+    base:                               fallback DPAD_CENTER
+    numlock:                            '5'
+}
+
+key NUMPAD_6 {
+    label:                              '6'
+    base:                               fallback DPAD_RIGHT
+    numlock:                            '6'
+}
+
+key NUMPAD_7 {
+    label:                              '7'
+    base:                               fallback MOVE_HOME
+    numlock:                            '7'
+}
+
+key NUMPAD_8 {
+    label:                              '8'
+    base:                               fallback DPAD_UP
+    numlock:                            '8'
+}
+
+key NUMPAD_9 {
+    label:                              '9'
+    base:                               fallback PAGE_UP
+    numlock:                            '9'
+}
+
+key NUMPAD_LEFT_PAREN {
+    label:                              '('
+    base:                               '('
+}
+
+key NUMPAD_RIGHT_PAREN {
+    label:                              ')'
+    base:                               ')'
+}
+
+key NUMPAD_DIVIDE {
+    label:                              '/'
+    base:                               '/'
+}
+
+key NUMPAD_MULTIPLY {
+    label:                              '*'
+    base:                               '*'
+}
+
+key NUMPAD_SUBTRACT {
+    label:                              '-'
+    base:                               '-'
+}
+
+key NUMPAD_ADD {
+    label:                              '+'
+    base:                               '+'
+}
+
+key NUMPAD_DOT {
+    label:                              '.'
+    base:                               fallback FORWARD_DEL
+    numlock:                            '.'
+}
+
+key NUMPAD_COMMA {
+    label:                              ','
+    base:                               ','
+}
+
+key NUMPAD_EQUALS {
+    label:                              '='
+    base:                               '='
+}
+
+key NUMPAD_ENTER {
+    label:                              '\n'
+    base:                               '\n' fallback ENTER
+    ctrl, alt, meta:                    none fallback ENTER
+}
+
+### Special keys on phones ###
+
+key AT {
+    label:                              '@'
+    base:                               '@'
+}
+
+key STAR {
+    label:                              '*'
+    base:                               '*'
+}
+
+key POUND {
+    label:                              '#'
+    base:                               '#'
+}
+
+key PLUS {
+    label:                              '+'
+    base:                               '+'
+}
+
+### Non-printing keys ###
+
+key ESCAPE {
+    base:                               fallback BACK
+    alt, meta:                          fallback HOME
+    ctrl:                               fallback MENU
+}
+
+### Gamepad buttons ###
+
+key BUTTON_A {
+    base:                               fallback BACK
+}
+
+key BUTTON_B {
+    base:                               fallback BACK
+}
+
+key BUTTON_C {
+    base:                               fallback BACK
+}
+
+key BUTTON_X {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_Y {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_Z {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_L1 {
+    base:                               none
+}
+
+key BUTTON_R1 {
+    base:                               none
+}
+
+key BUTTON_L2 {
+    base:                               none
+}
+
+key BUTTON_R2 {
+    base:                               none
+}
+
+key BUTTON_THUMBL {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_THUMBR {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_START {
+    base:                               fallback HOME
+}
+
+key BUTTON_SELECT {
+    base:                               fallback MENU
+}
+
+key BUTTON_MODE {
+    base:                               fallback MENU
+}
+
+key BUTTON_1 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_2 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_3 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_4 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_5 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_6 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_7 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_8 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_9 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_10 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_11 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_12 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_13 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_14 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_15 {
+    base:                               fallback DPAD_CENTER
+}
+
+key BUTTON_16 {
+    base:                               fallback DPAD_CENTER
+}
diff --git a/crowdstf/test/fixt/Virtual.kcm.json b/crowdstf/test/fixt/Virtual.kcm.json
new file mode 100644
index 0000000..600556a
--- /dev/null
+++ b/crowdstf/test/fixt/Virtual.kcm.json
@@ -0,0 +1,4232 @@
+{
+  "type": "FULL",
+  "keys": [
+    {
+      "key": "A",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "A"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "a"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "A"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "A"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "B",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "B"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "b"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "B"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "B"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "C",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "C"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "c"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "C"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "C"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "alt"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "ç"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            },
+            {
+              "type": "alt"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "Ç"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "D",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "D"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "d"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "D"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "D"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "E",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "E"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "e"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "E"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "E"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "alt"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "́"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "F",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "F"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "f"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "F"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "F"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "G",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "G"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "g"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "G"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "G"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "H",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "H"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "h"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "H"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "H"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "I",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "I"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "i"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "I"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "I"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "alt"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "̂"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "J",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "J"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "j"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "J"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "J"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "K",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "K"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "k"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "K"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "K"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "L",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "L"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "l"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "L"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "L"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "M",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "M"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "m"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "M"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "M"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "N",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "N"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "n"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "N"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "N"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "alt"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "̃"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "O",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "O"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "o"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "O"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "O"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "P",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "P"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "p"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "P"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "P"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "Q",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "Q"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "q"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "Q"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "Q"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "R",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "R"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "r"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "R"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "R"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "S",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "S"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "s"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "S"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "S"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "alt"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "ß"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "T",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "T"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "t"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "T"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "T"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "U",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "U"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "u"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "U"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "U"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "alt"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "̈"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "V",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "V"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "v"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "V"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "V"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "W",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "W"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "w"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "W"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "W"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "X",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "X"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "x"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "X"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "X"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "Y",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "Y"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "y"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "Y"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "Y"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "Z",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "Z"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "z"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "Z"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "capslock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "Z"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "0",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "0"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "0"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": ")"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "1",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "1"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "1"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "!"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "2",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "2"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "2"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "@"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "3",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "3"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "3"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "#"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "4",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "4"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "4"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "$"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "5",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "5"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "5"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "%"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "6",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "6"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "6"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "^"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "alt"
+            },
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "̂"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "7",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "7"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "7"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "&"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "8",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "8"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "8"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "*"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "9",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "9"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "9"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "("
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "SPACE",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": " "
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": " "
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "alt"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "SEARCH"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "meta"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "SEARCH"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "ctrl"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "LANGUAGE_SWITCH"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "ENTER",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "\n"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "\n"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "TAB",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "\t"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "\t"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "COMMA",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": ","
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": ","
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "<"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "PERIOD",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "."
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "."
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": ">"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "SLASH",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "/"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "/"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "?"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "GRAVE",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "`"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "`"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "~"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "alt"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "̀"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "alt"
+            },
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "̃"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "MINUS",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "-"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "-"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "_"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "EQUALS",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "="
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "="
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "+"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "LEFT_BRACKET",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "["
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "["
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "{"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "RIGHT_BRACKET",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "]"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "]"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "}"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BACKSLASH",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "\\"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "\\"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "|"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "SEMICOLON",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": ";"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": ";"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": ":"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "APOSTROPHE",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "'"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "'"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "shift"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "\""
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_0",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "0"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "INSERT"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "numlock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "0"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_1",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "1"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "MOVE_END"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "numlock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "1"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_2",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "2"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_DOWN"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "numlock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "2"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_3",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "3"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "PAGE_DOWN"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "numlock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "3"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_4",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "4"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_LEFT"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "numlock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "4"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_5",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "5"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "numlock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "5"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_6",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "6"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_RIGHT"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "numlock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "6"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_7",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "7"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "MOVE_HOME"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "numlock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "7"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_8",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "8"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_UP"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "numlock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "8"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_9",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "9"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "PAGE_UP"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "numlock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "9"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_LEFT_PAREN",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "("
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "("
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_RIGHT_PAREN",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": ")"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": ")"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_DIVIDE",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "/"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "/"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_MULTIPLY",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "*"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "*"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_SUBTRACT",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "-"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "-"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_ADD",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "+"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "+"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_DOT",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "."
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "FORWARD_DEL"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "numlock"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "."
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_COMMA",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": ","
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": ","
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_EQUALS",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "="
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "="
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "NUMPAD_ENTER",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "\n"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "\n"
+            },
+            {
+              "type": "fallback",
+              "key": "ENTER"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "ctrl"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "none"
+            },
+            {
+              "type": "fallback",
+              "key": "ENTER"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "alt"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "none"
+            },
+            {
+              "type": "fallback",
+              "key": "ENTER"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "meta"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "none"
+            },
+            {
+              "type": "fallback",
+              "key": "ENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "AT",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "@"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "@"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "STAR",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "*"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "*"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "POUND",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "#"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "#"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "PLUS",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "label"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "+"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "literal",
+              "value": "+"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "ESCAPE",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "BACK"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "alt"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "HOME"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "meta"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "HOME"
+            }
+          ]
+        },
+        {
+          "modifiers": [
+            {
+              "type": "ctrl"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "MENU"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_A",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "BACK"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_B",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "BACK"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_C",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "BACK"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_X",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_Y",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_Z",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_L1",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "none"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_R1",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "none"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_L2",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "none"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_R2",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "none"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_THUMBL",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_THUMBR",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_START",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "HOME"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_SELECT",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "MENU"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_MODE",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "MENU"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_1",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_2",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_3",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_4",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_5",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_6",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_7",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_8",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_9",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_10",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_11",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_12",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_13",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_14",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_15",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    },
+    {
+      "key": "BUTTON_16",
+      "rules": [
+        {
+          "modifiers": [
+            {
+              "type": "base"
+            }
+          ],
+          "behaviors": [
+            {
+              "type": "fallback",
+              "key": "DPAD_CENTER"
+            }
+          ]
+        }
+      ]
+    }
+  ]
+}
diff --git a/crowdstf/test/util/keyutil.js b/crowdstf/test/util/keyutil.js
new file mode 100644
index 0000000..e7640d1
--- /dev/null
+++ b/crowdstf/test/util/keyutil.js
@@ -0,0 +1,23 @@
+var path = require('path')
+var fs = require('fs')
+
+var chai = require('chai')
+var expect = chai.expect
+
+var keyutil = require('../../lib/util/keyutil')
+
+describe('keyutil', function() {
+  describe('parseKeyCharacterMap', function() {
+    it('should be able to parse Virtual.kcm', function(done) {
+      var expected = require('../fixt/Virtual.kcm.json')
+      var source = path.join(__dirname, '..', 'fixt', 'Virtual.kcm')
+
+      keyutil.parseKeyCharacterMap(fs.createReadStream(source))
+        .then(function(keymap) {
+          expect(keymap).to.eql(expected)
+          done()
+        })
+        .catch(done)
+    })
+  })
+})
diff --git a/crowdstf/test/util/logger.js b/crowdstf/test/util/logger.js
new file mode 100644
index 0000000..7cf29de
--- /dev/null
+++ b/crowdstf/test/util/logger.js
@@ -0,0 +1,14 @@
+var chai = require('chai')
+var expect = chai.expect
+
+var logger = require('../../lib/util/logger')
+
+describe('Logger', function() {
+  it('should have a createLogger method', function() {
+    expect(logger).itself.to.respondTo('createLogger')
+  })
+
+  it('should have a setGlobalIdentifier method', function() {
+    expect(logger).itself.to.respondTo('setGlobalIdentifier')
+  })
+})
diff --git a/crowdstf/test/util/ttlset.js b/crowdstf/test/util/ttlset.js
new file mode 100644
index 0000000..287e1fe
--- /dev/null
+++ b/crowdstf/test/util/ttlset.js
@@ -0,0 +1,193 @@
+var chai = require('chai')
+var sinon = require('sinon')
+var expect = chai.expect
+chai.use(require('sinon-chai'))
+
+var TtlSet = require('../../lib/util/ttlset')
+
+describe('TtlSet', function() {
+  it('should emit "drop" for entries with expired TTL', function(done) {
+    var ttlset = new TtlSet(50)
+
+    var spy = sinon.spy()
+    ttlset.on('drop', spy)
+
+    ttlset.bump(1, Date.now())
+    ttlset.bump(2, Date.now() + 100)
+    ttlset.bump(3, Date.now() + 200)
+    ttlset.bump(4, Date.now() + 1000)
+
+    setTimeout(function() {
+      expect(spy).to.have.been.calledThrice
+      expect(spy).to.have.been.calledWith(1)
+      expect(spy).to.have.been.calledWith(2)
+      expect(spy).to.have.been.calledWith(3)
+      expect(ttlset.head).to.equal(ttlset.tail)
+      ttlset.stop()
+      done()
+    }, 300)
+  })
+
+  describe('bump', function() {
+    it('should emit "insert" for new entries', function(done) {
+      var ttlset = new TtlSet(50)
+
+      var spy = sinon.spy()
+      ttlset.on('insert', spy)
+
+      ttlset.bump(1)
+      ttlset.bump(2)
+      ttlset.bump(3)
+
+      expect(spy).to.have.been.calledThrice
+      expect(spy).to.have.been.calledWith(1)
+      expect(spy).to.have.been.calledWith(2)
+      expect(spy).to.have.been.calledWith(3)
+
+      ttlset.stop()
+      done()
+    })
+
+    it('should not emit "insert" for new entries if SILENT', function(done) {
+      var ttlset = new TtlSet(50)
+
+      var spy = sinon.spy()
+      ttlset.on('insert', spy)
+
+      ttlset.bump(1, Date.now(), TtlSet.SILENT)
+      ttlset.bump(2, Date.now())
+      ttlset.bump(3, Date.now(), TtlSet.SILENT)
+
+      expect(spy).to.have.been.calledOnce
+      expect(spy).to.have.been.calledWith(2)
+
+      ttlset.stop()
+      done()
+    })
+
+    it('should create an item for the value if none exists', function(done) {
+      var ttlset = new TtlSet(5000)
+      ttlset.bump(5)
+      expect(ttlset.head).to.equal(ttlset.tail)
+      expect(ttlset.head.value).to.equal(5)
+      done()
+    })
+
+    it('should make the item the new tail', function(done) {
+      var ttlset = new TtlSet(5000)
+      ttlset.bump(5)
+      expect(ttlset.tail.value).to.equal(5)
+      ttlset.bump(6)
+      expect(ttlset.tail.value).to.equal(6)
+      done()
+    })
+
+    it('should set head if none exists', function(done) {
+      var ttlset = new TtlSet(5000)
+      expect(ttlset.head).to.be.null
+      ttlset.bump(5)
+      expect(ttlset.head.value).to.equal(5)
+      ttlset.bump(6)
+      expect(ttlset.head.value).to.equal(5)
+      done()
+    })
+
+    it('should take old item out and make it the tail', function(done) {
+      var ttlset = new TtlSet(5000)
+      ttlset.bump(1)
+      expect(ttlset.head.value).to.equal(1)
+      expect(ttlset.tail.value).to.equal(1)
+      expect(ttlset.head.next).to.be.null
+      expect(ttlset.head.prev).to.be.null
+      expect(ttlset.tail.next).to.be.null
+      expect(ttlset.tail.prev).to.be.null
+      ttlset.bump(2)
+      expect(ttlset.head.value).to.equal(1)
+      expect(ttlset.tail.value).to.equal(2)
+      expect(ttlset.head.next).to.equal(ttlset.tail)
+      expect(ttlset.head.prev).to.be.null
+      expect(ttlset.tail.next).to.be.null
+      expect(ttlset.tail.prev).to.equal(ttlset.head)
+      ttlset.bump(1)
+      expect(ttlset.head.value).to.equal(2)
+      expect(ttlset.tail.value).to.equal(1)
+      expect(ttlset.head.next).to.equal(ttlset.tail)
+      expect(ttlset.head.prev).to.be.null
+      expect(ttlset.tail.next).to.be.null
+      expect(ttlset.tail.prev).to.equal(ttlset.head)
+      ttlset.bump(1)
+      expect(ttlset.head.value).to.equal(2)
+      expect(ttlset.tail.value).to.equal(1)
+      expect(ttlset.head.next).to.equal(ttlset.tail)
+      expect(ttlset.head.prev).to.be.null
+      expect(ttlset.tail.next).to.be.null
+      expect(ttlset.tail.prev).to.equal(ttlset.head)
+      done()
+    })
+  })
+
+  describe('drop', function() {
+    it('should emit "drop" for the dropped entry', function(done) {
+      var ttlset = new TtlSet(50)
+
+      var spy = sinon.spy()
+      ttlset.on('drop', spy)
+
+      ttlset.bump(1)
+      ttlset.bump(2)
+      ttlset.bump(3)
+      ttlset.drop(1)
+      ttlset.drop(3)
+
+      expect(spy).to.have.been.calledTwice
+      expect(spy).to.have.been.calledWith(1)
+      expect(spy).to.have.been.calledWith(3)
+
+      ttlset.stop()
+      done()
+    })
+
+    it('should not emit "drop" for the dropped entry if SILENT', function(done) {
+      var ttlset = new TtlSet(50)
+
+      var spy = sinon.spy()
+      ttlset.on('drop', spy)
+
+      ttlset.bump(1)
+      ttlset.bump(2)
+      ttlset.bump(3)
+      ttlset.drop(1, TtlSet.SILENT)
+      ttlset.drop(3)
+
+      expect(spy).to.have.been.calledOnce
+      expect(spy).to.have.been.calledWith(3)
+
+      ttlset.stop()
+      done()
+    })
+
+    it('should silently ignore unknown values', function(done) {
+      var ttlset = new TtlSet(5000)
+      ttlset.drop(5)
+      done()
+    })
+
+    it('should remove the value from the set', function(done) {
+      var ttlset = new TtlSet(5000)
+      ttlset.bump(5)
+      ttlset.drop(5)
+      expect(ttlset.tail).to.be.null
+      expect(ttlset.head).to.be.null
+      ttlset.bump(1)
+      ttlset.bump(2)
+      ttlset.drop(1)
+      expect(ttlset.tail).to.equal(ttlset.head)
+      expect(ttlset.tail.value).to.equal(2)
+      ttlset.bump(3)
+      ttlset.drop(3)
+      expect(ttlset.tail).to.equal(ttlset.head)
+      expect(ttlset.tail.value).to.equal(2)
+      done()
+    })
+  })
+})
diff --git a/crowdstf/test/wire/messagestream.js b/crowdstf/test/wire/messagestream.js
new file mode 100644
index 0000000..906de60
--- /dev/null
+++ b/crowdstf/test/wire/messagestream.js
@@ -0,0 +1,97 @@
+var sinon = require('sinon')
+var chai = require('chai')
+chai.use(require('sinon-chai'))
+var expect = chai.expect
+
+var ms = require('../../lib/wire/messagestream')
+
+describe('MessageStream', function() {
+  describe('DelimitedStream', function() {
+    it('should emit complete varint-delimited chunks', function() {
+      var ds = new ms.DelimitedStream()
+      var spy = sinon.spy()
+      ds.on('data', spy)
+      ds.write(new Buffer([1, 0x61, 2, 0x62, 0x63]))
+      expect(spy).to.have.been.calledTwice
+      expect(spy.firstCall.args).to.eql([new Buffer([0x61])])
+      expect(spy.secondCall.args).to.eql([new Buffer([0x62, 0x63])])
+    })
+
+    it('should wait for more data', function() {
+      var ds = new ms.DelimitedStream()
+      var spy = sinon.spy()
+      ds.on('data', spy)
+      ds.write(new Buffer([3]))
+      expect(spy).to.not.have.been.called
+      ds.write(new Buffer([0x66]))
+      expect(spy).to.not.have.been.called
+      ds.write(new Buffer([0x65]))
+      expect(spy).to.not.have.been.called
+      ds.write(new Buffer([0x64]))
+      expect(spy).to.have.been.calledOnce
+      expect(spy.firstCall.args).to.eql([new Buffer([0x66, 0x65, 0x64])])
+    })
+
+    it('should read varint32 properly', function() {
+      var ds = new ms.DelimitedStream()
+      var spy = sinon.spy()
+      ds.on('data', spy)
+      ds.write(new Buffer([172, 2])) // 300
+      var data = new Buffer(300)
+      data.fill(0)
+      ds.write(data)
+      expect(spy).to.have.been.calledOnce
+      expect(spy.firstCall.args).to.eql([data])
+    })
+
+    it('should emit "end"', function(done) {
+      var ds = new ms.DelimitedStream()
+      var spy = sinon.spy()
+      ds.on('data', sinon.spy())
+      ds.on('end', spy)
+      ds.write(new Buffer([1]))
+      ds.end()
+      setImmediate(function() {
+        expect(spy).to.have.been.called
+        done()
+      })
+    })
+  })
+
+  describe('DelimitingStream', function() {
+    it('should add delimiter chunks to stream', function() {
+      var ds = new ms.DelimitingStream()
+      var spy = sinon.spy()
+      ds.on('data', spy)
+      ds.write(new Buffer([0x66, 0x6f, 0x6f]))
+      expect(spy).to.have.been.calledTwice
+      expect(spy.firstCall.args).to.eql([new Buffer([0x03])])
+      expect(spy.secondCall.args).to.eql([new Buffer([0x66, 0x6f, 0x6f])])
+    })
+
+    it('should write proper varints', function() {
+      var ds = new ms.DelimitingStream()
+      var spy = sinon.spy()
+      ds.on('data', spy)
+      var data = new Buffer(300)
+      data.fill(0)
+      ds.write(data)
+      expect(spy).to.have.been.calledTwice
+      expect(spy.firstCall.args).to.eql([new Buffer([172, 2])])
+      expect(spy.secondCall.args).to.eql([data])
+    })
+
+    it('should emit "end"', function(done) {
+      var ds = new ms.DelimitingStream()
+      var spy = sinon.spy()
+      ds.on('data', sinon.spy())
+      ds.on('end', spy)
+      ds.write(new Buffer([1]))
+      ds.end()
+      setImmediate(function() {
+        expect(spy).to.have.been.called
+        done()
+      })
+    })
+  })
+})
diff --git a/crowdstf/test/wire/seqqueue.js b/crowdstf/test/wire/seqqueue.js
new file mode 100644
index 0000000..3856578
--- /dev/null
+++ b/crowdstf/test/wire/seqqueue.js
@@ -0,0 +1,116 @@
+var sinon = require('sinon')
+var chai = require('chai')
+chai.use(require('sinon-chai'))
+var expect = chai.expect
+
+var SeqQueue = require('../../lib/wire/seqqueue')
+
+describe('SeqQueue', function() {
+  it('should wait until started', function() {
+    var spy = sinon.spy()
+    var q = new SeqQueue(10, Infinity)
+    q.push(1, spy)
+    expect(spy).to.not.have.been.called
+    q.start(0)
+    expect(spy).to.have.been.calledOnce
+  })
+
+  it('should call first item immediately if started', function() {
+    var spy = sinon.spy()
+    var q = new SeqQueue(10, Infinity)
+    q.start(0)
+    q.push(1, spy)
+    expect(spy).to.have.been.calledOnce
+  })
+
+  it('should call items in seq order', function() {
+    var spy1 = sinon.spy()
+    var spy2 = sinon.spy()
+    var spy3 = sinon.spy()
+    var spy4 = sinon.spy()
+    var q = new SeqQueue(10, Infinity)
+    q.start(0)
+    q.push(1, spy1)
+    q.push(2, spy2)
+    q.push(3, spy3)
+    q.push(4, spy4)
+    expect(spy1).to.have.been.calledOnce
+    expect(spy2).to.have.been.calledOnce
+    expect(spy3).to.have.been.calledOnce
+    expect(spy4).to.have.been.calledOnce
+  })
+
+  it('should not call item until seq reaches it', function() {
+    var spy1 = sinon.spy()
+    var spy2 = sinon.spy()
+    var spy3 = sinon.spy()
+    var spy4 = sinon.spy()
+    var q = new SeqQueue(10, Infinity)
+    q.start(0)
+    q.push(1, spy1)
+    q.push(4, spy4)
+    expect(spy1).to.have.been.calledOnce
+    expect(spy4).to.not.have.been.called
+    q.push(3, spy3)
+    expect(spy3).to.not.have.been.called
+    expect(spy4).to.not.have.been.called
+    q.push(2, spy2)
+    expect(spy2).to.have.been.calledOnce
+    expect(spy3).to.have.been.calledOnce
+    expect(spy4).to.have.been.calledOnce
+  })
+
+  it('should should start skipping items if too far behind', function() {
+    var spy1 = sinon.spy()
+    var spy2 = sinon.spy()
+    var spy3 = sinon.spy()
+    var spy4 = sinon.spy()
+    var q = new SeqQueue(10, 2)
+    q.start(0)
+    q.push(1, spy1)
+    q.push(3, spy3)
+    q.push(4, spy4)
+    q.push(2, spy2)
+    expect(spy1).to.have.been.calledOnce
+    expect(spy2).to.not.have.been.called
+    expect(spy3).to.have.been.calledOnce
+    expect(spy4).to.have.been.calledOnce
+  })
+
+  it('should should start a new queue', function() {
+    var spy1 = sinon.spy()
+    var spy2 = sinon.spy()
+    var spy3 = sinon.spy()
+    var spy4 = sinon.spy()
+    var q = new SeqQueue(10, Infinity)
+    q.start(0)
+    q.push(1, spy1)
+    q.push(2, spy2)
+    q.stop(3)
+    q.start(0)
+    q.push(1, spy3)
+    q.push(2, spy4)
+    expect(spy1).to.have.been.calledOnce
+    expect(spy2).to.have.been.calledOnce
+    expect(spy3).to.have.been.calledOnce
+    expect(spy4).to.have.been.calledOnce
+  })
+
+  it('should should start a new queue on even on 1 length', function() {
+    var spy1 = sinon.spy()
+    var spy2 = sinon.spy()
+    var spy3 = sinon.spy()
+    var q = new SeqQueue(2, Infinity)
+    q.start(0)
+    q.push(1, spy1)
+    q.stop(2)
+    q.start(0)
+    q.push(1, spy2)
+    q.stop(2)
+    q.start(0)
+    q.push(1, spy3)
+    expect(spy1).to.have.been.calledOnce
+    expect(spy2).to.have.been.calledOnce
+    expect(spy3).to.have.been.calledOnce
+  })
+})
diff --git a/crowdstf/vendor/STFService/STFService.apk b/crowdstf/vendor/STFService/STFService.apk
new file mode 100644
index 0000000..1b5eaf8
--- /dev/null
+++ b/crowdstf/vendor/STFService/STFService.apk
Binary files differ
diff --git a/crowdstf/vendor/STFService/wire.proto b/crowdstf/vendor/STFService/wire.proto
new file mode 100644
index 0000000..e38b9ac
--- /dev/null
+++ b/crowdstf/vendor/STFService/wire.proto
@@ -0,0 +1,295 @@
+package jp.co.cyberagent.stf.proto;
+
+option java_outer_classname = "Wire";
+
+enum MessageType {
+    DO_IDENTIFY = 1;
+    DO_KEYEVENT = 2;
+    DO_TYPE = 3;
+    DO_WAKE = 4;
+    DO_ADD_ACCOUNT_MENU = 24;
+    DO_REMOVE_ACCOUNT = 20;
+    GET_ACCOUNTS = 26;
+    GET_BROWSERS = 5;
+    GET_CLIPBOARD = 6;
+    GET_DISPLAY = 19;
+    GET_PROPERTIES = 7;
+    GET_RINGER_MODE = 27;
+    GET_SD_STATUS = 25;
+    GET_VERSION = 8;
+    GET_WIFI_STATUS = 23;
+    SET_CLIPBOARD = 9;
+    SET_KEYGUARD_STATE = 10;
+    SET_RINGER_MODE = 21;
+    SET_ROTATION = 12;
+    SET_WAKE_LOCK = 11;
+    SET_WIFI_ENABLED = 22;
+    SET_MASTER_MUTE = 28;
+    EVENT_AIRPLANE_MODE = 13;
+    EVENT_BATTERY = 14;
+    EVENT_CONNECTIVITY = 15;
+    EVENT_PHONE_STATE = 16;
+    EVENT_ROTATION = 17;
+    EVENT_BROWSER_PACKAGE = 18;
+}
+
+message Envelope {
+    optional uint32 id = 1;
+    required MessageType type = 2;
+    required bytes message = 3;
+}
+
+// Events
+
+message AirplaneModeEvent {
+    required bool enabled = 1;
+}
+
+message BatteryEvent {
+    required string status = 1;
+    required string health = 2;
+    required string source = 3;
+    required uint32 level = 4;
+    required uint32 scale = 5;
+    required double temp = 6;
+    required double voltage = 7;
+}
+
+message BrowserApp {
+    required string name = 1;
+    required string component = 2;
+    required bool selected = 3;
+    required bool system = 4;
+}
+
+message BrowserPackageEvent {
+    required bool selected = 1;
+    repeated BrowserApp apps = 2;
+}
+
+message ConnectivityEvent {
+    required bool connected = 1;
+    optional string type = 2;
+    optional string subtype = 3;
+    optional bool failover = 4;
+    optional bool roaming = 5;
+}
+
+message PhoneStateEvent {
+    required string state = 1;
+    required bool manual = 2;
+    optional string operator = 3;
+}
+
+message RotationEvent {
+    required int32 rotation = 1;
+}
+
+// Service
+
+message GetVersionRequest {
+}
+
+message GetVersionResponse {
+    required bool success = 1;
+    optional string version = 2;
+}
+
+message SetKeyguardStateRequest {
+    required bool enabled = 1;
+}
+
+message SetKeyguardStateResponse {
+    required bool success = 1;
+}
+
+message SetWakeLockRequest {
+    required bool enabled = 1;
+}
+
+message SetWakeLockResponse {
+    required bool success = 1;
+}
+
+enum ClipboardType {
+    TEXT = 1;
+}
+
+message SetClipboardRequest {
+    required ClipboardType type = 1;
+    optional string text = 2;
+}
+
+message SetClipboardResponse {
+    required bool success = 1;
+}
+
+message GetClipboardRequest {
+    required ClipboardType type = 1;
+}
+
+message GetClipboardResponse {
+    required bool success = 1;
+    optional ClipboardType type = 2;
+    optional string text = 3;
+}
+
+message GetBrowsersRequest {
+}
+
+message GetBrowsersResponse {
+    required bool success = 1;
+    required bool selected = 2;
+    repeated BrowserApp apps = 3;
+}
+
+message GetDisplayRequest {
+    required int32 id = 1;
+}
+
+message GetDisplayResponse {
+    required bool success = 1;
+    optional int32 width = 2;
+    optional int32 height = 3;
+    optional float xdpi = 4;
+    optional float ydpi = 5;
+    optional float fps = 6;
+    optional float density = 7;
+    optional int32 rotation = 8;
+    optional bool secure = 9;
+}
+
+message Property {
+    required string name = 1;
+    required string value = 2;
+}
+
+message GetPropertiesRequest {
+    repeated string properties = 1;
+}
+
+message GetPropertiesResponse {
+    required bool success = 1;
+    repeated Property properties = 2;
+}
+
+message DoIdentifyRequest {
+    required string serial = 1;
+}
+
+message DoIdentifyResponse {
+    required bool success = 1;
+}
+
+message GetAccountsRequest {
+    optional string type = 1;
+}
+
+message GetAccountsResponse {
+    required bool success = 1;
+    repeated string accounts = 2;
+}
+
+message DoAddAccountMenuRequest {
+}
+
+message DoAddAccountMenuResponse {
+    required bool success = 1;
+}
+
+message DoRemoveAccountRequest {
+    required string type = 1;
+    optional string account = 2;
+}
+
+message DoRemoveAccountResponse {
+    required bool success = 1;
+}
+
+enum RingerMode {
+    SILENT = 0;
+    VIBRATE = 1;
+    NORMAL = 2;
+}
+
+message SetRingerModeRequest {
+    required RingerMode mode = 1;
+}
+
+message SetRingerModeResponse {
+    required bool success = 1;
+}
+
+message GetRingerModeRequest {
+}
+
+message GetRingerModeResponse {
+    required bool success = 1;
+    required RingerMode mode = 2;
+}
+
+message SetWifiEnabledRequest {
+    required bool enabled = 1;
+}
+
+message SetWifiEnabledResponse {
+    required bool success = 1;
+}
+
+message GetWifiStatusRequest {
+}
+
+message GetWifiStatusResponse {
+    required bool success = 1;
+    required bool status = 2;
+}
+
+message GetSdStatusRequest {
+}
+
+message GetSdStatusResponse {
+    required bool success = 1;
+    required bool mounted = 2;
+}
+
+message SetMasterMuteRequest {
+    required bool enabled = 1;
+}
+
+message SetMasterMuteResponse {
+    required bool success = 1;
+}
+
+// Agent
+
+enum KeyEvent {
+    DOWN = 0;
+    UP = 1;
+    PRESS = 2;
+}
+
+message KeyEventRequest {
+    required KeyEvent event = 1;
+    required int32 keyCode = 2;
+    optional bool shiftKey = 3;
+    optional bool ctrlKey = 4;
+    optional bool altKey = 5;
+    optional bool metaKey = 6;
+    optional bool symKey = 7;
+    optional bool functionKey = 8;
+    optional bool capsLockKey = 9;
+    optional bool scrollLockKey = 10;
+    optional bool numLockKey = 11;
+}
+
+message DoTypeRequest {
+    required string text = 1;
+}
+
+message SetRotationRequest {
+    required int32 rotation = 1;
+    required bool lock = 2;
+}
+
+message DoWakeRequest {
+}
diff --git a/crowdstf/vendor/minicap/bin/arm64-v8a/minicap b/crowdstf/vendor/minicap/bin/arm64-v8a/minicap
new file mode 100755
index 0000000..204fba7
--- /dev/null
+++ b/crowdstf/vendor/minicap/bin/arm64-v8a/minicap
Binary files differ
diff --git a/crowdstf/vendor/minicap/bin/arm64-v8a/minicap-nopie b/crowdstf/vendor/minicap/bin/arm64-v8a/minicap-nopie
new file mode 100755
index 0000000..204fba7
--- /dev/null
+++ b/crowdstf/vendor/minicap/bin/arm64-v8a/minicap-nopie
Binary files differ
diff --git a/crowdstf/vendor/minicap/bin/armeabi-v7a/minicap b/crowdstf/vendor/minicap/bin/armeabi-v7a/minicap
new file mode 100755
index 0000000..1d3657c
--- /dev/null
+++ b/crowdstf/vendor/minicap/bin/armeabi-v7a/minicap
Binary files differ
diff --git a/crowdstf/vendor/minicap/bin/armeabi-v7a/minicap-nopie b/crowdstf/vendor/minicap/bin/armeabi-v7a/minicap-nopie
new file mode 100755
index 0000000..a7ba78a
--- /dev/null
+++ b/crowdstf/vendor/minicap/bin/armeabi-v7a/minicap-nopie
Binary files differ
diff --git a/crowdstf/vendor/minicap/bin/x86/minicap b/crowdstf/vendor/minicap/bin/x86/minicap
new file mode 100755
index 0000000..d1ee1e3
--- /dev/null
+++ b/crowdstf/vendor/minicap/bin/x86/minicap
Binary files differ
diff --git a/crowdstf/vendor/minicap/bin/x86/minicap-nopie b/crowdstf/vendor/minicap/bin/x86/minicap-nopie
new file mode 100755
index 0000000..1a4ca3b
--- /dev/null
+++ b/crowdstf/vendor/minicap/bin/x86/minicap-nopie
Binary files differ
diff --git a/crowdstf/vendor/minicap/bin/x86_64/minicap b/crowdstf/vendor/minicap/bin/x86_64/minicap
new file mode 100755
index 0000000..a8c68c1
--- /dev/null
+++ b/crowdstf/vendor/minicap/bin/x86_64/minicap
Binary files differ
diff --git a/crowdstf/vendor/minicap/bin/x86_64/minicap-nopie b/crowdstf/vendor/minicap/bin/x86_64/minicap-nopie
new file mode 100755
index 0000000..a8c68c1
--- /dev/null
+++ b/crowdstf/vendor/minicap/bin/x86_64/minicap-nopie
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-10/armeabi-v7a/minicap.so b/crowdstf/vendor/minicap/shared/android-10/armeabi-v7a/minicap.so
new file mode 100755
index 0000000..70a42dc
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-10/armeabi-v7a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-14/armeabi-v7a/minicap.so b/crowdstf/vendor/minicap/shared/android-14/armeabi-v7a/minicap.so
new file mode 100755
index 0000000..d0a2d87
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-14/armeabi-v7a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-14/x86/minicap.so b/crowdstf/vendor/minicap/shared/android-14/x86/minicap.so
new file mode 100755
index 0000000..0a7f739
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-14/x86/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-15/armeabi-v7a/minicap.so b/crowdstf/vendor/minicap/shared/android-15/armeabi-v7a/minicap.so
new file mode 100755
index 0000000..9f2714a
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-15/armeabi-v7a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-15/x86/minicap.so b/crowdstf/vendor/minicap/shared/android-15/x86/minicap.so
new file mode 100755
index 0000000..9ac43b8
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-15/x86/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-16/armeabi-v7a/minicap.so b/crowdstf/vendor/minicap/shared/android-16/armeabi-v7a/minicap.so
new file mode 100755
index 0000000..d7eab0f
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-16/armeabi-v7a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-16/x86/minicap.so b/crowdstf/vendor/minicap/shared/android-16/x86/minicap.so
new file mode 100755
index 0000000..ad8c104
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-16/x86/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-17/armeabi-v7a/minicap.so b/crowdstf/vendor/minicap/shared/android-17/armeabi-v7a/minicap.so
new file mode 100755
index 0000000..37369be
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-17/armeabi-v7a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-17/x86/minicap.so b/crowdstf/vendor/minicap/shared/android-17/x86/minicap.so
new file mode 100755
index 0000000..3079f78
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-17/x86/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-18/armeabi-v7a/minicap.so b/crowdstf/vendor/minicap/shared/android-18/armeabi-v7a/minicap.so
new file mode 100755
index 0000000..c2c8097
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-18/armeabi-v7a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-18/x86/minicap.so b/crowdstf/vendor/minicap/shared/android-18/x86/minicap.so
new file mode 100755
index 0000000..e7d1c3b
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-18/x86/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-19/armeabi-v7a/minicap.so b/crowdstf/vendor/minicap/shared/android-19/armeabi-v7a/minicap.so
new file mode 100755
index 0000000..46dadb8
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-19/armeabi-v7a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-19/x86/minicap.so b/crowdstf/vendor/minicap/shared/android-19/x86/minicap.so
new file mode 100755
index 0000000..6709216
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-19/x86/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-21/arm64-v8a/minicap.so b/crowdstf/vendor/minicap/shared/android-21/arm64-v8a/minicap.so
new file mode 100755
index 0000000..30954af
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-21/arm64-v8a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-21/armeabi-v7a/minicap.so b/crowdstf/vendor/minicap/shared/android-21/armeabi-v7a/minicap.so
new file mode 100755
index 0000000..39f1b62
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-21/armeabi-v7a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-21/x86/minicap.so b/crowdstf/vendor/minicap/shared/android-21/x86/minicap.so
new file mode 100755
index 0000000..3e77d6c
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-21/x86/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-21/x86_64/minicap.so b/crowdstf/vendor/minicap/shared/android-21/x86_64/minicap.so
new file mode 100755
index 0000000..9ccc805
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-21/x86_64/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-22/arm64-v8a/minicap.so b/crowdstf/vendor/minicap/shared/android-22/arm64-v8a/minicap.so
new file mode 100755
index 0000000..9464fa5
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-22/arm64-v8a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-22/armeabi-v7a/minicap.so b/crowdstf/vendor/minicap/shared/android-22/armeabi-v7a/minicap.so
new file mode 100755
index 0000000..899a65c
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-22/armeabi-v7a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-22/x86/minicap.so b/crowdstf/vendor/minicap/shared/android-22/x86/minicap.so
new file mode 100755
index 0000000..26cba09
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-22/x86/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-22/x86_64/minicap.so b/crowdstf/vendor/minicap/shared/android-22/x86_64/minicap.so
new file mode 100755
index 0000000..dda8e34
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-22/x86_64/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-23/arm64-v8a/minicap.so b/crowdstf/vendor/minicap/shared/android-23/arm64-v8a/minicap.so
new file mode 100755
index 0000000..18bbfda
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-23/arm64-v8a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-23/armeabi-v7a/minicap.so b/crowdstf/vendor/minicap/shared/android-23/armeabi-v7a/minicap.so
new file mode 100755
index 0000000..6199581
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-23/armeabi-v7a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-23/x86/minicap.so b/crowdstf/vendor/minicap/shared/android-23/x86/minicap.so
new file mode 100755
index 0000000..d4ed08e
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-23/x86/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-23/x86_64/minicap.so b/crowdstf/vendor/minicap/shared/android-23/x86_64/minicap.so
new file mode 100755
index 0000000..967e4f4
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-23/x86_64/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-9/armeabi-v7a/minicap.so b/crowdstf/vendor/minicap/shared/android-9/armeabi-v7a/minicap.so
new file mode 100755
index 0000000..70a42dc
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-9/armeabi-v7a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-M/arm64-v8a/minicap.so b/crowdstf/vendor/minicap/shared/android-M/arm64-v8a/minicap.so
new file mode 100755
index 0000000..18bbfda
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-M/arm64-v8a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-M/armeabi-v7a/minicap.so b/crowdstf/vendor/minicap/shared/android-M/armeabi-v7a/minicap.so
new file mode 100755
index 0000000..6199581
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-M/armeabi-v7a/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-M/x86/minicap.so b/crowdstf/vendor/minicap/shared/android-M/x86/minicap.so
new file mode 100755
index 0000000..d4ed08e
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-M/x86/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minicap/shared/android-M/x86_64/minicap.so b/crowdstf/vendor/minicap/shared/android-M/x86_64/minicap.so
new file mode 100755
index 0000000..967e4f4
--- /dev/null
+++ b/crowdstf/vendor/minicap/shared/android-M/x86_64/minicap.so
Binary files differ
diff --git a/crowdstf/vendor/minirev/arm64-v8a/minirev b/crowdstf/vendor/minirev/arm64-v8a/minirev
new file mode 100755
index 0000000..ce4ce82
--- /dev/null
+++ b/crowdstf/vendor/minirev/arm64-v8a/minirev
Binary files differ
diff --git a/crowdstf/vendor/minirev/arm64-v8a/minirev-nopie b/crowdstf/vendor/minirev/arm64-v8a/minirev-nopie
new file mode 100755
index 0000000..ce4ce82
--- /dev/null
+++ b/crowdstf/vendor/minirev/arm64-v8a/minirev-nopie
Binary files differ
diff --git a/crowdstf/vendor/minirev/armeabi-v7a/minirev b/crowdstf/vendor/minirev/armeabi-v7a/minirev
new file mode 100755
index 0000000..0cf0fcf
--- /dev/null
+++ b/crowdstf/vendor/minirev/armeabi-v7a/minirev
Binary files differ
diff --git a/crowdstf/vendor/minirev/armeabi-v7a/minirev-nopie b/crowdstf/vendor/minirev/armeabi-v7a/minirev-nopie
new file mode 100755
index 0000000..f88cfe6
--- /dev/null
+++ b/crowdstf/vendor/minirev/armeabi-v7a/minirev-nopie
Binary files differ
diff --git a/crowdstf/vendor/minirev/armeabi/minirev b/crowdstf/vendor/minirev/armeabi/minirev
new file mode 100755
index 0000000..adae076
--- /dev/null
+++ b/crowdstf/vendor/minirev/armeabi/minirev
Binary files differ
diff --git a/crowdstf/vendor/minirev/armeabi/minirev-nopie b/crowdstf/vendor/minirev/armeabi/minirev-nopie
new file mode 100755
index 0000000..a079eac
--- /dev/null
+++ b/crowdstf/vendor/minirev/armeabi/minirev-nopie
Binary files differ
diff --git a/crowdstf/vendor/minirev/mips/minirev b/crowdstf/vendor/minirev/mips/minirev
new file mode 100755
index 0000000..57e5e3a
--- /dev/null
+++ b/crowdstf/vendor/minirev/mips/minirev
Binary files differ
diff --git a/crowdstf/vendor/minirev/mips/minirev-nopie b/crowdstf/vendor/minirev/mips/minirev-nopie
new file mode 100755
index 0000000..ac36a9b
--- /dev/null
+++ b/crowdstf/vendor/minirev/mips/minirev-nopie
Binary files differ
diff --git a/crowdstf/vendor/minirev/mips64/minirev b/crowdstf/vendor/minirev/mips64/minirev
new file mode 100755
index 0000000..6346ef9
--- /dev/null
+++ b/crowdstf/vendor/minirev/mips64/minirev
Binary files differ
diff --git a/crowdstf/vendor/minirev/mips64/minirev-nopie b/crowdstf/vendor/minirev/mips64/minirev-nopie
new file mode 100755
index 0000000..6346ef9
--- /dev/null
+++ b/crowdstf/vendor/minirev/mips64/minirev-nopie
Binary files differ
diff --git a/crowdstf/vendor/minirev/x86/minirev b/crowdstf/vendor/minirev/x86/minirev
new file mode 100755
index 0000000..b7cd56d
--- /dev/null
+++ b/crowdstf/vendor/minirev/x86/minirev
Binary files differ
diff --git a/crowdstf/vendor/minirev/x86/minirev-nopie b/crowdstf/vendor/minirev/x86/minirev-nopie
new file mode 100755
index 0000000..3d15ea7
--- /dev/null
+++ b/crowdstf/vendor/minirev/x86/minirev-nopie
Binary files differ
diff --git a/crowdstf/vendor/minirev/x86_64/minirev b/crowdstf/vendor/minirev/x86_64/minirev
new file mode 100755
index 0000000..f66324b
--- /dev/null
+++ b/crowdstf/vendor/minirev/x86_64/minirev
Binary files differ
diff --git a/crowdstf/vendor/minirev/x86_64/minirev-nopie b/crowdstf/vendor/minirev/x86_64/minirev-nopie
new file mode 100755
index 0000000..f66324b
--- /dev/null
+++ b/crowdstf/vendor/minirev/x86_64/minirev-nopie
Binary files differ
diff --git a/crowdstf/vendor/minitouch/arm64-v8a/minitouch b/crowdstf/vendor/minitouch/arm64-v8a/minitouch
new file mode 100755
index 0000000..a2891c3
--- /dev/null
+++ b/crowdstf/vendor/minitouch/arm64-v8a/minitouch
Binary files differ
diff --git a/crowdstf/vendor/minitouch/arm64-v8a/minitouch-nopie b/crowdstf/vendor/minitouch/arm64-v8a/minitouch-nopie
new file mode 100755
index 0000000..a2891c3
--- /dev/null
+++ b/crowdstf/vendor/minitouch/arm64-v8a/minitouch-nopie
Binary files differ
diff --git a/crowdstf/vendor/minitouch/armeabi-v7a/minitouch b/crowdstf/vendor/minitouch/armeabi-v7a/minitouch
new file mode 100755
index 0000000..948d566
--- /dev/null
+++ b/crowdstf/vendor/minitouch/armeabi-v7a/minitouch
Binary files differ
diff --git a/crowdstf/vendor/minitouch/armeabi-v7a/minitouch-nopie b/crowdstf/vendor/minitouch/armeabi-v7a/minitouch-nopie
new file mode 100755
index 0000000..0bdad6a
--- /dev/null
+++ b/crowdstf/vendor/minitouch/armeabi-v7a/minitouch-nopie
Binary files differ
diff --git a/crowdstf/vendor/minitouch/armeabi/minitouch b/crowdstf/vendor/minitouch/armeabi/minitouch
new file mode 100755
index 0000000..249dfe7
--- /dev/null
+++ b/crowdstf/vendor/minitouch/armeabi/minitouch
Binary files differ
diff --git a/crowdstf/vendor/minitouch/armeabi/minitouch-nopie b/crowdstf/vendor/minitouch/armeabi/minitouch-nopie
new file mode 100755
index 0000000..96ccac8
--- /dev/null
+++ b/crowdstf/vendor/minitouch/armeabi/minitouch-nopie
Binary files differ
diff --git a/crowdstf/vendor/minitouch/mips/minitouch b/crowdstf/vendor/minitouch/mips/minitouch
new file mode 100755
index 0000000..cc12b5b
--- /dev/null
+++ b/crowdstf/vendor/minitouch/mips/minitouch
Binary files differ
diff --git a/crowdstf/vendor/minitouch/mips/minitouch-nopie b/crowdstf/vendor/minitouch/mips/minitouch-nopie
new file mode 100755
index 0000000..1afb58c
--- /dev/null
+++ b/crowdstf/vendor/minitouch/mips/minitouch-nopie
Binary files differ
diff --git a/crowdstf/vendor/minitouch/mips64/minitouch b/crowdstf/vendor/minitouch/mips64/minitouch
new file mode 100755
index 0000000..2d581f3
--- /dev/null
+++ b/crowdstf/vendor/minitouch/mips64/minitouch
Binary files differ
diff --git a/crowdstf/vendor/minitouch/mips64/minitouch-nopie b/crowdstf/vendor/minitouch/mips64/minitouch-nopie
new file mode 100755
index 0000000..2d581f3
--- /dev/null
+++ b/crowdstf/vendor/minitouch/mips64/minitouch-nopie
Binary files differ
diff --git a/crowdstf/vendor/minitouch/x86/minitouch b/crowdstf/vendor/minitouch/x86/minitouch
new file mode 100755
index 0000000..9ae633b
--- /dev/null
+++ b/crowdstf/vendor/minitouch/x86/minitouch
Binary files differ
diff --git a/crowdstf/vendor/minitouch/x86/minitouch-nopie b/crowdstf/vendor/minitouch/x86/minitouch-nopie
new file mode 100755
index 0000000..b7bd606
--- /dev/null
+++ b/crowdstf/vendor/minitouch/x86/minitouch-nopie
Binary files differ
diff --git a/crowdstf/vendor/minitouch/x86_64/minitouch b/crowdstf/vendor/minitouch/x86_64/minitouch
new file mode 100755
index 0000000..b13bdaf
--- /dev/null
+++ b/crowdstf/vendor/minitouch/x86_64/minitouch
Binary files differ
diff --git a/crowdstf/vendor/minitouch/x86_64/minitouch-nopie b/crowdstf/vendor/minitouch/x86_64/minitouch-nopie
new file mode 100755
index 0000000..b13bdaf
--- /dev/null
+++ b/crowdstf/vendor/minitouch/x86_64/minitouch-nopie
Binary files differ
diff --git a/crowdstf/webpack.config.js b/crowdstf/webpack.config.js
new file mode 100644
index 0000000..6e9ae34
--- /dev/null
+++ b/crowdstf/webpack.config.js
@@ -0,0 +1,118 @@
+var _ = require('lodash')
+
+var webpack = require('webpack')
+var CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin')
+var ProgressPlugin = require('webpack/lib/ProgressPlugin')
+
+var pathutil = require('./lib/util/pathutil')
+var log = require('./lib/util/logger').createLogger('webpack:config')
+
+module.exports = {
+  webpack: {
+    context: __dirname
+    , cache: true
+    , entry: {
+      app: pathutil.resource('app/app.js')
+      , authldap: pathutil.resource('auth/ldap/scripts/entry.js')
+      , authmock: pathutil.resource('auth/mock/scripts/entry.js')
+    }
+    , output: {
+      path: pathutil.resource('build')
+      , publicPath: '/static/app/build/'
+      , filename: 'entry/[name].entry.js'
+      , chunkFilename: '[id].[hash].chunk.js'
+    }
+    , stats: {
+      colors: true
+    }
+    , resolve: {
+      root: [
+        pathutil.resource('app/components')
+      ]
+      , modulesDirectories: [
+        'web_modules'
+        , 'bower_components'
+        , 'node_modules'
+      ]
+      , alias: {
+        'angular-bootstrap': 'angular-bootstrap/ui-bootstrap-tpls'
+        , localforage: 'localforage/dist/localforage.js'
+        , 'socket.io': 'socket.io-client'
+        , stats: 'stats.js/src/Stats.js'
+        , 'underscore.string': 'underscore.string/index'
+      }
+    }
+    , module: {
+      loaders: [
+        {test: /\.css$/, loader: 'style!css'}
+        , {test: /\.scss$/, loader: 'style!css!sass'}
+        , {test: /\.less$/, loader: 'style!css!less'}
+        , {test: /\.json$/, loader: 'json'}
+        , {test: /\.jpg$/, loader: 'url?limit=1000&mimetype=image/jpeg'}
+        , {test: /\.png$/, loader: 'url?limit=1000&mimetype=image/png'}
+        , {test: /\.gif$/, loader: 'url?limit=1000&mimetype=image/gif'}
+        , {test: /\.svg/, loader: 'url?limit=1&mimetype=image/svg+xml'}
+        , {test: /\.woff/, loader: 'url?limit=1&mimetype=application/font-woff'}
+        , {test: /\.otf/, loader: 'url?limit=1&mimetype=application/font-woff'}
+        , {test: /\.ttf/, loader: 'url?limit=1&mimetype=application/font-woff'}
+        , {test: /\.eot/, loader: 'url?limit=1&mimetype=vnd.ms-fontobject'}
+        , {test: /\.jade$/, loader: 'template-html-loader'}
+        , {test: /\.html$/, loader: 'html-loader'}
+        , {test: /angular\.js$/, loader: 'exports?angular'}
+        , {test: /angular-cookies\.js$/, loader: 'imports?angular=angular'}
+        , {test: /angular-route\.js$/, loader: 'imports?angular=angular'}
+        , {test: /angular-touch\.js$/, loader: 'imports?angular=angular'}
+        , {test: /angular-animate\.js$/, loader: 'imports?angular=angular'}
+        , {test: /angular-growl\.js$/, loader: 'imports?angular=angular'}
+        , {test: /uuid\.js$/, loader: 'imports?require=>undefined'}
+        , {test: /dialogs\.js$/, loader: 'script'}
+      ]
+      // TODO: enable when its sane
+      // preLoaders: [
+      //  {
+      //    test: /\.js$/,
+      //    exclude: /node_modules|bower_components/,
+      //    loader: 'eslint-loader'
+      //  }
+      // ],
+      , noParse: [
+        // pathutil.resource('bower_components')
+      ]
+    }
+    , plugins: [
+      new webpack.ResolverPlugin(
+        new webpack.ResolverPlugin.DirectoryDescriptionFilePlugin(
+          'bower.json'
+          , ['main']
+        )
+      )
+      , new webpack.ResolverPlugin(
+        new webpack.ResolverPlugin.DirectoryDescriptionFilePlugin(
+          '.bower.json'
+          , ['main']
+        )
+      )
+      , new CommonsChunkPlugin('entry/commons.entry.js')
+      , new ProgressPlugin(_.throttle(
+        function(progress, message) {
+          var msg
+          if (message) {
+            msg = message
+          }
+          else {
+            msg = progress >= 1 ? 'complete' : 'unknown'
+          }
+          log.info('Build progress %d%% (%s)', Math.floor(progress * 100), msg)
+        }
+        , 1000
+      ))
+    ]
+  }
+  , webpackServer: {
+    debug: true
+    , devtool: 'eval'
+    , stats: {
+      colors: true
+    }
+  }
+}