From 1e7e66670da248fa380ba55e93518d6f0ab2c5f9 Mon Sep 17 00:00:00 2001 From: Simon Chan Date: Fri, 31 Jan 2020 16:39:49 +0800 Subject: [PATCH] feat: init commit --- .gitignore | 2 + README.md | 43 +++++ package-lock.json | 404 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 35 ++++ rollup.config.js | 22 +++ src/packet.ts | 70 ++++++++ src/stream.ts | 114 +++++++++++++ src/test.ts | 22 +++ src/webadb.ts | 244 ++++++++++++++++++++++++++++ test.html | 13 ++ tsconfig.json | 66 ++++++++ 11 files changed, 1035 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 rollup.config.js create mode 100644 src/packet.ts create mode 100644 src/stream.ts create mode 100644 src/test.ts create mode 100644 src/webadb.ts create mode 100644 test.html create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..491fc359 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +lib diff --git a/README.md b/README.md new file mode 100644 index 00000000..0fbaa424 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Yet Another WebADB + +Connect to your Android phones from everything that can run (supported) web browser, including PC, mac, and even another Android phone. + +Inspired by [webadb.js](https://github.com/webadb/webadb.js), but completely rewritten. + +## How does it work + +Currently only the interactive shell (`adb shell`) is implemented, but I think it's the most difficult but interesting part. + +WebUSB API gives JavaScript running in supported web browsers access to USB devices, including Android phones. + +ADB uses a fairly simple protocol to commnunicate, so it's pretty easy to reimplement with JavaScript. + +`adb shell`, the interactive shell, uses plain PTY protocol, and [xterm.js](https://github.com/xtermjs/xterm.js/) can handle it very well. + +## Build + +```shell +npm run build +``` + +## Run + +```shell +npm start +``` + +And navigate to `http://localhost:8080/test.html`. + +WebUSB API requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) (basicly means HTTPS). + +Chrome will treat `localhost` as one, but if you want to access test server running on another machine, you can configure you Chrome as following: + +1. Open `chrome://flags/#unsafely-treat-insecure-origin-as-secure` +2. Add the protocol and domain part of your url (e.g. `http://192.168.0.100:8080`) to the input box +3. Choose `Enable` from the dropdown menu +4. Restart your browser + +## Useful links + +* [ADB protocol overview](https://github.com/aosp-mirror/platform_system_core/blob/master/adb/OVERVIEW.TXT) +* [ADB commands](https://github.com/aosp-mirror/platform_system_core/blob/d7c1bc73dc5b4e43b8288d43052a8b8890c4bf5a/adb/SERVICES.TXT#L145) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..1a03df72 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,404 @@ +{ + "name": "webadb.js", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@rollup/plugin-commonjs": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-11.0.1.tgz", + "integrity": "sha512-SaVUoaLDg3KnIXC5IBNIspr1APTYDzk05VaYcI6qz+0XX3ZlSCwAkfAhNSOxfd5GAdcm/63Noi4TowOY9MpcDg==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.0.0", + "estree-walker": "^0.6.1", + "is-reference": "^1.1.2", + "magic-string": "^0.25.2", + "resolve": "^1.11.0" + }, + "dependencies": { + "estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true + } + } + }, + "@rollup/plugin-node-resolve": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.0.0.tgz", + "integrity": "sha512-+vOx2+WMBMFotYKM3yYeDGZxIvcQ7yO4g+SuKDFsjKaq8Lw3EPgfB6qNlp8Z/3ceDCEhHvC9/b+PgBGwDQGbzQ==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.0.0", + "@types/resolve": "0.0.8", + "builtin-modules": "^3.1.0", + "is-module": "^1.0.0", + "resolve": "^1.11.1" + } + }, + "@rollup/plugin-typescript": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-3.0.0.tgz", + "integrity": "sha512-O6915Ril3+Q0B4P898PULAcPFZfPuatEB/4nox7bnK48ekGrmamMYhMB5tOqWjihEWrw4oz/NL+c+/kS3Fk95g==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.0.1", + "resolve": "^1.14.1" + } + }, + "@rollup/pluginutils": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.0.6.tgz", + "integrity": "sha512-Nb6U7sg11v8D+E4mxRxwT+UumUL7MSnwI8V1SJB3THyW2MOGD/Q6GyxLtpnjrbT3zTRPSozzDMyVZwemgldO3w==", + "dev": true, + "requires": { + "estree-walker": "^1.0.1" + } + }, + "@types/estree": { + "version": "0.0.42", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.42.tgz", + "integrity": "sha512-K1DPVvnBCPxzD+G51/cxVIoc2X8uUVl1zpJeE6iKcgHMj4+tbat5Xu4TjV7v2QSDbIeAfLi2hIk+u2+s0MlpUQ==", + "dev": true + }, + "@types/node": { + "version": "13.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.5.2.tgz", + "integrity": "sha512-Fr6a47c84PRLfd7M7u3/hEknyUdQrrBA6VoPmkze0tcflhU5UnpWEX2kn12ktA/lb+MNHSqFlSiPHIHsaErTPA==", + "dev": true + }, + "@types/resolve": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", + "integrity": "sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/w3c-web-usb": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.4.tgz", + "integrity": "sha512-aaOB3EL5WCWBBOYX7W1MKuzspOM9ZJI9s3iziRVypr1N+QyvIgXzCM4lm1iiOQ1VFzZioUPX9bsa23myCbKK4A==", + "dev": true + }, + "@yume-chan/async-operation-manager": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@yume-chan/async-operation-manager/-/async-operation-manager-1.0.1.tgz", + "integrity": "sha512-ju38Oa9HKwpr5WgpuASCWQFak/02XuF1S8dET7n2eKYMmdxnMXIJHM+eLWqpwFouqkHu7mkeeJavvBWXGz257Q==" + }, + "acorn": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz", + "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==", + "dev": true + }, + "async": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", + "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", + "dev": true, + "requires": { + "lodash": "^4.17.14" + } + }, + "basic-auth": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-1.1.0.tgz", + "integrity": "sha1-RSIe5Cn37h5QNb4/UVM/HN/SmIQ=", + "dev": true + }, + "builtin-modules": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.1.0.tgz", + "integrity": "sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw==", + "dev": true + }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true + }, + "corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha1-jtolLsqrWEDc2XXOuQ2TcMgZ/4c=", + "dev": true + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ecstatic": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/ecstatic/-/ecstatic-3.3.2.tgz", + "integrity": "sha512-fLf9l1hnwrHI2xn9mEDT7KIi22UDqA2jaCwyCbSUJh9a1V+LEUSL/JO/6TIz/QyuBURWUHrFL5Kg2TtO1bkkog==", + "dev": true, + "requires": { + "he": "^1.1.1", + "mime": "^1.6.0", + "minimist": "^1.1.0", + "url-join": "^2.0.5" + } + }, + "estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true + }, + "eventemitter3": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz", + "integrity": "sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==", + "dev": true + }, + "follow-redirects": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.10.0.tgz", + "integrity": "sha512-4eyLK6s6lH32nOvLLwlIOnr9zrL8Sm+OvW4pVTJNoXeGzYIkHVf+pADQi+OJ0E67hiuSLezPVPyBcIZO50TmmQ==", + "dev": true, + "requires": { + "debug": "^3.0.0" + } + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "http-proxy": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.0.tgz", + "integrity": "sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==", + "dev": true, + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, + "http-server": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-0.12.1.tgz", + "integrity": "sha512-T0jB+7J7GJ2Vo+a4/T7P7SbQ3x2GPDnqRqQXdfEuPuUOmES/9NBxPnDm7dh1HGEeUWqUmLUNtGV63ZC5Uy3tGA==", + "dev": true, + "requires": { + "basic-auth": "^1.0.3", + "colors": "^1.3.3", + "corser": "^2.0.1", + "ecstatic": "^3.3.2", + "http-proxy": "^1.17.0", + "opener": "^1.5.1", + "optimist": "~0.6.1", + "portfinder": "^1.0.20", + "secure-compare": "3.0.1", + "union": "~0.5.0" + } + }, + "is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", + "dev": true + }, + "is-reference": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.1.4.tgz", + "integrity": "sha512-uJA/CDPO3Tao3GTrxYn6AwkM4nUPJiGGYu5+cB8qbC7WGFlrKZbiRo7SFKxUAEpFUfiHofWCXBUNhvYJMh+6zw==", + "dev": true, + "requires": { + "@types/estree": "0.0.39" + }, + "dependencies": { + "@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + } + } + }, + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", + "dev": true + }, + "magic-string": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.6.tgz", + "integrity": "sha512-3a5LOMSGoCTH5rbqobC2HuDNRtE2glHZ8J7pK+QZYppyWA36yuNpsX994rIY2nCuyP7CZYy7lQq/X2jygiZ89g==", + "dev": true, + "requires": { + "sourcemap-codec": "^1.4.4" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + } + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "opener": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.1.tgz", + "integrity": "sha512-goYSy5c2UXE4Ra1xixabeVh1guIX/ZV/YokJksb6q2lubWu6UbvPQ20p542/sFIll1nl8JnCyK9oBaOcCWXwvA==", + "dev": true + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", + "dev": true + } + } + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "portfinder": { + "version": "1.0.25", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.25.tgz", + "integrity": "sha512-6ElJnHBbxVA1XSLgBp7G1FiCkQdlqGzuF7DswL5tcea+E8UpuvPU7beVAjjRwCioTS9ZluNbu+ZyRvgTsmqEBg==", + "dev": true, + "requires": { + "async": "^2.6.2", + "debug": "^3.1.1", + "mkdirp": "^0.5.1" + } + }, + "qs": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.1.tgz", + "integrity": "sha512-Cxm7/SS/y/Z3MHWSxXb8lIFqgqBowP5JMlTUFyJN88y0SGQhVmZnqFK/PeuMX9LzUyWsqqhNxIyg0jlzq946yA==", + "dev": true + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", + "dev": true + }, + "resolve": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.0.tgz", + "integrity": "sha512-+hTmAldEGE80U2wJJDC1lebb5jWqvTYAfm3YZ1ckk1gBr0MnCqUKlwK1e+anaFljIl+F5tR5IoZcm4ZDA1zMQw==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "rollup": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-1.30.1.tgz", + "integrity": "sha512-Uus8mwQXwaO+ZVoNwBcXKhT0AvycFCBW/W8VZtkpVGsotRllWk9oldfCjqWmTnFRI0y7x6BnEqSqc65N+/YdBw==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/node": "*", + "acorn": "^7.1.0" + } + }, + "secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha1-8aAymzCLIh+uN7mXTz1XjQypmeM=", + "dev": true + }, + "sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "dev": true + }, + "tslib": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", + "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" + }, + "typescript": { + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.7.5.tgz", + "integrity": "sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw==", + "dev": true + }, + "union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "requires": { + "qs": "^6.4.0" + } + }, + "url-join": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-2.0.5.tgz", + "integrity": "sha1-WvIvGMBSoACkjXuCxenC4v7tpyg=", + "dev": true + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", + "dev": true + }, + "xterm": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.3.0.tgz", + "integrity": "sha512-6dnrC4nxgnRKQzIWwC5HA0mnT9/rpDPZflUIr24gdcdSMTKM1QQcor4qQ/xz4Zerz6AIL/CuuBPypFfzsB63dQ==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..a49c2107 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "webadb.js", + "version": "1.0.0", + "description": "``` let webusb = await Adb.open(\"WebUSB\"); let adb = await webusb.connectAdb(\"host::\"); let shell = await adb.shell(\"uname -a\"); console.log(await shell.receive()); ```", + "main": "webadb.js", + "scripts": { + "start": "hs", + "build": "rollup -c" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/webadb/webadb.js.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/webadb/webadb.js/issues" + }, + "homepage": "https://github.com/webadb/webadb.js#readme", + "devDependencies": { + "@rollup/plugin-commonjs": "^11.0.1", + "@rollup/plugin-node-resolve": "^7.0.0", + "@rollup/plugin-typescript": "^3.0.0", + "@types/w3c-web-usb": "^1.0.4", + "http-server": "^0.12.1", + "rollup": "^1.30.1", + "typescript": "^3.7.5" + }, + "dependencies": { + "@yume-chan/async-operation-manager": "^1.0.1", + "tslib": "^1.10.0", + "xterm": "^4.3.0" + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 00000000..c9f5ea00 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,22 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import typescript from '@rollup/plugin-typescript'; + +export default { + input: 'src/test.ts', + plugins: [ + resolve(), + typescript(), + commonjs({ + extensions: ['.js', '.ts'], + namedExports: { + 'xterm': ['Terminal'], + }, + }), + ], + output: { + dir: 'lib', + format: 'iife', + sourcemap: true, + }, +}; diff --git a/src/packet.ts b/src/packet.ts new file mode 100644 index 00000000..ac919947 --- /dev/null +++ b/src/packet.ts @@ -0,0 +1,70 @@ +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export class AdbPacket { + public static parse(buffer: ArrayBuffer): AdbPacket { + const command = textDecoder.decode(buffer.slice(0, 4)); + + const view = new DataView(buffer); + const arg0 = view.getUint32(4, true); + const arg1 = view.getUint32(8, true); + const payloadLength = view.getUint32(12, true); + + const packet = new AdbPacket(command, arg0, arg1, undefined); + packet._payloadLength = payloadLength; + return packet; + } + + public command: string; + + public arg0: number; + + public arg1: number; + + private _payloadLength!: number; + public get payloadLength(): number { return this._payloadLength; } + + private _payload: ArrayBuffer | undefined; + public get payload(): ArrayBuffer | undefined { return this._payload; } + public set payload(value: ArrayBuffer | undefined) { + if (value !== undefined) { + this._payloadLength = value.byteLength; + this._payload = value; + } else { + this._payloadLength = 0; + this._payload = undefined; + } + } + + public constructor(command: string, arg0: number, arg1: number, payload?: string | ArrayBuffer) { + if (command.length !== 4) { + throw new TypeError('length of command must be 4'); + } + + this.command = command; + this.arg0 = arg0; + this.arg1 = arg1; + + if (typeof payload === "string") { + this.payload = textEncoder.encode(payload + '\0').buffer; + } else { + this.payload = payload; + } + } + + public toBuffer(): ArrayBuffer { + const buffer = new ArrayBuffer(24); + const array = new Uint8Array(buffer); + const view = new DataView(buffer); + + textEncoder.encodeInto(this.command, array); + + view.setUint32(4, this.arg0, true); + view.setUint32(8, this.arg1, true); + view.setUint32(12, this.payloadLength, true); + view.setUint32(16, /* checksum */ 0, true); + view.setUint32(20, /* magic */ view.getUint32(0, true) ^ 0xFFFFFFFF, true); + + return buffer; + } +} diff --git a/src/stream.ts b/src/stream.ts new file mode 100644 index 00000000..d45909d8 --- /dev/null +++ b/src/stream.ts @@ -0,0 +1,114 @@ +import { PromiseResolver } from '@yume-chan/async-operation-manager'; +import { WebAdb } from './webadb'; +import { IEvent } from 'xterm'; + +interface IListener { + (e: T): void; +} + +export interface IEventEmitter { + event: IEvent; + fire(data: T): void; + dispose(): void; +} + +export class EventEmitter implements IEventEmitter { + private _listeners: IListener[] = []; + private _event?: IEvent; + private _disposed: boolean = false; + + public get event(): IEvent { + if (!this._event) { + this._event = (listener: (e: T) => any) => { + this._listeners.push(listener); + const disposable = { + dispose: () => { + if (!this._disposed) { + for (let i = 0; i < this._listeners.length; i++) { + if (this._listeners[i] === listener) { + this._listeners.splice(i, 1); + return; + } + } + } + } + }; + return disposable; + }; + } + return this._event; + } + + public fire(data: T): void { + const queue: IListener[] = []; + for (let i = 0; i < this._listeners.length; i++) { + queue.push(this._listeners[i]); + } + for (let i = 0; i < queue.length; i++) { + queue[i].call(undefined, data); + } + } + + public dispose(): void { + if (this._listeners) { + this._listeners.length = 0; + } + this._disposed = true; + } +} + +class AutoResetEvent { + private _list: PromiseResolver[] = []; + + private _blocking: boolean = false; + + public wait(): Promise { + if (!this._blocking) { + this._blocking = true; + + if (this._list.length === 0) { + return Promise.resolve(); + } + } + + const resolver = new PromiseResolver(); + this._list.push(resolver); + return resolver.promise; + } + + public notify() { + if (this._list.length !== 0) { + this._list.pop()!.resolve(); + } else { + this._blocking = false; + } + } +} + +export class AdbStream { + private _writeMutex = new AutoResetEvent(); + + public onDataEvent: EventEmitter = new EventEmitter(); + public get onData(): IEvent { return this.onDataEvent.event; } + + private _adb: WebAdb; + + public localId: number; + + public remoteId: number; + + public constructor(adb: WebAdb, localId: number, remoteId: number) { + this._adb = adb; + this.localId = localId; + this.remoteId = remoteId; + } + + public async write(data: ArrayBuffer): Promise { + await this._writeMutex.wait(); + await this._adb.sendMessage('WRTE', this.localId, this.remoteId, data); + } + + public ack(): void { + this._writeMutex.notify(); + } +} diff --git a/src/test.ts b/src/test.ts new file mode 100644 index 00000000..df421b7c --- /dev/null +++ b/src/test.ts @@ -0,0 +1,22 @@ +import { Terminal } from 'xterm'; +import { WebAdb } from './webadb.js'; + +document.getElementById('start')!.onclick = async () => { + const adb = await WebAdb.open(); + await adb.connect(); + + const textEncoder = new TextEncoder(); + + const shell = await adb.shell(); + + const terminal = new Terminal({ + }); + terminal.open(document.getElementById('terminal')!); + terminal.onData(data => { + const { buffer } = textEncoder.encode(data); + shell.write(buffer); + }); + shell.onData(data => { + terminal.write(new Uint8Array(data)); + }); +}; diff --git a/src/webadb.ts b/src/webadb.ts new file mode 100644 index 00000000..ca605448 --- /dev/null +++ b/src/webadb.ts @@ -0,0 +1,244 @@ +import { AdbPacket } from './packet'; +import { AdbStream } from './stream'; +import AsyncOperationManager from '@yume-chan/async-operation-manager'; + +export const AdbDeviceFilter: USBDeviceFilter = { classCode: 0xFF, subclassCode: 0x42, protocolCode: 1 }; + +export class WebAdb { + public static async open() { + const device = await navigator.usb.requestDevice({ filters: [] }); + await device.open(); + + const webadb = new WebAdb(device); + await webadb.initialize(); + return webadb; + } + + private _device: USBDevice; + + private _inEndpointNumber!: number; + private _outEndpointNumber!: number; + + private _alive = true; + private _looping = false; + + private _streamInitializer = new AsyncOperationManager(); + private _streams = new Map(); + + public constructor(device: USBDevice) { + this._device = device; + } + + private async initialize(): Promise { + for (const configuration of this._device.configurations) { + for (const interface_ of configuration.interfaces) { + for (const alternate of interface_.alternates) { + if (alternate.interfaceProtocol === AdbDeviceFilter.protocolCode && + alternate.interfaceClass === AdbDeviceFilter.classCode && + alternate.interfaceSubclass === AdbDeviceFilter.subclassCode) { + if (this._device.configuration?.configurationValue !== configuration.configurationValue) { + await this._device.selectConfiguration(configuration.configurationValue); + } + + if (!interface_.claimed) { + await this._device.claimInterface(interface_.interfaceNumber); + } + + if (interface_.alternate.alternateSetting !== alternate.alternateSetting) { + await this._device.selectAlternateInterface(interface_.interfaceNumber, alternate.alternateSetting); + } + + this._inEndpointNumber = this.getEndpointNumber(alternate.endpoints, 'in'); + this._outEndpointNumber = this.getEndpointNumber(alternate.endpoints, 'out'); + return; + } + } + } + } + } + + private async receiveLoop(): Promise { + if (this._looping) { + return; + } + + this._looping = true; + + while (this._alive) { + const response = await this.receiveMessage(); + switch (response.command) { + case 'OKAY': + this._streamInitializer.resolve(response.arg1, response.arg0); + this._streams.get(response.arg1)?.ack(); + break; + case 'CLSE': + if (response.arg0 === 0) { + this._streamInitializer.reject(response.arg1, new Error('open failed')); + } else { + this._streams.delete(response.arg1); + } + break; + case 'WRTE': + this._streams.get(response.arg1)?.onDataEvent.fire(response.payload!); + await this.sendMessage('OKAY', response.arg1, response.arg0); + break; + default: + this._device.close(); + throw new Error('unknown command'); + } + } + } + + public async connect() { + const version = 0x01000001; + + await this.sendMessage('CNXN', version, 256 * 1024, 'host::'); + + while (true) { + const response = await this.receiveMessage(); + switch (response.command) { + case 'CNXN': + if (response.arg0 !== version) { + this._device.close(); + throw new Error('version mismatch'); + } + return; + case 'AUTH': + if (response.arg0 !== 1) { + this._device.close(); + throw new Error('unknwon auth type'); + } + const key = await this.generateKey(); + const publicKey = await crypto.subtle.exportKey('spki', key.publicKey); + await this.sendMessage('AUTH', 3, 0, this.toBase64(publicKey)); + break; + default: + this._device.close(); + throw new Error('unknown command'); + } + } + } + + public async shell(): Promise { + const { id: localId, promise: initializer } = this._streamInitializer.add(); + await this.sendMessage('OPEN', localId, 0, 'shell:'); + this.receiveLoop(); + + const remoteId = await initializer; + const stream = new AdbStream(this, localId, remoteId); + this._streams.set(localId, stream); + return stream; + } + + public async sendMessage(command: string, arg0: number, arg1: number, payload?: string | ArrayBuffer): Promise { + const packet = new AdbPacket(command, arg0, arg1, payload); + console.log('send', command, arg0, arg1, payload); + await this._device.transferOut(this._outEndpointNumber, packet.toBuffer()); + if (packet.payloadLength !== 0) { + await this._device.transferOut(this._outEndpointNumber, packet.payload!); + } + } + + public async receiveMessage() { + console.log('receiving'); + + const header = await this.receiveData(24); + const packet = AdbPacket.parse(header); + + if (packet.payloadLength !== 0) { + packet.payload = await this.receiveData(packet.payloadLength); + } + + console.log('receive', packet.command, packet.arg0, packet.arg1, packet.payload); + return packet; + } + + private getEndpointNumber(endpoints: USBEndpoint[], direction: USBDirection, type: USBEndpointType = 'bulk') { + for (const endpoint of endpoints) { + if (endpoint.direction === direction && + endpoint.type === type) { + return endpoint.endpointNumber; + } + } + + throw new Error('Cannot find endpoint'); + } + + private async receiveData(length: number): Promise { + const result = await this._device.transferIn(this._inEndpointNumber, length); + if (result.status === 'stall') { + console.log('clear halt in'); + await this._device.clearHalt('in', this._inEndpointNumber); + } + return result.data!.buffer; + } + + private async generateKey() { + return await crypto.subtle.generateKey({ + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { name: 'SHA-1' }, + }, false, ['sign', 'verify']); + } + + private toBase64(arraybuffer: ArrayBuffer) { + let characterSet = []; + const pairs = [ + ['A', 'Z'], + ['a', 'z'], + ['0', '9'], + ].map(pair => pair.map(character => character.charCodeAt(0))); + + for (const [begin, end] of pairs) { + for (let i = begin; i <= end; i += 1) { + characterSet.push(String.fromCharCode(i)); + } + } + characterSet.push('+', '/'); + + const array = new Uint8Array(arraybuffer); + const length = arraybuffer.byteLength; + const remainder = length % 3; + let result = ''; + + for (let i = 0; i < length - remainder; i += 3) { + // aaaaaabb + const x = array[i]; + // bbbbcccc + const y = array[i + 1]; + // ccdddddd + const z = array[i + 2]; + + const a = x >> 2; + const b = ((x & 0b11) << 4) | (y >> 4); + const c = ((y & 0b1111) << 2) | (z >> 6); + const d = z & 0b111111; + + result += characterSet[a] + characterSet[b] + characterSet[c] + characterSet[d]; + } + + if (remainder === 1) { + // aaaaaabb + const x = array[length - 1]; + + const a = x >> 2; + const b = ((x & 0b11) << 4); + + result += characterSet[a] + characterSet[b] + '=='; + } else if (remainder === 2) { + // aaaaaabb + const x = array[length - 2]; + // bbbbcccc + const y = array[length - 1]; + + const a = x >> 2; + const b = ((x & 0b11) << 4) | (y >> 4); + const c = ((y & 0b1111) << 2); + + result = characterSet[a] + characterSet[b] + characterSet[c] + '='; + } + + return result; + } +} diff --git a/test.html b/test.html new file mode 100644 index 00000000..a31a27f3 --- /dev/null +++ b/test.html @@ -0,0 +1,13 @@ + + + + + + + + +
+ + + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..a54681fc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,66 @@ +{ + "compilerOptions": { + /* Basic Options */ + "incremental": true, // /* Enable incremental compilation */ + "target": "ES2018", // /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ + "module": "ESNext", // /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ + "lib": [ // /* Specify library files to be included in the compilation. */ + "DOM", + "ESNext" + ], + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + "declaration": true, // /* Generates corresponding '.d.ts' file. */ + "declarationMap": true, // /* Generates a sourcemap for each corresponding '.d.ts' file. */ + "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "lib/index.js", // /* Concatenate and emit output to single file. */ + "outDir": "lib", // /* Redirect output structure to the directory. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + /* Strict Type-Checking Options */ + "strict": true, // /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + /* Module Resolution Options */ + "moduleResolution": "node", // /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true, // /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + /* Advanced Options */ + "forceConsistentCasingInFileNames": true ///* Disallow inconsistently-cased references to the same file. */ + }, + "include": [ + "src" + ] +}