From 82a5a8c414cb97c3559359905faeaecc190622d0 Mon Sep 17 00:00:00 2001 From: adelphes Date: Sun, 22 Jan 2017 15:40:33 +0000 Subject: [PATCH] android-dev-ext initial commit --- .eslintrc.json | 23 + .gitignore | 1 + .vscode/extensions.json | 7 + .vscode/launch.json | 36 + .vscode/settings.json | 4 + .vscodeignore | 7 + CHANGELOG.md | 11 + README.md | 53 ++ adbclient.js | 803 +++++++++++++++++++++ chrome-polyfill.js | 190 +++++ debugMain.js | 1135 ++++++++++++++++++++++++++++++ debugger.js | 1480 +++++++++++++++++++++++++++++++++++++++ extension.js | 31 + jdwp.js | 1056 ++++++++++++++++++++++++++++ jq-promise.js | 124 ++++ jsconfig.json | 12 + minwebsocket.js | 122 ++++ package.json | 113 +++ services.js | 322 +++++++++ sockets.js | 290 ++++++++ test/extension.test.js | 24 + test/index.js | 22 + transport.js | 424 +++++++++++ util.js | 631 +++++++++++++++++ wsproxy.js | 440 ++++++++++++ 25 files changed, 7361 insertions(+) create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscodeignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 adbclient.js create mode 100644 chrome-polyfill.js create mode 100644 debugMain.js create mode 100644 debugger.js create mode 100644 extension.js create mode 100644 jdwp.js create mode 100644 jq-promise.js create mode 100644 jsconfig.json create mode 100644 minwebsocket.js create mode 100644 package.json create mode 100644 services.js create mode 100644 sockets.js create mode 100644 test/extension.test.js create mode 100644 test/index.js create mode 100644 transport.js create mode 100644 util.js create mode 100644 wsproxy.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..02509e2 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,23 @@ +{ + "env": { + "browser": false, + "commonjs": true, + "es6": true, + "node": true + }, + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "sourceType": "module" + }, + "rules": { + "no-const-assign": "warn", + "no-this-before-super": "warn", + "no-undef": "warn", + "no-unreachable": "warn", + "no-unused-vars": "warn", + "constructor-super": "warn", + "valid-typeof": "warn" + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..0915202 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the extensions.json format + "recommendations": [ + "dbaeumer.vscode-eslint" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d945aa6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,36 @@ +// A launch configuration that launches the extension inside a new window +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Extension", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": ["--extensionDevelopmentPath=${workspaceRoot}" ], + "stopOnEntry": false + }, + { + "name": "Server", + "type": "node", + "request": "launch", + "cwd": "${workspaceRoot}", + "program": "${workspaceRoot}/debugMain.js", + "args": [ "--server=4711" ] + }, + { + "name": "Launch Tests", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": ["--extensionDevelopmentPath=${workspaceRoot}", "--extensionTestsPath=${workspaceRoot}/test" ], + "stopOnEntry": false + } + ], + "compounds": [ + { + "name": "Extension + Server", + "configurations": [ "Launch Extension", "Server" ] + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..902014b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "typescript.tsdk": "./node_modules/typescript/lib" // we want to use the TS server from our node_modules folder to control its version +} \ No newline at end of file diff --git a/.vscodeignore b/.vscodeignore new file mode 100644 index 0000000..499648c --- /dev/null +++ b/.vscodeignore @@ -0,0 +1,7 @@ +.vscode/** +.vscode-test/** +test/** +.gitignore +jsconfig.json +vsc-extension-quickstart.md +.eslintrc.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6a01e5a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Change Log + +## version 0.1.0 +Initial release +* Support for deploying, launching and debugging Apps via ADB +* Single step debugging (step in, step over, step out) +* Local variable evaluation +* Simple watch expressions +* Breakpoints +* Large array chunking (performance) +* Stale build detection \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..23d46dd --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Android for VS Code + +This is a preview version of the Android for VS Code Extension. The extension allows developers to install, launch and debug Android Apps from +within the VS Code environment. + +## Requirements + +You must have [Android SDK Tools](https://developer.android.com/studio/releases/sdk-tools.html) installed. This extension communicates with your device via the ADB (Android Debug Bridge) interface. +> You are not required to have Android Studio installed - if you have Android Studio installed, make sure there are no active instances of it when using this +extension or you may run into problems with ADB. + +## Limitations + +* This is a preview version so expect the unexpected. Please log any issues you find on GitHub. +* This extension **will not build your app**. +> You must use gradle or some other build procedure to create your APK. Once built, the extension can deploy and launch your app, allowing +you to debug it in the normal way. +* Some debugger options are yet to be implemented. You cannot modify local variable values or set conditional breakpoints and watch expressions must be simple variables. +* If you require a must-have feature that isn't there yet, let us know on GitHub. +* This extension does not provide any additional code completion or other editing enhancements. + +## Extension Settings + +This extension allows you to debug your App by creating a new Android configuration in `launch.json`. +The following settings are used to configure the debugger: + + { + "version": "0.2.0", + "configurations": [ + { + // configuration type, request and name. "launch" is used to deploy the app to your device and start a debugging session + "type": "android", + "request": "launch", + "name": "Launch App", + + // Location of the App source files. This value must point to the root of your App source tree (containing AndroidManifest.xml) + "appSrcRoot": "${workspaceRoot}/app/src/main", + + // Fully qualified path to the built APK (Android Application Package) + "apkFile": "${workspaceRoot}/app/build/outputs/apk/app-debug.apk", + + // Port number to connect to the local ADB (Android Debug Bridge) instance. Default: 5037 + "adbPort": 5037, + + // Launch behaviour if source files have been saved after the APK was built. One of: [ ignore warn stop ]. Default: warn + "staleBuild": "warn", + } + ] + } + +## Questions / Problems + +If you run into any problems, let us know on GitHub or via Twitter. diff --git a/adbclient.js b/adbclient.js new file mode 100644 index 0000000..60aaf9d --- /dev/null +++ b/adbclient.js @@ -0,0 +1,803 @@ +/* + ADBClient: class to manage connection and commands to adb (via the Dex plugin) running on the local machine. +*/ +const _JDWP = require('./jdwp')._JDWP; +const $ = require('./jq-promise'); +const WebSocket = require('./minwebsocket').WebSocketClient; +const { atob,btoa,D } = require('./util'); + +function ADBClient(deviceid) { + this.deviceid = deviceid; + this.status = 'notinit'; + this.reset(); + this.JDWP = new _JDWP(); +} + +ADBClient.prototype = { + + reset : function() { + this.ws = null; + this.activepromise={}; + this.authdone=false; + this.fd=-1; + this.disconnect_reject_reason=null; + }, + + _parse_device_list:function(data, extended) { + var lines = atob(data).trim().split(/\r\n?|\n/); + lines.sort(); + var devicelist = []; + var i=0; + if (extended) { + for (var i=0; i < lines.length; i++) { + try { + var m = JSON.parse(lines[i]); + if (!m) continue; + m.num = i; + } catch(e) {continue;} + devicelist.push(m); + } + } else { + for (var i=0; i < lines.length; i++) { + var m = lines[i].match(/([^\t]+)\t([^\t]+)/); + if (!m) continue; + devicelist.push({ + serial: m[1], + status: m[2], + num:i, + }); + } + } + return devicelist; + }, + + track_devices_extended : function(o) { + var x = {o:o||{},deferred:$.Deferred()}; + this.proxy_connect() + .then(function() { + return this.dexcmd('cn'); + }) + .then(function(data) { + this.fd = data; + return this.dexcmd_read_status('track_devices', 'wa', this.fd, 'host:track-devices-extended'); + }) + .then(function(data) { + return this.dexcmd('ra', this.fd); + }) + .then(function(data) { + function nextdeviceinfo(data) { + this.dexcmd('ra', this.fd, null, {notimeout:true}) + .then(nextdeviceinfo); + var devicelist = this._parse_device_list(data, true); + x.o.ondevices(devicelist, this); + } + nextdeviceinfo.call(this, data); + x.deferred.resolveWith(x.o.ths||this, [x.o.extra]); + }) + .fail(function(err) { + x.deferred.rejectWith(x.o.ths||this, [err]); + }); + return x.deferred; + }, + + finish_track_devices : function() { + return this.dexcmd('dc', this.fd) + .then(function() { + return this.proxy_disconnect(); + }); + }, + + list_devices : function(o) { + var x = {o:o||{},deferred:$.Deferred()}; + this.proxy_connect() + .then(function() { + return this.dexcmd('cn'); + }) + .then(function(data) { + this.fd = data; + return this.dexcmd_read_status('list_devices', 'wa', this.fd, 'host:devices'); + }) + .then(function(data) { + return this.dexcmd('ra', this.fd); + }) + .then(function(data) { + x.devicelist = this._parse_device_list(data); + return this.dexcmd('dc', this.fd); + }) + .then(function() { + return this.proxy_disconnect(); + }) + .then(function() { + x.deferred.resolveWith(x.o.ths||this, [x.devicelist, x.o.extra]); + }) + .fail(function(err) { + x.deferred.rejectWith(x.o.ths||this, [err]); + }); + return x.deferred; + }, + + jdwp_list : function(o) { + var x = {o:o||{},deferred:$.Deferred()}; + this.proxy_connect() + .then(function() { + return this.dexcmd('cn'); + }) + .then(function(data) { + this.fd = data; + return this.dexcmd_read_status('set_transport', 'wa', this.fd, 'host:transport:'+this.deviceid); + }) + .then(function(data) { + return this.dexcmd_read_status('jdwp', 'wa', this.fd, 'jdwp'); + }) + .then(function(data) { + return this.dexcmd_read_stdout(this.fd); + }) + .then(function(data) { + this.stdout = data; + return this.dexcmd('dc', this.fd); + }) + .then(function() { + return this.proxy_disconnect(); + }) + .then(function() { + x.deferred.resolveWith(x.o.ths||this, [this.stdout.trim().split(/\r?\n|\r/g), x.o.extra]); + }) + .fail(function(err) { + x.deferred.rejectWith(x.o.ths||this, [err]); + }); + return x.deferred; + }, + + jdwp_forward : function(o) { + // localport:1234 + // jdwp:1234 + var x = {o:o,deferred:$.Deferred()}; + this.proxy_connect() + .then(function() { + return this.dexcmd('cn'); + }) + .then(function(data) { + this.fd = data; + return this.dexcmd_read_status('forward', 'wa', this.fd, 'host:forward:tcp:'+x.o.localport+';jdwp:'+x.o.jdwp); + }) + .then(function(data) { + return this.dexcmd('dc', this.fd); + }) + .then(function() { + return this.proxy_disconnect(); + }) + .then(function() { + x.deferred.resolveWith(x.o.ths||this, [x.o.extra]); + }) + .fail(function(err) { + x.deferred.rejectWith(x.o.ths||this, [err]); + }); + return x.deferred; + }, + + forward_remove_all : function(o) { + var x = {o:o||{},deferred:$.Deferred()}; + this.proxy_connect() + .then(function() { + return this.dexcmd('cn'); + }) + .then(function(data) { + this.fd = data; + return this.dexcmd_read_status('forward_remove_all', 'wa', this.fd, 'host:killforward-all'); + }) + .then(function(data) { + return this.dexcmd('dc', this.fd); + }) + .then(function() { + return this.proxy_disconnect(); + }) + .then(function() { + x.deferred.resolveWith(x.o.ths||this, [x.o.extra]); + }) + .fail(function(err) { + x.deferred.rejectWith(x.o.ths||this, [err]); + }); + return x.deferred; + }, + + jdwp_connect : function(o) { + // {localport:1234, onreply:fn()} + // note that upon success, this method does not close the connection + var x = {o:o,deferred:$.Deferred()}; + this.jdwpinfo = { + o: o, + localport: o.localport, + onreply: o.onreply, + received: [], + }; + this.proxy_connect() + .then(function() { + return this.dexcmd('cp', o.localport); + }) + .then(function(data) { + this.jdwpfd = data; + return this.dexcmd('wx', this.jdwpfd, 'JDWP-Handshake'); + }) + .then(function(data) { + return this.dexcmd_read_stdout(this.jdwpfd); + }) + .then(function(data) { + if (data!=='JDWP-Handshake') { + // disconnect and fail + return this.dexcmd('dc', this.jdwpfd) + .then(function() { + return this.proxy_disconnect_with_fail({cat:'jdwp', msg:'Invalid handshake response'}); + }); + } + // start the monitor - we don't want it terminated on timeout + return this.logsend('rj', 'rj '+this.jdwpfd, {notimeout:true}); + }) + .then(function() { + // the first rj reply is a blank ok message indicating the monitor + // has started + x.deferred.resolveWith(x.o.ths||this, [x.o.extra]); + }) + .fail(function(err) { + x.deferred.rejectWith(x.o.ths||this, [err]); + }); + return x.deferred; + }, + + jdwp_command : function(o) { + // cmd: JDWP.Command + // resolveonreply: true/false + + // send the raw command over the socket - the reply + // is received via the JDWP monitor + var x = {o:o,deferred:$.Deferred()}; + this.dexcmd('wx', this.jdwpfd, o.cmd.toRawString()) + .fail(function(err) { + o.cmd.deferred.rejectWith(o.ths||this, [err]); + }); + + o.cmd.deferred + .then(function(decoded,reply,command) { + x.deferred.resolveWith(x.o.ths||this, [decoded,x.o.extra]); + }) + .fail(function(err) { + x.deferred.rejectWith(x.o.ths||this, [err]); + }); + + return x.deferred; + }, + + jdwp_disconnect : function(o) { + var x = {o:o,deferred:$.Deferred()}; + this.dexcmd('dc', this.jdwpfd) + .then(function() { + delete this.jdwpfd; + return this.proxy_disconnect(); + }) + .then(function() { + x.deferred.resolveWith(x.o.ths||this, [x.o.extra]); + }) + .fail(function(err) { + x.deferred.rejectWith(x.o.ths||this, [err]); + }); + return x.deferred; + }, + + readwritesocket : function(o) { + var x = {o:o,deferred:$.Deferred()}; + this.proxy_connect() + .then(function() { + return this.dexcmd('cn'); + }) + .then(function(data) { + this.fd = data; + return this.dexcmd('qs', this.fd, ''+o.port+':'+o.readlen+':'+o.data); + }) + .then(function(data) { + this.socket_reply = data; + return this.dexcmd('dc', this.fd); + }) + .then(function() { + return this.proxy_disconnect(); + }) + .then(function() { + x.deferred.resolveWith(x.o.ths||this, [this.socket_reply, x.o.extra]); + }) + .fail(function(err) { + x.deferred.rejectWith(x.o.ths||this, [err]); + }); + return x.deferred; + }, + + shell_cmd : function(o) { + // command='ls /' + // untilclosed=true + var x = {o:o,deferred:$.Deferred()}; + this.proxy_connect() + .then(function() { + return this.dexcmd('cn'); + }) + .then(function(data) { + this.fd = data; + return this.dexcmd_read_status('set_transport', 'wa', this.fd, 'host:transport:'+this.deviceid); + }) + .then(function(data) { + return this.dexcmd_read_status('shell_cmd', 'wa', this.fd, 'shell:'+x.o.command); + }) + .then(function(data) { + return this.dexcmd_read_stdout(this.fd, !!x.o.untilclosed); + }) + .then(function(data) { + this.stdout = data; + return this.dexcmd('dc', this.fd); + }) + .then(function() { + return this.proxy_disconnect(); + }) + .then(function() { + x.deferred.resolveWith(x.o.ths||this, [this.stdout, x.o.extra]); + }) + .fail(function(err) { + x.deferred.rejectWith(x.o.ths||this, [err]); + }); + return x.deferred; + }, + + logcat : function(o) { + // onlog:function(e) + // onclose:function(e) + // data:anything + var x = {o:o,deferred:$.Deferred()}; + this.proxy_connect() + .then(function() { + return this.dexcmd('cn'); + }) + .then(function(data) { + this.fd = data; + return this.dexcmd_read_status('set_transport', 'wa', this.fd, 'host:transport:'+this.deviceid); + }) + .then(function(data) { + return this.dexcmd_read_status('shell_cmd', 'wa', this.fd, 'shell:logcat -v time'); + }) + .then(function(data) { + // if there's no handler, just read the complete log and finish + if (!o.onlog) { + return this.dexcmd_read_stdout(this.fd) + .then(function(data) { + this.logcatbuffer = data; + return this.dexcmd('dc', this.fd); + }) + .then(function() { + return this.proxy_disconnect(); + }) + .then(function() { + x.deferred.resolveWith(x.o.ths||this, [this.logcatbuffer, x.o.extra]); + }); + } + + // start the logcat monitor + return this.dexcmd('so', this.fd) + .then(function() { + this.logcatinfo = { + deferred: x.deferred, + buffer: '', + onlog: o.onlog||$.noop, + onlogdata: o.data, + onclose: o.onclose||$.noop, + fd: this.fd, + waitfn:_waitfornextlogcat, + } + this.logcatinfo.waitfn.call(this); + function _waitfornextlogcat() { + // create a new promise for when the next message is received + this.activepromise.so = $.Deferred(); + this.activepromise.so + .then(function(data) { + var decodeddata = atob(data); + if (decodeddata === 'eoso:d10d9798-1351-11e5-bdd9-5b316631f026') { + this.logcatinfo.fd=0; + this.proxy_disconnect().always(function() { + var e = {adbclient:this, data:this.logcatinfo.onlogdata}; + this.logcatinfo.onclose.call(this, e); + if (this.logcatinfo.end) { + var x = this.logcatinfo.end; + x.deferred.resolveWith(x.o.ths||this, [x.o.extra]); + } + }); + return; + } + var s = this.logcatinfo.buffer + atob(data); + var sp = s.split(/\r\n?|\n/); + if (/[\r\n]$/.test(s)) { + this.logcatinfo.buffer = '' + } else { + this.logcatinfo.buffer = sp.pop(); + } + var e = {adbclient:this, data:this.logcatinfo.onlogdata, logs:sp}; + this.logcatinfo.onlog.call(this, e); + this.logcatinfo.waitfn.call(this); + }); + } + // resolve the promise to indicate that logging has started + return x.deferred.resolveWith(x.o.ths||this, [x.o.extra]); + }); + }) + .fail(function(err) { + x.deferred.rejectWith(x.o.ths||this, [err]); + }); + return x.deferred; + }, + + endlogcat : function(o) { + var x = {o:o||{},deferred:$.Deferred()}; + var logcatfd = this.logcatinfo && this.logcatinfo.fd; + if (!logcatfd) + return x.deferred.resolveWith(x.o.ths||this, [x.o.extra]); + this.logcatinfo.fd = 0; + this.logcatinfo.end = x; + + // close the connection - the monitor callback will resolve the promise + this.dexcmd('dc', logcatfd); + return x.deferred; + }, + + push_file : function(o) { + // filepathname='/data/local/tmp/fname' + // filedata: + // filemtime:12345678 + this.push_file_info = o; + var x = {o:o,deferred:$.Deferred()}; + this.proxy_connect() + .then(function() { + return this.dexcmd('cn'); + }) + .then(function(data) { + this.fd = data; + return this.dexcmd_read_status('set_transport', 'wa', this.fd, 'host:transport:'+this.deviceid); + }) + .then(function(data) { + return this.dexcmd_read_status('sync', 'wa', this.fd, 'sync:'); + }) + .then(function() { + var perms = '33204'; + var cmddata = this.push_file_info.filepathname+','+perms; + var cmd='SEND'+String.fromCharCode(cmddata.length)+'\0\0\0'+cmddata; + return this.dexcmd('wx', this.fd, cmd) + }) + .then(function(data) { + return this.dexcmd_write_data(this.push_file_info.filedata); + }) + .then(function(data) { + var cmd='DONE'; + var mtime = this.push_file_info.filemtime; + for(var i=0;i < 4; i++) + cmd+= String.fromCharCode((mtime>>(i*8))&255); + return this.dexcmd_read_sync_response('done', 'wx', this.fd, cmd); + }) + .then(function(data) { + this.progress = 'quit'; + var cmd='QUIT\0\0\0\0'; + return this.dexcmd('wx', this.fd, cmd); + }) + .then(function(data) { + return this.dexcmd('dc', this.fd); + }) + .then(function() { + return this.proxy_disconnect(); + }) + .then(function() { + x.deferred.resolveWith(x.o.ths||this, [x.o.extra]); + }) + .fail(function(err) { + x.deferred.rejectWith(x.o.ths||this, [err]); + }); + return x.deferred; + }, + + do_auth : function(msg) { + var m = msg.match(/^vscadb proxy version 1/); + if (m) { + this.authdone = true; + this.status='connected'; + return this.activepromise.auth.resolveWith(this, []); + } + return this.proxy_disconnect_with_fail({cat:"Authentication", msg:"Proxy handshake failed"}); + }, + + proxy_disconnect_with_fail : function(reason) { + this.disconnect_reject_reason = reason; + return this.proxy_disconnect(); + }, + + proxy_disconnect : function() { + this.ws&&this.ws.close(); + return this.activepromise.disconnect; + }, + + proxy_onopen : function() { + this.status='handshake'; + this.logsend('auth','vscadb client version 1') + .then(function(){ + this.activepromise.connected.resolveWith(this, []); + }); + }, + + proxy_onerror : function() { + var reason; + if (this.status!=='connecting') { + reason= {cat:"Protocol", msg:"Connection fault"}; + } else { + reason = {cat:"Connection", msg:"A connection to the Dex debugger could not be established.", nodbgr:true}; + } + this.proxy_disconnect_with_fail(reason); + }, + + proxy_onmessage : function(e) { + if (!this.authdone) + return this.do_auth(e.data); + var cmd = e.data.substring(0, 2); + var msgresult = e.data.substring(3, 5); + if (cmd === 'rj' && this.jdwpinfo) { + // rj is the receive-jdwp reply - it is handled separately + if (this.jdwpinfo.started) { + this.jdwpinfo.received.push(e.data.substring(6)); + if (this.jdwpinfo.received.length > 1) return; + process.nextTick(function() { + while (this.jdwpinfo.received.length) { + var nextdata = this.jdwpinfo.received.shift(); + this.jdwpinfo.onreply.call(this.jdwpinfo.o.ths||this, atob(nextdata)); + } + }.bind(this)); + return; + } + if (e.data === 'rj ok') + this.jdwpinfo.started = new Date(); + } + var err; + var ap = this.activepromise[cmd], p = ap; + if (Array.isArray(p)) + p = p.shift(); + if (msgresult === "ok") { + if (p) { + if (!ap.length) + this.activepromise[cmd] = null; + p.resolveWith(this, [e.data.substring(6)]); + return; + } + err = {cat:"Command", msg:'Missing response message: ' + cmd}; + } else if (e.data==='cn error connection failed') { + // this is commonly expected, so remap the error to something nice + err = {cat:"Connection", msg:'ADB server is not running or cannot be contacted'}; + } else { + err = {cat:"Command", msg:e.data}; + } + this.proxy_disconnect_with_fail(err); + }, + + proxy_onclose : function(e) { + // when disconnecting, reject any pending promises first + var pending = []; + for (var cmd in this.activepromise) { + do { + var p = this.activepromise[cmd]; + if (!p) break; + if (Array.isArray(p)) + p = p.shift(); + if (p !== this.activepromise.disconnect) + if (p.state()==='pending') + pending.push(p); + } while(this.activepromise[cmd].length); + } + if (pending.length) { + var reject_reason = this.disconnect_reject_reason || {cat:'Connection', msg:'Proxy disconnection'}; + for (var i=0; i < pending.length; i++) + pending[i].rejectWith(this, [reject_reason]); + } + + // reset the object so it can be reused + var dcinfo = { + client: this, + deferred: this.activepromise.disconnect, + reason: this.disconnect_reject_reason + }; + this.status='closed'; + this.reset(); + + // resolve the disconnect promise after all others + pending.unshift(dcinfo); + $.when.apply($, pending) + .then(function(dcinfo) { + if (dcinfo.reason) + dcinfo.deferred.rejectWith(dcinfo.client, [dcinfo.reason]); + else + dcinfo.deferred.resolveWith(dcinfo.client); + }); + }, + + proxy_connect : function(o) { + var ws, port=(o&&o.port)||6037; + try { + ws = new WebSocket('ws://127.0.0.1:'+port); + } catch(e) { + ws=null; + return $.Deferred().rejectWith(this, [new Error('A connection to the ADB proxy could not be established.')]); + }; + + this.ws = ws; + this.ws.adbclient = this; + this.status='connecting'; + // connected is resolved after auth has completed + this.activepromise.connected = $.Deferred(); + // disconnect is resolved when the websocket is closed + this.activepromise.disconnect = $.Deferred(); + + ws.onopen = function(e) { + this.adbclient.proxy_onopen(e); + } + ws.onerror = function(e) { + clearTimeout(this.commandTimeout); + this.adbclient.proxy_onerror(e); + }; + ws.onmessage = function(e) { + clearTimeout(this.commandTimeout); + this.adbclient.proxy_onmessage(e); + }; + ws.onclose = function(e) { + clearTimeout(this.commandTimeout); + // safari doesn't call onerror for connection failures + if (this.adbclient.status==='connecting' && !this.adbclient.disconnect_reject_reason) + this.adbclient.proxy_onerror(e); + this.adbclient.proxy_onclose(e); + }; + + // the first promise is always connected, resolved after auth has completed + return this.activepromise.connected.promise(); + }, + + logsend : function(cmd, msg, opts) { + var def = $.Deferred(); + if (this.activepromise[cmd]) { + if (Array.isArray(this.activepromise[cmd])) { + // already a queue - just add it + this.activepromise[cmd].push(def); + } else { + // one pending - turn this into a queue + this.activepromise[cmd] = [this.activepromise[cmd], def]; + } + } else { + // no active entry + this.activepromise[cmd] = def; + } + if (!this.ws) { + this.proxy_disconnect_with_fail({cat:'Connection', msg:'Proxy disconnected'}); + return def; + } + clearTimeout(this.ws.commandTimeout); + try { + this.ws.send(msg); + } catch (e){ + this.proxy_disconnect_with_fail({cat:'Connection', msg:e.toString()}); + return def; + } + var docmdtimeout = 0;// !(opts&&opts.notimeout); + // if adb is not active, Windows takes at least 1 second to fail + // the socket connect... + this.ws.commandTimeout = docmdtimeout ? + setTimeout(function(adbclient) { + adbclient.proxy_disconnect_with_fail({cat:'Connection', msg:'Command timeout'}); + }, 300*1000, this) + : -1; + + return def; + }, + + dexcmd : function(cmd, fd, data, opts) { + var msg = cmd; + if (fd) + msg = msg + " " + fd; + if (data) + msg = msg + " " + btoa(data); + return this.logsend(cmd, msg, opts); + }, + + dexcmd_read_status : function(cmdname, cmd, fd, data) { + return this.dexcmd(cmd, fd, data) + .then(function() { + return this.dexcmd('rs', this.fd); + }) + .then(function(data) { + if (data !== 'OKAY') { + return this.proxy_disconnect_with_fail({cat:"cmd", msg:"Command "+ cmdname +" failed"}); + } + return data; + }); + }, + + dexcmd_read_sync_response : function(cmdname, cmd, fd, data) { + return this.dexcmd(cmd, fd, data) + .then(function() { + return this.dexcmd('rs', this.fd, '4'); + }) + .then(function(data) { + if (data.slice(0,4) !== 'OKAY') { + return this.proxy_disconnect_with_fail({cat:"cmd", msg:"Command "+ cmdname +" failed"}); + } + return data; + }); + }, + + dexcmd_read_stdout : function(fd, untilclosed) { + this.stdoutinfo = { + fd: fd, + result:'', + untilclosed:untilclosed||false, + deferred: $.Deferred(), + } + function readchunk() { + this.dexcmd('rx', this.stdoutinfo.fd) + .then(function(data) { + var eod = data==='nomore'; + if (data && data.length && !eod) { + this.stdoutinfo.result += atob(data); + } + if (this.stdoutinfo.untilclosed && !eod) { + readchunk.call(this); + return; + } + var info = this.stdoutinfo; + delete this.stdoutinfo; + info.deferred.resolveWith(this, [info.result]); + }) + .fail(function(err) { + var info = this.stdoutinfo; + delete this.stdoutinfo; + info.deferred.rejectWith(this, [err]); + }); + } + readchunk.call(this); + return this.stdoutinfo.deferred.promise(); + }, + + dexcmd_write_data : function(data) { + this.dtinfo = { + transferred: 0, + transferring: 0, + data: data, + deferred: $.Deferred(), + } + + function writechunk() { + this.dtinfo.transferred += this.dtinfo.transferring; + var remaining = this.dtinfo.data.byteLength-this.dtinfo.transferred; + if (remaining <= 0 || isNaN(remaining)) { + var info = this.dtinfo; + delete this.dtinfo; + info.deferred.resolveWith(this, [info.transferred]); + return; + } + var datalen=remaining; + if (datalen > 4000) datalen=4000; + var cmd='DATA'; + for(var i=0;i < 4; i++) + cmd+= String.fromCharCode((datalen>>(i*8))&255); + var bytes = new Uint8Array(this.dtinfo.data.slice(this.dtinfo.transferred, this.dtinfo.transferred+datalen)); + for(var i=0;i < bytes.length; i++) + cmd+= String.fromCharCode(bytes[i]); + bytes = null; + this.dtinfo.transferring = datalen; + this.dexcmd('wx', this.fd, cmd) + .then(function(data) { + writechunk.call(this); + }) + .fail(function(err) { + var info = this.dtinfo; + delete this.dtinfo; + info.deferred.rejectWith(this, [err]); + }); + } + writechunk.call(this); + return this.dtinfo.deferred.promise(); + }, + +}; + +exports.ADBClient = ADBClient; diff --git a/chrome-polyfill.js b/chrome-polyfill.js new file mode 100644 index 0000000..3c3d485 --- /dev/null +++ b/chrome-polyfill.js @@ -0,0 +1,190 @@ +const net = require('net'); +const D = require('./util').D; + +var sockets_by_id = {}; +var last_socket_id = 0; + +const chrome = { + storage: { + local: { + q:{}, + get(o, cb) { + for (var key in o) { + var x = this.q[key]; + if (typeof(x) !== 'undefined') o[key] = x; + } + process.nextTick(cb, o); + }, + set(obj, cb) { + for (var key in obj) + this.q[key] = obj[key]; + process.nextTick(cb); + } + } + }, + runtime: { + _lastError:[], + get lastError() { return this._lastError.pop() }, + set lastError(e) { this._lastError.push(e) } + }, + permissions: { + request(usbPermissions, cb) { + process.nextTick(cb, true); + } + }, + socket: { + listen(socketId, host, port, max_connections, cb) { + var result = 0; + var s = sockets_by_id[socketId]; + s._raw.listen(port, host, max_connections); + process.nextTick(cb, result); + }, + connect(socketId, host, port, cb) { + var s = sockets_by_id[socketId]; + s._raw.connect({port:port,host:host}, function(){ + this.s.onerror = null; + this.cb.call(null,0); + }.bind({s:s,cb:cb})); + s.onerror = function(e) { + this.s.onerror = null; + chrome.runtime.lastError = e; + this.cb.call(null,-1); + }.bind({s:s,cb:cb}); + }, + disconnect(socketId) { + var s = sockets_by_id[socketId]; + s._raw.end(); + }, + setNoDelay(socketId, state, cb) { + var s = sockets_by_id[socketId]; + s._raw.setNoDelay(state); + process.nextTick(cb, 1); + }, + read(socketId, bufferSize, onRead) { + if (!onRead && typeof(bufferSize) === 'function') + onRead = bufferSize, bufferSize=-1; + if (!onRead) return; + var s = sockets_by_id[socketId]; + if (bufferSize === 0) { + process.nextTick(function(onRead) { + onRead.call(null, {resultCode:1,data:Buffer.alloc(0)}); + }, onRead); + return; + } + s.read_requests.push({onRead:onRead, bufferSize:bufferSize}); + if (s.read_requests.length > 1) { + return; + } + !s.ondata && s._raw.on('data', s.ondata = function(data) { + this.readbuffer = Buffer.concat([this.readbuffer, data]); + while(this.read_requests.length) { + var amount = this.read_requests[0].bufferSize; + if (amount <= 0) amount = this.readbuffer.length; + if (amount > this.readbuffer.length || this.readbuffer.length === 0) + return; // wait for more data + var readInfo = { + resultCode:1, + data:Buffer.from(this.readbuffer.slice(0,amount)), + }; + this.readbuffer = this.readbuffer.slice(amount); + this.read_requests.shift().onRead.call(null,readInfo); + } + this.onerror = this.onclose = null; + }.bind(s)); + var on_read_terminated = function(e) { + this.readbuffer = Buffer.alloc(0); + while(this.read_requests.length) { + var readInfo = { + resultCode:-1, // <=0 for error + }; + this.read_requests.shift().onRead.call(null,readInfo); + } + this.onerror = this.onclose = null; + }.bind(s); + !s.onerror && (s.onerror = on_read_terminated); + !s.onclose && (s.onclose = on_read_terminated); + if (s.readbuffer.length || bufferSize < 0) { + process.nextTick(s.ondata, Buffer.alloc(0)); + } + }, + write(socketId, data, cb) { + var s = sockets_by_id[socketId]; + if (!(data instanceof Buffer)) + data = Buffer.from(data); + s._raw.write(data, function(e,f,g) { + if (this.s.write_cbs.length === 1) + this.s.onerror = null; + var writeInfo = { + bytesWritten: this.len, + }; + this.s.write_cbs.shift().call(null, writeInfo); + }.bind({s:s,len:data.length,cb:cb})); + s.write_cbs.push(cb); + if (!s.onerror) { + s.onerror = function(e) { + this.s.onerror = null; + while (this.s.write_cbs.length) { + var writeInfo = { + bytesWritten: 0, + }; + this.s.write_cbs.shift().call(null, writeInfo); + } + }.bind({s:s}); + } + }, + }, + + create_socket:function(id, type, cb) { + if (!cb && typeof(type) === 'function') { + cb = type, type = null; + } + var socket = type === 'server' ? new net.Server() : new net.Socket(); + var socketInfo = { + id: id, + socketId: ++last_socket_id, + _raw: socket, + onerror:null, + onclose:null, + write_cbs:[], + read_requests:[], + readbuffer:Buffer.alloc(0), + }; + socketInfo._raw.on('error', function(e) { + this.onerror && this.onerror(e); + }.bind(socketInfo)); + socketInfo._raw.on('close', function(e) { + this.onclose && this.onclose(e); + }.bind(socketInfo)); + sockets_by_id[socketInfo.socketId] = socketInfo; + process.nextTick(cb, socketInfo); + }, + create_chrome_socket(id, type, cb) { return chrome.create_socket(id, type, cb) }, + + accept_socket:function(id, socketId, cb) { + var s = sockets_by_id[socketId]; + if (s.onconnection) { + s.onconnection = cb; + } else { + s.onconnection = cb; + s._raw.on('connection', function(client_socket) { + var acceptInfo = { + socketId: ++last_socket_id, + _raw: client_socket, + } + sockets_by_id[acceptInfo.socketId] = acceptInfo; + this.onconnection(acceptInfo); + }.bind(s)); + } + }, + accept_chrome_socket(id, socketId, cb) { return chrome.accept_socket(id, socketId, cb) }, + + destroy_socket:function(socketId) { + var s = sockets_by_id[socketId]; + if (!s) return; + s._raw.end(); + sockets_by_id[socketId] = null; + }, + destroy_chrome_socket(socketId) { return chrome.destroy_socket(socketId) }, +} + +exports.chrome = chrome; diff --git a/debugMain.js b/debugMain.js new file mode 100644 index 0000000..32c20ca --- /dev/null +++ b/debugMain.js @@ -0,0 +1,1135 @@ +'use strict' +const { + DebugSession, + InitializedEvent, ExitedEvent, TerminatedEvent, StoppedEvent, BreakpointEvent, OutputEvent, Event, + Thread, StackFrame, Scope, Source, Handles, Breakpoint } = require('vscode-debugadapter'); +const DebugProtocol = { //require('vscode-debugprotocol'); + /** Arguments for 'launch' request. */ + LaunchRequestArguments: class { + /** If noDebug is true the launch request should launch the program without enabling debugging. */ + get noDebug() { return false } + } +} +// node and external modules +const crypto = require('crypto'); +const dom = require('xmldom').DOMParser; +const fs = require('fs'); +const path = require('path'); +const xpath = require('xpath'); +// our stuff +const { ADBClient } = require('./adbclient'); +const { Debugger } = require('./debugger'); +const $ = require('./jq-promise'); +const { D, isEmptyObject } = require('./util'); +const ws_proxy = require('./wsproxy').proxy.Server(6037, 5037); + +// arbitrary precision helper class for 64 bit numbers +const NumberBaseConverter = { + // Adds two arrays for the given base (10 or 16), returning the result. + add(x, y, base) { + var z = [], n = Math.max(x.length, y.length), carry = 0, i = 0; + while (i < n || carry) { + var xi = i < x.length ? x[i] : 0; + var yi = i < y.length ? y[i] : 0; + var zi = carry + xi + yi; + z.push(zi % base); + carry = Math.floor(zi / base); + i++; + } + return z; + }, + // Returns a*x, where x is an array of decimal digits and a is an ordinary + // JavaScript number. base is the number base of the array x. + multiplyByNumber(num, x, base) { + if (num < 0) return null; + if (num == 0) return []; + var result = [], power = x; + for(;;) { + if (num & 1) { + result = this.add(result, power, base); + } + num = num >> 1; + if (num === 0) return result; + power = this.add(power, power, base); + } + }, + twosComplement(str, base) { + const invdigits = str.split('').map(c => 15 - parseInt(c,base)).reverse(); + const negdigits = this.add(invdigits, [1], base).slice(0,str.length); + return negdigits.reverse().map(d => d.toString(base)).join(''); + }, + convertBase(str, fromBase, toBase) { + var digits = str.split('').map(d => parseInt(d,fromBase)).reverse(); + var outArray = [], power = [1]; + for (var i = 0; i < digits.length; i++) { + if (digits[i]) { + outArray = this.add(outArray, this.multiplyByNumber(digits[i], power, toBase), toBase); + } + power = this.multiplyByNumber(fromBase, power, toBase); + } + return outArray.reverse().map(d => d.toString(toBase)).join(''); + }, + hexToDec(hexstr, signed) { + var res, isneg = /^[^0-7]/.test(hexstr); + if (hexstr.match(/^0*(.+)$/)[1].length*4 < 52) { + // less than 52 bits - just use parseInt + res = parseInt(hexstr, 16); + if (signed && isneg) res = -res; + return res.toString(10); + } + if (isneg) { + hexstr = NumberBaseConverter.twosComplement(hexstr, 16); + } + res = (isneg ? '-' : '') + NumberBaseConverter.convertBase(hexstr, 16, 10); + return res; + }, +}; + +// some commonly used Java types in debugger-compatible format +const JTYPES = { + byte: {name:'int',signature:'B'}, + short: {name:'short',signature:'S'}, + int: {name:'int',signature:'I'}, + long: {name:'long',signature:'J'}, + float: {name:'float',signature:'F'}, + double: {name:'double',signature:'D'}, + char: {name:'char',signature:'C'}, + boolean: {name:'boolean',signature:'Z'}, + null: {name:'null',signature:'Lnull;'}, // null has no type really, but we need something for literals + String: {name:'String',signature:'Ljava/lang/String;'}, + Object: {name:'Object',signature:'Ljava/lang/Object;'}, + isArray(t) { return t.signature[0]==='[' }, + isObject(t) { return t.signature[0]==='L' }, + isReference(t) { return /^[L[]/.test(t.signature) }, + isPrimitive(t) { return !JTYPES.isReference(t.signature) }, + isInteger(t) { return /^[BIJS]$/.test(t.signature) }, +} + +function ensure_path_end_slash(p) { + return p + (/[\\/]$/.test(p) ? '' : path.sep); +} + +class AndroidDebugSession extends DebugSession { + + /** + * Creates a new debug adapter that is used for one debug session. + */ + constructor() { + super(); + // create the Android debugger instance - we proxy requests through this + this.dbgr = new Debugger(); + + // the base folder of the app (where AndroidManifest.xml and source files should be) + this.app_src_root = ''; + // the filepathname of the built apk + this.apk_fpn = ''; + // the apk file content + this._apk_file_data = null; + // the file info, hash and manifest data of the apk + this.apk_file_info = {}; + // hashmap of packages we found in the source tree + this.src_packages = {}; + // the device we are debugging + this._device = null; + // expandable primitives + this._expandable_prims = false; + // true if the app is resumed, false if stopped (exception, breakpoint, etc) + this._running = false; + // a promise to wait on for the stack variables to evaluate + this._locals_done = null; + // the fifo queue of evaluations (watches, hover, etc) + this._evals_queue = []; + + // since we want to send breakpoint events, we will assign an id to every event + // so that the frontend can match events with breakpoints. + this._breakpointId = 1000; + + // hashmap of variables and frames + this._variableHandles = {}; + this._frameBaseId = 0x00010000; // high, so we don't clash with thread id's + this._nextObjVarRef = 0x10000000; // high, so we don't clash with thread or frame id's + + // flag to distinguish unexpected disconnection events (initiated from the device) vs user-terminated requests + this._isDisconnecting = false; + + // this debugger uses one-based lines and columns + this.setDebuggerLinesStartAt1(true); + this.setDebuggerColumnsStartAt1(true); + } + + /** + * The 'initialize' request is the first request called by the frontend + * to interrogate the features the debug adapter provides. + */ + initializeRequest(response/*: DebugProtocol.InitializeResponse*/, args/*: DebugProtocol.InitializeRequestArguments*/) { + + // This debug adapter implements the configurationDoneRequest. + response.body.supportsConfigurationDoneRequest = true; + + this.sendResponse(response); + } + + LOG(msg) { + D(msg); + this.sendEvent(new OutputEvent(msg)); + } + + WARN(msg) { + D(msg = 'Warning: '+msg); + this.sendEvent(new OutputEvent(msg)); + } + + failRequest(msg, response) { + // yeah, it can happen sometimes... + this.WARN(msg); + if (response) { + response.success = false; + this.sendResponse(response); + } + } + + launchRequest(response/*: DebugProtocol.LaunchResponse*/, args/*: LaunchRequestArguments*/) { + + try { D('Launching: ' + JSON.stringify(args)); } catch(ex) {} + // app_src_root must end in a path-separator for correct validation of sub-paths + this.app_src_root = ensure_path_end_slash(args.appSrcRoot); + this.apk_fpn = args.apkFile; + + // configure the ADB port - if it's undefined, it will set the default value. + // if it's not a valid port number, any connection request should neatly fail. + ws_proxy.setADBPort(args.adbPort); + + try { + // start by scanning the source folder for stuff we need to know about (packages, manifest, etc) + this.src_packages = this.scanSourceSync(this.app_src_root); + // warn if we couldn't find any packages (-> no source -> cannot debug anything) + if (isEmptyObject(this.src_packages.packages)) + this.WARN('No source files found. Check the "appSrcRoot" setting in launch.json'); + + } catch(err) { + // wow, we really didn't make it very far... + this.LOG(err.message); + this.LOG('Check the "appSrcRoot" and "apkFile" entries in launch.json'); + this.sendEvent(new TerminatedEvent(false)); + return; + } + + var fail_launch = (msg) => $.Deferred().rejectWith(this, [new Error(msg)]); + + this.LOG('Checking build') + this.getAPKFileInfo() + .then(apk_file_info => { + this.apk_file_info = apk_file_info; + // check if any source file was modified after the apk + if (this.src_packages.last_src_modified >= this.apk_file_info.app_modified) { + switch (args.staleBuild) { + case 'ignore': break; + case 'stop': return fail_launch('Build is not up-to-date'); + case 'warn': + default: this.WARN('Build is not up-to-date. Source files may not match execution when debugging.'); break; + } + } + // check we have something to launch - we do this again later, but it's a bit better to do it before we start device comms + var launchActivity = args.launchActivity; + if (!launchActivity) + if (!(launchActivity = this.apk_file_info.launcher)) + return fail_launch('No valid launch activity found in AndroidManifest.xml or launch.json'); + return this.findSuitableDevice(args.targetDevice); + }) + .then(device => { + this._device = device; + this._device.adbclient = new ADBClient(this._device.serial); + // we've got our device - retrieve the hash of the installed app (or sha1 utility itself if the app is not installed) + const query_app_hash = `/system/bin/sha1sum $(pm path ${this.apk_file_info.package}|grep -o -e '/.*' || echo '/system/bin/sha1sum')`; + return this._device.adbclient.shell_cmd({command: query_app_hash}); + }) + .then(sha1sum_output => { + const installed_hash = sha1sum_output.match(/^[0-9a-fA-F]*/)[0].toLowerCase(); + // does the installed apk hash match the content hash? if, so we don't need to install the app + if (installed_hash === this.apk_file_info.content_hash) { + this.LOG('Current build already installed'); + return; + } + return this.copyAndInstallAPK(); + }) + .then(() => { + // when we reach here, the app should be installed and ready to be launched + // - before we continue, splunk the apk file data because node *still* hangs when evaluating large arrays + this._apk_file_data = null; + + // start the launch + var launchActivity = args.launchActivity; + if (!launchActivity) + if (!(launchActivity = this.apk_file_info.launcher)) + return fail_launch('No valid launch activity found in AndroidManifest.xml or launch.json'); + var build = { + pkgname:this.apk_file_info.package, + packages:Object.assign({}, this.src_packages.packages), + launchActivity: launchActivity, + }; + this.LOG(`Launching ${build.pkgname+'/'+launchActivity} on device ${this._device.serial}`); + return this.dbgr.startDebugSession(build, this._device.serial, launchActivity); + }) + .then(() => { + // if we get this far, the debugger is connected and waiting for the resume command + // - set up some events + this.dbgr.on('bpstatechange', this, this.onBreakpointStateChange) + .on('bphit', this, this.onBreakpointHit) + .on('step', this, this.onStep) + .on('disconnect', this, this.onDebuggerDisconnect); + this.waitForConfigurationDone = $.Deferred(); + // - tell the client we're initialised and ready for breakpoint info, etc + this.sendEvent(new InitializedEvent()); + return this.waitForConfigurationDone; + }) + .then(() => { + // config is done - we're all set and ready to go! + D('Continuing app start'); + this.continueRequest(response, {is_start:true}); + }) + .fail(e => { + // exceptions use message, adbclient uses msg + this.LOG('Launch failed: '+(e.message||e.msg||'No additional information is available')); + // more info for adb connect errors + if (/^ADB server is not running/.test(e.msg)) { + this.LOG('Make sure the Android SDK tools are installed and run:'); + this.LOG(' adb start-server'); + this.LOG('If you are running ADB on a non-default port, also make sure the adbPort value in your launch.json is correct.'); + } + // tell the client we're done + this.sendEvent(new TerminatedEvent(false)); + }); + } + + copyAndInstallAPK() { + // copy the file to the device + this.LOG('Deploying current build...'); + return this._device.adbclient.push_file({ + filepathname:'/data/local/tmp/debug.apk', + filedata:this._apk_file_data, + filemtime:new Date().getTime(), + }) + .then(() => { + // send the install command + this.LOG('Installing...'); + return this._device.adbclient.shell_cmd({ + command:'pm install -r /data/local/tmp/debug.apk', + untilclosed:true, + }) + }) + .then((stdout) => { + // failures: + // pkg: x-y-z.apk + // Failure [INSTALL_FAILED_OLDER_SDK] + var m = stdout.match(/Failure\s+\[([^\]]+)\]/g); + if (m) { + return $.Deferred().rejectWith(this, [new Error('Installation failed. ' + m[0])]); + } + }) + } + + getAPKFileInfo() { + var done = $.Deferred(); + done.result = { fpn:this.apk_fpn, app_modified:0, content_hash:'', manifest:'', package:'', activities:[], launcher:'' }; + // read the APK + fs.readFile(this.apk_fpn, (err,apk_file_data) => { + if (err) return done.rejectWith(this, [new Error('APK read error. ' + err.message)]); + // debugging is painful when the APK file content is large, so keep the data in a separate field so node + // doesn't have to evaluate it when we're looking at the apk info + this._apk_file_data = apk_file_data; + // save the last modification time of the app + done.result.app_modified = fs.statSync(done.result.fpn).mtime.getTime(); + // create a SHA-1 hash as a simple way to see if we need to install/update the app + const h = crypto.createHash('SHA1'); + h.update(apk_file_data); + done.result.content_hash = h.digest('hex'); + // read the manifest + fs.readFile(path.join(this.app_src_root,'AndroidManifest.xml'), 'utf8', (err,manifest) => { + if (err) return done.rejectWith(this, [new Error('Manifest read error. ' + err.message)]); + done.result.manifest = manifest; + try { + const doc = new dom().parseFromString(manifest); + // extract the package name from the manifest + const pkg_xpath = '/manifest/@package'; + done.result.package = xpath.select1(pkg_xpath, doc).value; + const android_select = xpath.useNamespaces({"android": "http://schemas.android.com/apk/res/android"}); + // extract a list of all the (named) activities declared in the manifest + const activity_xpath='/manifest/application/activity/@android:name'; + var nodes = android_select(activity_xpath, doc); + nodes && (done.result.activities = nodes.map(n => n.value)); + + // extract the default launcher activity + const launcher_xpath='/manifest/application/activity[intent-filter/action[@android:name="android.intent.action.MAIN"] and intent-filter/category[@android:name="android.intent.category.LAUNCHER"]]/@android:name'; + var nodes = android_select(launcher_xpath, doc); + // should we warn if there's more than one? + if (nodes && nodes.length >= 1) + done.result.launcher = nodes[0].value + } catch(err) { + return done.rejectWith(this, [new Error('Manifest parse failed. ' + err.message)]); + } + done.resolveWith(this, [done.result]); + }); + }); + return done; + } + + scanSourceSync(app_root) { + try { + // scan known app folders looking for file changes and package folders + var p, paths = fs.readdirSync(app_root,'utf8'), done=[]; + var src_packages = { + last_src_modified: 0, + packages: {}, + }; + while (paths.length) { + p = paths.shift(); + // just in case someone has some crazy circular links going on + if (done.indexOf(p)>=0) continue; + done.push(p); + var subfiles = [], stat, fpn = path.join(app_root,p); + try { + stat = fs.statSync(fpn); + src_packages.last_src_modified = Math.max(src_packages.last_src_modified, stat.mtime.getTime()); + if (!stat.isDirectory()) continue; + subfiles = fs.readdirSync(fpn, 'utf8'); + } + catch (err) { continue } + // ignore folders not starting with a known top-level Android folder + if (!/^(assets|res|src|main|java)([\\/]|$)/.test(p)) continue; + // is this a package folder + var pkgmatch = p.match(/^(src|main|java)[\\/](.+)/); + if (pkgmatch && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(pkgmatch[2].split(/[\\/]/).pop())) { + // looks good - add it to the list + const src_folder = pkgmatch[1]; // src, main or java + const pkgname = pkgmatch[2].replace(/[\\/]/g,'.'); + src_packages.packages[pkgname] = { + package: pkgname, + package_path: fpn, + srcroot: path.join(app_root,src_folder), + } + } + // add the subfiles to the list to process + paths = subfiles.map(sf => path.join(p,sf)).concat(paths); + } + return src_packages; + } catch(err) { + throw new Error('Source path error: ' + err.message); + } + } + + findSuitableDevice(target_deviceid) { + this.LOG('Searching for devices...'); + return this.dbgr.list_devices() + .then(devices => { + this.LOG(`Found ${devices.length} device${devices.length===1?'':'s'}`); + var reject; + if (devices.length === 0) { + reject = 'No devices are connected'; + } else if (target_deviceid) { + // check (only one of) the requested device is present + var matching_devices = devices.filter(d => d.serial === target_deviceid); + switch(matching_devices.length) { + case 0: reject = `Target device: '${target_deviceid}' is not connected. Connect it or specify an alternate target device in launch.json`; break; + case 1: return matching_devices[0]; + default: reject = `Target device: '${target_deviceid}' has multiple candidates. Connect a single device or specify an alternate target device in launch.json`; break; + } + } else if (devices.length === 1) { + // no specific target device and only one device is connected to adb - use it + return devices[0]; + } else { + // more than one device and no specific target - fail the launch + reject = `Multiple devices are connected and no target device is specified in launch.json`; + // be nice and list the devices so the user can easily configure + devices.forEach(d => this.LOG(`\t${d.serial}\t${d.status}`)); + } + return $.Deferred().rejectWith(this, [new Error(reject)]); + }) + } + + configurationDoneRequest(response, args) { + this.waitForConfigurationDone.resolve(); + this.sendResponse(response); + } + + onDebuggerDisconnect() { + // called when we manually disconnect, or from an unexpected disconnection (USB cable disconnect, etc) + if (!this._isDisconnecting) { + D('Unexpected disconnection'); + // this is a surprise disconnect (initiated from the device) - tell the client we're done + this.LOG(`Device disconnected`); + this.sendEvent(new TerminatedEvent(false)); + } + } + + disconnectRequest(response, args) { + D('disconnectRequest'); + this._isDisconnecting = true; + // if we're connected, ask ADB to terminate the app + if (this.dbgr.status() === 'connected') + this.dbgr.forcestop(); + return this.dbgr.disconnect(response) + .then((state, response) => { + if (/^connect/.test(state)) + this.LOG(`Debugger disconnected`); + this.sendResponse(response); + //this.sendEvent(new ExitedEvent(0)); + }) + } + + onBreakpointStateChange(e) { + e.breakpoints.forEach(javabp => { + // if there's no associated vsbp we're deleting it, so just ignore the update + if (!javabp.vsbp) return; + var verified = !!javabp.state.match(/set|enabled/); + javabp.vsbp.verified = verified; + this.sendEvent(new BreakpointEvent('updated', javabp.vsbp)); + }); + } + + onBreakpointHit(e) { + D('Breakpoint hit: ' + JSON.stringify(e.stoppedlocation)); + this._running = false; + var tid = parseInt(e.stoppedlocation.threadid,16); + this.sendEvent(new StoppedEvent("breakpoint", tid)); + } + + markAllThreadsStopped(reason, exclude) { + this.dbgr.allthreads(reason) + .then(threads => { + if (Array.isArray(exclude)) + threads = threads.filter(t => !exclude.includes(t)); + threads.forEach(t => this.sendEvent(new StoppedEvent(reason, parseInt(t,16)))); + }); + } + + /** + * Called when the user requests a change to breakpoints in a source file + * Note: all breakpoints in a file are always sent in args, even if they are not changing + */ + setBreakPointsRequest(response/*: DebugProtocol.SetBreakpointsResponse*/, args/*: DebugProtocol.SetBreakpointsArguments*/) { + var srcfpn = args.source && args.source.path; + var clientLines = args.lines; + D('setBreakPointsRequest: ' + srcfpn); + + // the file must lie inside one of the source packages we found (and it must be have a .java extension) + var srcfolder = path.dirname(srcfpn); + var pkginfo; + for (var pkg in this.src_packages.packages) { + if ((pkginfo = this.src_packages.packages[pkg]).package_path === srcfolder) break; + pkginfo = null; + } + if (!pkginfo || !/\.java$/.test(srcfpn)) { + // source file is not a java file or is outside of the known source packages + // just send back a list of unverified breakpoints + response.body = { + breakpoints: args.lines.map(l => { + var bp = new Breakpoint(false,l); + bp.id = ++this._breakpointId; + return bp; + }) + }; + this.sendResponse(response); + return; + } + + // our debugger requires a relative fpn beginning with / , rooted at the java source base folder + // - it should look like: /some/package/name/abc.java + var relative_fpn = srcfpn.slice(pkginfo.srcroot.length); + + // delete any existing breakpoints not in the list + this.dbgr.clearbreakpoints(javabp => { + var remove = javabp.srcfpn===relative_fpn && !clientLines.includes(javabp.linenum); + if (remove) javabp.vsbp = null; + return remove; + }); + + // return the list of new and existing breakpoints + var breakpoints = clientLines.map((line,idx) => { + var dbgline = this.convertClientLineToDebugger(line); + var javabp = this.dbgr.setbreakpoint(relative_fpn, dbgline); + if (!javabp.vsbp) { + // state is one of: set,notloaded,enabled,removed + var verified = !!javabp.state.match(/set|enabled/); + const bp = new Breakpoint(verified, this.convertDebuggerLineToClient(dbgline)); + // the breakpoint *must* have an id field or it won't update properly + bp.id = ++this._breakpointId; + javabp.vsbp = bp; + } + javabp.vsbp.order = idx; + return javabp.vsbp; + }); + + // send back the actual breakpoint positions + response.body = { + breakpoints: breakpoints + }; + this.sendResponse(response); + } + + threadsRequest(response/*: DebugProtocol.ThreadsResponse*/) { + + this.dbgr.allthreads(response) + .then((threads, response) => { + // convert the (hex) thread strings into real numbers + var tids = threads.map(t => parseInt(t,16)); + response.body = { + threads: tids.map(tid => new Thread(tid, `Thread (id:${tid})`)) + }; + this.sendResponse(response); + }) + .fail(e => { + response.success = false; + this.sendResponse(response); + }); + } + + /** + * Returns a stack trace for the given threadId + */ + stackTraceRequest(response/*: DebugProtocol.StackTraceResponse*/, args/*: DebugProtocol.StackTraceArguments*/) { + + // debugger threadid's are a padded 64bit hex number + var threadid = ('000000000000000' + args.threadId.toString(16)).slice(-16); + // retrieve the (stack) frames from the debugger + this.dbgr.getframes(threadid, {response:response, args:args}) + .then((frames, x) => { + // first ensure that the line-tables for all the methods are loaded + var defs = frames.map(f => this.dbgr._ensuremethodlines(f.method)); + defs.unshift(frames,x); + return $.when.apply($,defs); + }) + .then((frames, x) => { + const startFrame = typeof x.args.startFrame === 'number' ? x.args.startFrame : 0; + const maxLevels = typeof x.args.levels === 'number' ? x.args.levels : frames.length-startFrame; + const endFrame = Math.min(startFrame + maxLevels, frames.length); + var stack = [], totalFrames = frames.length, highest_known_source=0; + for (var i= startFrame; i < endFrame; i++) { + // the stack_frame_id must be unique across all threads + const stack_frame_id = (x.args.threadId * this._frameBaseId) + i; + this._variableHandles[stack_frame_id] = { varref: stack_frame_id, frame: frames[i], threadId:x.args.threadId }; + const name = `${frames[i].method.owningclass.name}.${frames[i].method.name}`; + const pkginfo = this.src_packages.packages[frames[i].method.owningclass.type.package]; + const sourcefile = frames[i].method.owningclass.src.sourcefile; + const srcloc = this.dbgr.line_idx_to_source_location(frames[i].method, frames[i].location.idx); + if (!srcloc) { + totalFrames--; + continue; // ignore frames which have no location (they're probably synthetic) + } + const linenum = srcloc && this.convertDebuggerLineToClient(srcloc.linenum); + const src = sourcefile && new Source(sourcefile, (pkginfo && path.join(pkginfo.package_path,sourcefile))||'', pkginfo ? 0 : 1); + pkginfo && (highest_known_source=i); + stack.push(new StackFrame(stack_frame_id, name, src, linenum, 0)); + } + // FIX: trim the stack to exclude anything above the known sources - otherwise an error occurs in the editor when the user tries to view it + stack = stack.slice(0,highest_known_source+1); + totalFrames = stack.length; + // return the frames + response.body = { + stackFrames: stack, + totalFrames: totalFrames, + }; + this.sendResponse(response); + }); + } + + scopesRequest(response/*: DebugProtocol.ScopesResponse*/, args/*: DebugProtocol.ScopesArguments*/) { + + response.body = { + scopes: [new Scope("Local", args.frameId, false)] + }; + this.sendResponse(response); + } + + sourceRequest(response/*: DebugProtocol.SourceResponse*/, args/*: DebugProtocol.SourceArguments*/) { + response.body = { content:'// The source for this class is unavailable.' } + this.sendResponse(response); + } + + /** + * Converts locals (or other vars) in debugger format into Variable objects used by VSCode + */ + _locals_to_variables(vars) { + return vars.map(v => { + var varref = 0, objvalue, typename = v.type.package ? `${v.type.package}.${v.type.typename}` : v.type.typename; + switch(true) { + case v.hasnullvalue && JTYPES.isReference(v.type): + // null object or array type + objvalue = 'null'; + break; + case v.type.signature === JTYPES.Object.signature: + // Object doesn't really have anything worth seeing, so just treat it as unexpandable + objvalue = v.type.typename; + break; + case v.type.signature === JTYPES.String.signature: + objvalue = JSON.stringify(v.string); + if (v.biglen) { + // since this is a big string - make it viewable on expand + varref = ++this._nextObjVarRef; + this._variableHandles[varref] = {varref:varref, bigstring:v}; + objvalue = `String (length:${v.biglen})`; + } + else if (this._expandable_prims) { + // as a courtesy, allow strings to be expanded to see their length + varref = ++this._nextObjVarRef; + this._variableHandles[varref] = {varref:varref, signature:v.type.signature, primitive:true, value:v.string.length}; + } + break; + case JTYPES.isArray(v.type): + // non-null array type - if it's not zero-length add another variable reference so the user can expand + if (v.arraylen) { + varref = ++this._nextObjVarRef; + this._variableHandles[varref] = { varref:varref, arrvar:v, range:[0,v.arraylen] }; + } + objvalue = v.type.typename.replace(/]$/, v.arraylen+']'); // insert len as the final array bound + break; + case JTYPES.isObject(v.type): + // non-null object instance - add another variable reference so the user can expand + varref = ++this._nextObjVarRef; + this._variableHandles[varref] = {varref:varref, objvar:v}; + objvalue = v.type.typename; + break; + case v.type.signature === 'C': + const cmap = {'\f':'f','\r':'r','\n':'n','\t':'t','\v':'v','\'':'\'','\\':'\\'}, cc = v.value.charCodeAt(0); + if (cmap[v.value]) { + objvalue = `'\\${cmap[v.value]}'`; + } else if (cc < 32) { + objvalue = cc ? `'\\u${('000'+cc.toString(16)).slice(-4)}'` : "'\\0'"; + } else objvalue = `'${v.value}'`; + break; + case v.type.signature === 'J': + // because JS cannot handle 64bit ints, we need a bit of extra work + var v64hex = v.value.replace(/[^0-9a-fA-F]/g,''); + objvalue = NumberBaseConverter.hexToDec(v64hex, true); + break; + default: + // other primitives: int, boolean, etc + objvalue = v.value.toString(); + break; + } + // as a courtesy, allow integer and character values to be expanded to show the value in alternate bases + if (this._expandable_prims && /^[IJBSC]$/.test(v.type.signature)) { + varref = ++this._nextObjVarRef; + this._variableHandles[varref] = {varref:varref, signature:v.type.signature, primitive:true, value:v.value}; + } + return { + name: v.name, + type: typename, + value: objvalue, + variablesReference: varref, + } + }); + + } + + variablesRequest(response/*: DebugProtocol.VariablesResponse*/, args/*: DebugProtocol.VariablesArguments*/) { + + const return_mapped_vars = (vars, response) => { + response.body = { + variables: this._locals_to_variables(vars.filter(v => v.valid)) + }; + this.sendResponse(response); + } + + var varinfo = this._variableHandles[args.variablesReference]; + if (!varinfo) { + return_mapped_vars([], response); + } + else if (varinfo.cached) { + return_mapped_vars(varinfo.cached, response); + } + else if (varinfo.objvar) { + // object fields request + this.dbgr.getsupertype(varinfo.objvar, {varinfo:varinfo, response:response}) + .then((supertype, x) => { + x.supertype = supertype; + return this.dbgr.getfieldvalues(x.varinfo.objvar, x); + }) + .then((fields, x) => { + // ignore supertypes of Object + x.supertype && x.supertype.signature!=='Ljava/lang/Object;' && fields.unshift({ + vtype:'super', + name:'super', + hasnullvalue:false, + type: x.supertype, + value: x.varinfo.objvar.value, + valid:true, + }); + x.varinfo.cached = fields; + return_mapped_vars(fields, x.response); + }); + } + else if (varinfo.arrvar) { + // array elements request + var range = varinfo.range, count = range[1] - range[0]; + // should always have a +ve count, but just in case... + if (count <= 0) return return_mapped_vars([], response); + // add some hysteresis + if (count > 110) { + // create subranges in the sub-power of 10 + var subrangelen = Math.max(Math.pow(10, (Math.log10(count)|0)-1),100), variables = []; + for (var i=range[0],varref,v; i < range[1]; i+= subrangelen) { + varref = ++this._nextObjVarRef; + v = this._variableHandles[varref] = { varref:varref, arrvar:varinfo.arrvar, range:[i, Math.min(i+subrangelen, range[1])] }; + variables.push({name:`[${v.range[0]}..${v.range[1]-1}]`,type:'',value:'',variablesReference:varref}); + } + response.body = { + variables: variables + }; + this.sendResponse(response); + return; + } + // get the elements for the specified range + this.dbgr.getarrayvalues(varinfo.arrvar, range[0], count, response) + .then((elements, response) => { + varinfo.cached = elements; + return_mapped_vars(elements, response); + }); + } + else if (varinfo.bigstring) { + this.dbgr.getstringchars(varinfo.bigstring.value, response) + .then((s,response) => { + return_mapped_vars([{name:'',hasnullvalue:false,string:s,type:JTYPES.String,valid:true}], response); + }); + } + else if (varinfo.primitive) { + // convert the primitive value into alternate formats + var variables = [], bits = {J:64,I:32,S:16,B:8}[varinfo.signature]; + const pad = (u,base,len) => ('0000000000000000000000000000000'+u.toString(base)).slice(-len); + switch(varinfo.signature) { + case 'Ljava/lang/String;': + variables.push({name:'',type:'',value:varinfo.value.toString(),variablesReference:0}); + break; + case 'C': + variables.push({name:'',type:'',value:varinfo.value.charCodeAt(0).toString(),variablesReference:0}); + break; + case 'J': + // because JS cannot handle 64bit ints, we need a bit of extra work + var v64hex = varinfo.value.replace(/[^0-9a-fA-F]/g,''); + const s4 = { hi:parseInt(v64hex.slice(0,8),16), lo:parseInt(v64hex.slice(-8),16) }; + variables.push( + {name:'',type:'',value:pad(s4.hi,2,32)+pad(s4.lo,2,32),variablesReference:0} + ,{name:'',type:'',value:NumberBaseConverter.hexToDec(v64hex,false),variablesReference:0} + ,{name:'',type:'',value:pad(s4.hi,16,8)+pad(s4.lo,16,8),variablesReference:0} + ); + break; + default:// integer/short/byte value + const u = varinfo.value >>> 0; + variables.push( + {name:'',type:'',value:pad(u,2,bits),variablesReference:0} + ,{name:'',type:'',value:u.toString(10),variablesReference:0} + ,{name:'',type:'',value:pad(u,16,bits/4),variablesReference:0} + ); + break; + } + response.body = { + variables: variables + }; + this.sendResponse(response); + } + else { + // frame locals request + this.dbgr.getlocals(varinfo.frame.threadid, varinfo.frame, response) + .then((locals, response) => { + varinfo.cached = locals; + return_mapped_vars(locals, response); + if (this._locals_done) { + this._locals_done.resolveWith(this, [locals]); + this._locals_done = null; + }; + }); + } + } + + continueRequest(response/*: DebugProtocol.ContinueResponse*/, args/*: DebugProtocol.ContinueArguments*/) { + D('Continue'); + this._variableHandles = {}; + // sometimes, the device is so quick that a breakpoint is hit + // before we've completed the resume promise chain. + // so tell the client that we've resumed now and just send a StoppedEvent + // if it ends up failing + this._running = true; + this._locals_done = $.Deferred(); + this.dbgr.resume() + .then(() => { + if (args.is_start) + this.LOG(`App started`); + }) + .fail(() => { + if (!response) + this.sendEvent(new StoppedEvent('Continue failed')); + this.failRequest('Resume command failed', response); + response = null; + }); + response && this.sendResponse(response) && D('Sent continue response'); + response = null; + } + + /** + * Called by the debugger after a step operation has completed + */ + onStep(e) { + D('step hit: ' + JSON.stringify(e.stoppedlocation)); + this._running = false; + this.sendEvent(new StoppedEvent("step", parseInt(e.stoppedlocation.threadid,16))); + } + + /** + * Called by the user to start a step operation + */ + doStep(which, response, args) { + D('step '+which); + this._variableHandles = {}; + var threadid = ('000000000000000' + args.threadId.toString(16)).slice(-16); + this.dbgr.step(which, threadid) + .then(() => { + this._running = true; + this._locals_done = $.Deferred(); + this.sendResponse(response); + }); + } + + stepInRequest(response/*: DebugProtocol.NextResponse*/, args/*: DebugProtocol.StepInArguments*/) { + this.doStep('in', response, args); + } + + nextRequest(response/*: DebugProtocol.NextResponse*/, args/*: DebugProtocol.NextArguments*/) { + this.doStep('over', response, args); + } + + stepOutRequest(response/*: DebugProtocol.NextResponse*/, args/*: DebugProtocol.StepOutArguments*/) { + this.doStep('out', response, args); + } + + /** + * Called by VSCode to perform watch, console and hover evaluations + */ + evaluateRequest(response/*: DebugProtocol.EvaluateResponse*/, args/*: DebugProtocol.EvaluateArguments*/) { + + // Some notes to remember: + // annoyingly, during stepping, the step can complete before the resume has called evaluateRequest on watches. + // The order can go: doStep(running=true),onStep(running=false),evaluateRequest(),evaluateRequest() + // so we end up evaluating twice... + // also annoyingly, this method is called before the locals in the current stack frame are evaluated + // and even more annoyingly, Android (or JDWP) seems to get confused on the first request when we're retrieving multiple values, fields, etc + // so we have to queue them or we end up with strange results + + if (this._running) { + response.body = { result:'(running)', variablesReference:0 }; + this.sendResponse(response); + return; + } + this._evals_queue.push([response,args]); + if (this._evals_queue.length > 1) + return; + if (this._locals_done) { + // wait for the promise to be resolved (after the locals have been retrieved) + this._locals_done.then(() => { + // start the evaluations + this.doNextEvaluateRequest(); + }); + return; + } + // we reach here if the program is paused, all the queued evaluations are done and a new evaluation is requested + this.doNextEvaluateRequest(); + } + + sendResponseAndDoNext(response, value, varref) { + response.body = { result:value, variablesReference:varref|0 }; + this.sendResponse(response); + this._evals_queue.shift(); + this.doNextEvaluateRequest(); + } + + doNextEvaluateRequest() { + if (!this._evals_queue.length) return; + this.doEvaluateRequest.apply(this, this._evals_queue[0]); + } + + doEvaluateRequest(response, args) { + + // just in case the user starts the app running again, before we've evaluated everything in the queue + if (this._running) { + return this.sendResponseAndDoNext(response, '(running)'); + } + + var parse_array_or_fncall = function(e) { + var arg, res = {arr:[], call:null}; + // pre-call array indexes + while (e.expr[0] === '[') { + e.expr = e.expr.slice(1).trim(); + if ((arg = parse_expression(e)) === null) return null; + res.arr.push(arg); + if (e.expr[0] !== ']') return null; + e.expr = e.expr.slice(1).trim(); + } + if (res.arr.length) return res; + // method call + if (e.expr[0] === '(') { + res.call = []; e.expr = e.expr.slice(1).trim(); + if (e.expr[0] !== ')') { + for (;;) { + if ((arg = parse_expression(e)) === null) return null; + res.call.push(arg); + if (e.expr[0] === ')') break; + if (e.expr[0] !== ',') return null; + e.expr = e.expr.slice(1).trim(); + } + } + e.expr = e.expr.slice(1).trim(); + // post-call array indexes + while (e.expr[0] === '[') { + e.expr = e.expr.slice(1).trim(); + if ((arg = parse_expression(e)) === null) return null; + res.arr.push(arg); + if (e.expr[0] !== ']') return null; + e.expr = e.expr.slice(1).trim(); + } + } + return res; + } + var parse_expression = function(e) { + var root_term = e.expr.match(/^(?:(true(?![\w$]))|(false(?![\w$]))|(null(?![\w$]))|([a-zA-Z_$][a-zA-Z0-9_$]*)|(\d+(?:\.\d+)?)|('[^\\']')|('\\[frntv0]')|('\\u[0-9a-fA-F]{4}')|("[^"]*"))/); + if (!root_term) return null; + var res = { + root_term: root_term[0], + root_term_type: ['boolean','boolean','null','ident','number','char','echar','uchar','string'][[1,2,3,4,5,6,7,8,9].find(x => root_term[x])-1], + array_or_fncall: null, + members:[], + } + e.expr = e.expr.slice(res.root_term.length).trim(); + if ((res.array_or_fncall = parse_array_or_fncall(e)) === null) return null; + // the root term is not allowed to be a method call + if (res.array_or_fncall.call) return null; + while (e.expr[0] === '.') { + // member expression + e.expr = e.expr.slice(1).trim(); + var m, member_name = e.expr.match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/); + if (!member_name) return null; + res.members.push(m = {member:member_name[0], array_or_fncall:null}) + e.expr = e.expr.slice(m.member.length).trim(); + if ((m.array_or_fncall = parse_array_or_fncall(e)) === null) return null; + } + return res; + } + const descape_char = (c) => { + if (c.length===2) { + // backslash escape + var x = {'f':'\f','r':'\r','n':'\n','t':'\t',v:'\v'}[c[1]]; + return x || (c[1]==='0'?String.fromCharCode(0):c[1]); + } + // unicode escape + return String.fromCharCode(parseInt(c.slice(2,6),16)); + } + var reject_evaluation = (msg) => $.Deferred().rejectWith(this, [new Error(msg)]); + var evaluate_number = (n) => { + const numtype = /\./.test(n) ? JTYPES.double : JTYPES.int; + const iszero = /^0+(\.0*)?$/.test(n); + return { vtype:'literal',name:'',hasnullvalue:iszero,type:numtype,value:n,valid:true }; + } + var evaluate_expression = (expr) => { + var q = $.Deferred(), local; + switch(expr.root_term_type) { + case 'boolean': + local = { vtype:'literal',name:'',hasnullvalue:false,type:JTYPES.boolean,value:expr.root_term,valid:true }; + break; + case 'null': + const nullvalue = '0000000000000000'; // null reference value + local = { vtype:'literal',name:'',hasnullvalue:true,type:JTYPES.null,value:nullvalue,valid:true }; + break; + case 'ident': + var v = this._variableHandles[args.frameId]; + if (v && v.frame && v.cached) + local = v.cached.find(l => l.name === expr.root_term); + break; + case 'number': + local = evaluate_number(expr.root_term); + break; + case 'char': + local = expr.root_term[1]; // fall-through + case 'echar': + case 'uchar': + !local && (local = descape_char(expr.root_term.slice(1,-1))); // fall-through + local = { vtype:'literal',name:'',hasnullvalue:false,type:JTYPES.char,value:local,valid:true }; + break; + case 'string': + const raw = expr.root_term.slice(1,-1).replace(/\\u[0-9a-fA-F]{4}|\\./,descape_char); + // we must get the runtime to create string instances + q = this.dbgr.createstring(raw); + local = {valid:true}; // make sure we don't fail the evaluation + break; + } + if (!local || !local.valid) return reject_evaluation('not available'); + // we've got the root term variable - work out the rest + q = expr.array_or_fncall.arr.reduce((q,index_expr) => { + return q.then(function(index_expr,local) { return evaluate_array_element.call(this,index_expr,local) }.bind(this,index_expr)); + }, q); + q = expr.members.reduce((q,m) => { + return q.then(function(m,local) { return evaluate_member.call(this,m,local) }.bind(this,m)); + }, q); + // if it's a string literal, we are already waiting for the runtime to create the string + // - otherwise, start the evalaution... + if (expr.root_term_type !== 'string') + q.resolveWith(this,[local]); + return q; + } + var evaluate_array_element = (index_expr, arr_local) => { + if (arr_local.type.signature[0] !== '[') return reject_evaluation('TypeError: value is not an array'); + if (arr_local.hasnullvalue) return reject_evaluation('NullPointerException'); + return evaluate_expression(index_expr) + .then(function(arr_local, idx_local) { + if (!JTYPES.isInteger(idx_local.type)) return reject_evaluation('TypeError: array index is not an integer value'); + var idx = parseInt(idx_local.value,10); + if (idx < 0 || idx >= arr_local.arraylen) return reject_evaluation('BoundsError: array index out of bounds'); + return this.dbgr.getarrayvalues(arr_local, idx, 1) + }.bind(this,arr_local)) + .then(els => els[0]) + } + var evaluate_methodcall = (m, obj_local) => { + return reject_evaluation('Error: method calls are not supported'); + } + var evaluate_member = (m, obj_local) => { + if (!JTYPES.isReference(obj_local.type)) return reject_evaluation('TypeError: value is not a reference type'); + if (obj_local.hasnullvalue) return reject_evaluation('NullPointerException'); + if (m.array_or_fncall.call) return evaluate_methodcall(m, obj_local); + // length is a 'fake' field of arrays, so special-case it + if (JTYPES.isArray(obj_local.type) && m.member==='length') + return evaluate_number(obj_local.arraylen); + return this.dbgr.getfieldvalues(obj_local, m) + .then((fields,m) => { + var field = fields.find(f => f.name === m.member); + if (!field) return reject_evaluation('no such field: '+m.member); + if (m.array_or_fncall.arr.length) { + var q = $.Deferred(); + m.array_or_fncall.arr.reduce((q,index_expr) => { + return q.then(function(index_expr,local) { return evaluate_array_element(index_expr,local) }.bind(this,index_expr)); + }, q); + return q.resolveWith(this, [field]); + } + return field; + }) + } + D('evaluate: ' + args.expression); + var e = { expr:args.expression }; + var parsed_expression = parse_expression(e); + // if there's anything left, it's an error + if (parsed_expression && !e.expr) { + // the expression is well-formed - start the (asynchronous) evaluation + evaluate_expression(parsed_expression) + .then(function(response,local) { + var v = this._locals_to_variables([local])[0]; + this.sendResponseAndDoNext(response, v.value, v.variablesReference); + }.bind(this,response)) + .fail(function(response,reason) { + this.sendResponseAndDoNext(response, reason.message); + }.bind(this,response)) + return; + } + + // the expression is not well-formed + this.sendResponseAndDoNext(response, 'not available'); + } + +} + + +DebugSession.run(AndroidDebugSession); \ No newline at end of file diff --git a/debugger.js b/debugger.js new file mode 100644 index 0000000..18c5762 --- /dev/null +++ b/debugger.js @@ -0,0 +1,1480 @@ +'use strict' +/* + Debugger: thin wrapper around other classes to manage debug connections +*/ +const _JDWP = require('./jdwp')._JDWP; +const { ADBClient } = require('./adbclient'); +const $ = require('./jq-promise'); +const { D } = require('./util'); + +function Debugger() { + this.connection = null; + this.ons = {}; + this.breakpoints = { all:[], enabled:{}, bysrcloc:{} }; + this.JDWP = new _JDWP(); + this.session = null; + this.globals = Debugger.globals; +} + +Debugger.globals = { + portrange : {lowest:31000, highest:31099}, + inuseports : [], + debuggers : {}, + reserveport:function() { + // choose a random port to use each time + for (var i=0; i < 10000; i++) { + var portidx = ((Math.random()*100)|0); + if (this.inuseports.includes(portidx)) + continue; // try again + this.inuseports.push(portidx); + return this.portrange.lowest+portidx; + } + }, + freeport : function(port) { + var iuidx = this.inuseports.indexOf(port - this.portrange.lowest); + if (iuidx >= 0) this.inuseports.splice(iuidx, 1); + } +}; + +Debugger.prototype = { + + on : function(which, context, data, fn) { + if (!fn && !data && typeof(context)==='function') { + fn = context; context = data = null; + } + else if (!fn && typeof(data)==='function') { + fn = data; data = null; + } + if (!this.ons[which]) this.ons[which] = []; + this.ons[which].push({ + context:context,data:data,fn:fn + }); + return this; + }, + + _trigger : function(which, e) { + var k = this.ons[which]; + if (!k || !k.length) return this; + k = k.slice(); + e = e||{}; + e.dbgr = this; + for (var i=0; i < k.length; i++) { + e.data = k[i].data; + try { k[i].fn.call(k[i].context, e)} + catch(ex) { + D('Exception in event trigger: '+ex.message); + } + } + return this; + }, + + startDebugSession(build, deviceid, launcherActivity) { + return this.newSession(build, deviceid) + .runapp('debug', launcherActivity, this) + .then(function(deviceid) { + return this.getDebuggablePIDs(this.session.deviceid, this); + }) + .then(function(pids, dbgr) { + // choose the last pid in the list + var pid = pids[pids.length-1]; + // after connect(), the caller must call resume() to begin + return dbgr.connect(pid, dbgr); + }) + }, + + runapp(action, launcherActivity) { + // older (<3) versions of Android only allow target components to be specified with -n + var launchcmdparams = ['--activity-brought-to-front','-a android.intent.action.MAIN','-c android.intent.category.LAUNCHER','-n '+ this.session.build.pkgname+'/'+launcherActivity]; + if (action==='debug') { + launchcmdparams.splice(0,0,'-D'); + } + var x = { + dbgr:this, + shell_cmd: { + command: 'am start '+launchcmdparams.join(' '), + untilclosed:true, + }, + retries: { + count:10,pause:1000, + }, + deviceid:this.session.deviceid, + deferred: $.Deferred(), + }; + tryrunapp(x); + function tryrunapp(x) { + var adb = new ADBClient(x.deviceid); + adb.shell_cmd(x.shell_cmd) + .then(function(stdout) { + // failures: + // Error: Activity not started... + var m = stdout.match(/Error:.*/g); + if (m) { + if (--x.retries.count) { + setTimeout(function(o) { + tryrunapp(o); + }, x.retries.pause, x); + return; + } + return x.deferred.reject({cat:'cmd', msg: m[0]}); + } + // running the JDWP command so soon after launching hangs, so give it a breather before continuing + setTimeout(x => { + x.deferred.resolveWith(x.dbgr, [x.deviceid]) + }, 1000, x); + }) + .fail(function(err) { + }); + } + return x.deferred; + }, + + newSession : function(build, deviceid) { + this.session = { + build: build, + deviceid: deviceid, + apilevel:0, + adbclient: null, + stoppedlocation: null, + classes:{}, + // classprepare notifier done + cpndone:false, + preparedclasses:[], + } + return this; + }, + + /* return a list of deviceids available for debugging */ + list_devices : function(extra) { + return new ADBClient().list_devices(extra); + }, + + getDebuggablePIDs : function(deviceid, extra) { + return new ADBClient(deviceid).jdwp_list({ + ths:this, + extra:extra, + }) + }, + + getDebuggableProcesses : function(deviceid, extra) { + var info = { + debugger:this, + adbclient: new ADBClient(deviceid), + extra:extra, + }; + return info.adbclient.jdwp_list({ + ths:this, + extra:info, + }) + .then(function(jdwps, info) { + if (!jdwps.length) + return $.Deferred().resolveWith(this,[[], info.extra]); + info.jdwps = jdwps; + // retrieve the ps list from the device + return info.adbclient.shell_cmd({ + ths:this, + extra:info, + command:'ps', + untilclosed:true, + }).then(function(stdout, info) { + // output should look something like... + // USER PID PPID VSIZE RSS WCHAN PC NAME + // u0_a153 32721 1452 1506500 37916 ffffffff 00000000 S com.example.somepkg + // but we cope with variations so long as PID and NAME exist + var lines = stdout.split(/\r?\n|\r/g); + var hdrs = (lines.shift()||'').trim().toUpperCase().split(/\s+/); + var pidindex = hdrs.indexOf('PID'); + var nameindex = hdrs.indexOf('NAME'); + var result = {deviceid:info.adbclient.deviceid,name:{},jdwp:{},all:[]}; + if (pidindex<0||nameindex<0) + return $.Deferred().resolveWith(null,[[],info.extra]); + // scan the list looking for matching pids... + for (var i=0; i < lines.length; i++) { + var entries=lines[i].trim().replace(/ [S] /,' ').split(/\s+/); + if (entries.length != hdrs.length) continue; + var jdwpidx = info.jdwps.indexOf(entries[pidindex]); + if (jdwpidx < 0) continue; + // we found a match + var entry = { + jdwp: entries[pidindex], + name: entries[nameindex], + }; + result.all.push(entry); + result.name[entry.name] = entry; + result.jdwp[entry.jdwp] = entry; + } + return $.Deferred().resolveWith(this,[result, info.extra]); + }) + }); + }, + + /* attach to the debuggable pid + Quite a lot happens in this - we setup port forwarding, complete the JDWP handshake, + setup class loader notifications and call anyone waiting for us. + If anything fails, we call disconnect() to return to a sense of normality. + */ + connect : function(jdwpid, extra) { + switch(this.status()) { + case 'connected': + // already connected - just resolve + return $.Deferred().resolveWith(this, [extra]); + case 'connecting': + // wait for the connection to complete (or fail) + var x = { deferred:$.Deferred(), extra: extra }; + this.connection.connectingpromises.push(x); + return x.deferred; + default: + if (!jdwpid) + return $.Deferred().rejectWith(this, [new Error('Debugger not connected')]); + break; + } + + var info = { + dbgr: this, + extra: extra, + }; + + // from this point on, we are in the "connecting" state until the JDWP handshake is complete + // (and we mark as connected) or we fail and return to the disconnected state + this.connection = { + jdwp:jdwpid, + localport:this.globals.reserveport(), + portforwarding:false, + connected:false, + connectingpromises:[], + }; + + // setup port forwarding + return new ADBClient(this.session.deviceid).jdwp_forward({ + ths:this, + extra:info, + localport:this.connection.localport, + jdwp:this.connection.jdwp, + }) + .then(function(info) { + this.connection.portforwarding = true; + // after this, the client keeps an open connection until + // jdwp_disconnect() is called + this.session.adbclient = new ADBClient(this.session.deviceid); + return this.session.adbclient.jdwp_connect({ + ths:this, + extra:info, + localport: this.connection.localport, + onreply: this._onjdwpmessage, + }); + }) + .then(function(info) { + // handshake has completed + this.connection.connected = true; + // call suspend first - we shouldn't really need to do this (as the debugger + // is already suspended and will not resume until we tell it), but if we + // don't do this, it logs a complaint... + return this.suspend(); + }) + .then(function() { + return this.session.adbclient.jdwp_command({ + ths: this, + cmd: this.JDWP.Commands.idsizes(), + }); + }) + .then(function(idsizes) { + // set the class loader event notifier so we can set breakpoints... + this.JDWP.setIDSizes(idsizes); + return this._initbreakpoints(); + }) + .then(function() { + return new ADBClient(this.session.deviceid).shell_cmd({ + ths:this, + command:'getprop ro.build.version.sdk', + }); + }) + .then(function(apilevel) { + this.session.apilevel = apilevel.trim(); + // at this point, we are ready to go - all the caller needs to do is call resume(). + // resolve all the connection promises for those waiting on us (usually none) + var cp = this.connection.connectingpromises; + var deferreds = [this, info]; + delete this.connection.connectingpromises; + for (var i=0; i < cp.length; i++) { + deferreds.push(cp[i].deferred); + cp[i].deferred.resolveWith(this, [cp[i].extra]); + } + return $.when.apply($, deferreds).then(function(dbgr, info) { + return $.Deferred().resolveWith(dbgr, [info.extra]); + }) + }) + .then(function() { + this._trigger('connected'); + }) + .fail(function(err) { + this.connection.err = err; + // force a return to the disconnected state + this.disconnect(); + }) + }, + + _onjdwpmessage : function(data) { + // decodereply will resolve the promise associated with + // any command this reply is in response to. + var reply = this.JDWP.decodereply(this, data); + if (reply.isevent) { + if (reply.decoded.events && reply.decoded.events.length) { + switch (reply.decoded.events[0].kind.value) { + case 100: + // vm disconnected - sent by plugin + this.disconnect(); + break; + } + } + } + }, + + ensureconnected : function(extra) { + // passing null as the jdwpid will cause a fail if the client is not connected (or connecting) + return this.connect(null, extra); + }, + + status : function() { + if (!this.connection) return "disconnected"; + if (this.connection.connected) return "connected"; + return "connecting"; + }, + + forcestop : function(extra) { + return this.ensureconnected() + .then(function() { + return new ADBClient(this.session.deviceid).shell_cmd({ + command:'am force-stop '+ this.session.build.pkgname, + }); + }) + }, + + disconnect : function(extra) { + // disconnect is called from a variety of failure scenarios + // so it must be fairly robust in how it undoes stuff + const current_state = this.status(); + if (!this.connection) + return $.Deferred().resolveWith(this, [current_state,extra]); + + var info = { + connection: this.connection, + current_state: current_state, + extra: extra, + }; + // from here on in, this instance is in the disconnected state + this.connection = null; + + // fail any waiting for the connection to complete + var cp = info.connection.connectingpromises; + if (cp) { + for (var i=0; i < cp.length; i++) { + cp[i].deferred.rejectWith(this, [info.connection.err]); + } + } + + // reset the breakpoint states + this._finitbreakpoints(); + + this._trigger('disconnect'); + + // perform the JDWP disconnect + info.jdwpdisconnect = info.connection.connected + ? this.session.adbclient.jdwp_disconnect({ths:this, extra:info}) + : $.Deferred().resolveWith(this, [info]); + + return info.jdwpdisconnect + .then(function(info) { + this.session.adbclient = null; + // undo the portforwarding + // todo: replace remove_all with remove_port + info.pfremove = info.connection.portforwarding + ? new ADBClient(this.session.deviceid).forward_remove_all({ths:this, extra:info}) + : $.Deferred().resolveWith(this, [info]); + + return info.pfremove; + }) + .then(function(info) { + // mark the port as freed + if (info.connection.portforwarding) { + this.globals.freeport(info.connection.localport) + } + this.session = null; + return $.Deferred().resolveWith(this, [info.current_state, info.extra]); + }); + }, + + allthreads : function(extra) { + return this.ensureconnected(extra) + .then(function(extra) { + return this.session.adbclient.jdwp_command({ + ths: this, + extra: extra, + cmd: this.JDWP.Commands.allthreads(), + }); + }); + }, + + suspend : function(extra) { + return this.ensureconnected(extra) + .then(function(extra) { + this._trigger('suspending'); + return this.session.adbclient.jdwp_command({ + ths: this, + extra: extra, + cmd: this.JDWP.Commands.suspend(), + }); + }) + .then(function() { + this._trigger('suspended'); + }); + }, + + resume : function(extra) { + return this.ensureconnected(extra) + .then(function(extra) { + this._trigger('resuming'); + this.session.stoppedlocation = null; + return this.session.adbclient.jdwp_command({ + ths: this, + extra: extra, + cmd: this.JDWP.Commands.resume(), + }); + }) + .then(function(decoded, extra) { + this._trigger('resumed'); + return extra; + }); + }, + + _resumesilent : function() { + return this.ensureconnected() + .then(function() { + this.session.stoppedlocation = null; + return this.session.adbclient.jdwp_command({ + ths: this, + //extra: extra, + cmd: this.JDWP.Commands.resume(), + }); + }); + }, + + step : function(steptype, threadid) { + var x = {steptype:steptype, threadid:threadid}; + return this.ensureconnected(x) + .then(function(x) { + this._trigger('stepping'); + return this._setupstepevent(x.steptype, x.threadid); + }) + .then(function() { + return this._resumesilent(); + }); + }, + + _splitsrcfpn: function(srcfpn) { + var m = srcfpn.match(/^\/([^/]+(?:\/[^/]+)*)?\/([^./]+)\.java$/); + return { + pkg:m[1].replace(/\/+/g,'.'), + type:m[2], + qtype:m[1]+'/'+m[2], + } + }, + + getbreakpoint : function(srcfpn,line) { + var cls = this._splitsrcfpn(srcfpn); + var bp = this.breakpoints.bysrcloc[cls.qtype+':'+line]; + return bp; + }, + + getbreakpoints : function(filterfn) { + var x = this.breakpoints.all.reduce(function(x, bp) { + if (x.filterfn(bp)) + x.res.push(bp); + return x; + }, {filterfn:filterfn, res:[]}); + return x.res; + }, + + getallbreakpoints : function() { + return this.breakpoints.all.slice(); + }, + + setbreakpoint : function(srcfpn, line) { + var cls = this._splitsrcfpn(srcfpn); + var bid = cls.qtype+':'+line; + var newbp = this.breakpoints.bysrcloc[bid]; + if (newbp) return newbp; + newbp = { + id:bid, + srcfpn:srcfpn, + qtype: cls.qtype, + pkg: cls.pkg, + type: cls.type, + linenum:line, + sigpattern: new RegExp('^L'+cls.qtype+'([$][$a-zA-Z0-9_]+)?;$'), + state:'set'// set,notloaded,enabled,removed + }; + this.breakpoints.all.push(newbp); + this.breakpoints.bysrcloc[bid] = newbp; + + // what happens next depends upon what state we are in + switch(this.status()) { + case 'connected': + //this._changebpstate([newbp], 'set'); + //this._changebpstate([newbp], 'notloaded'); + newbp.state = 'notloaded'; + if (this.session.cpndone) { + var bploc = this._findbplocation(this.session.classes, newbp); + if (bploc) { + this._setupbreakpointsevent([bploc]); + } + } + break; + case 'connecting': + case 'disconnected': + default: + //this._changebpstate([newbp], 'set'); + newbp.state = 'set'; + break; + } + + return newbp; + }, + + clearbreakpoint : function(srcfpn,line) { + var cls = this._splitsrcfpn(srcfpn); + var bp = this.breakpoints.bysrcloc[cls.qtype+':'+line]; + if (!bp) return null; + return this._clearbreakpoints([bp])[0]; + }, + + clearbreakpoints : function(bps) { + if (typeof(bps) === 'function') { + // argument is a filter function + return this.clearbreakpoints(this.getbreakpoints(bps)); + } + // sanitise first to remove duplicates, non-existants, nulls, etc + var bpstoclear = []; + var bpkeys = {}; + (bps||[]).forEach(function(bp) { + if (!bp) return; + if (this.breakpoints.all.indexOf(bp) < 0) return; + var bpkey = bp.cls+':'+bp.linenum; + if (bpkeys[bpkey]) return; + bpkeys[bpkey] = 1; + bpstoclear.push(bp); + }, this); + return this._clearbreakpoints(bpstoclear); + }, + + _clearbreakpoints : function(bpstoclear) { + if (!bpstoclear || !bpstoclear.length) return []; + bpstoclear.forEach(function(bp) { + delete this.breakpoints.bysrcloc[bp.qtype+':'+bp.linenum]; + this.breakpoints.all.splice(this.breakpoints.all.indexOf(bp),1); + }, this); + + switch(this.status()) { + case 'connected': + var bpcleareddefs = [{dbgr:this, bpstoclear:bpstoclear}]; + for (var cmlkey in this.breakpoints.enabled) { + var enabledbp = this.breakpoints.enabled[cmlkey].bp; + if (bpstoclear.indexOf(enabledbp)>=0) { + bpcleareddefs.push(this._clearbreakpointsevent([cmlkey], enabledbp)); + } + } + $.when.apply($, bpcleareddefs) + .then(function(x) { + x.dbgr._changebpstate(x.bpstoclear, 'removed'); + }); + break; + case 'connecting': + case 'disconnected': + default: + this._changebpstate(bpstoclear, 'removed'); + break; + } + + return bpstoclear; + }, + + getframes : function(threadid, extra) { + return this.session.adbclient.jdwp_command({ + ths: this, + extra: extra, + cmd: this.JDWP.Commands.Frames(threadid), + }).then(function(frames, extra) { + var deferreds = [{dbgr:this, frames:frames, threadid:threadid, extra:extra}]; + for (var i=0; i < frames.length; i++) { + deferreds.push(this._findmethodasync(this.session.classes, frames[i].location)); + } + return $.when.apply($, deferreds) + .then(function(x) { + for (var i=0; i < x.frames.length; i++) { + x.frames[i].method = arguments[i+1][0]; + x.frames[i].threadid = x.threadid; + } + return $.Deferred().resolveWith(x.dbgr, [x.frames,x.extra]); + }); + }) + }, + + getlocals : function(threadid, frame, extra) { + var method = this._findmethod(this.session.classes, frame.location.cid, frame.location.mid); + if (!method) + return $.Deferred().resolveWith(this); + + return this._ensuremethodvars(method) + .then(function(method) { + + function withincodebounds(low, length, idx) { + var i=parseInt(low, 16), j=parseInt(idx, 16); + return (j>=i) && (j<(i+length)); + } + + var slots = []; + var validslots = []; + var tags = {'[':76,B:66,C:67,L:76,F:70,D:68,I:73,J:74,S:83,V:86,Z:90}; + for (var i=0, k = method.vartable.vars; i < k.length; i++) { + var tag = tags[k[i].type.signature[0]]; + if (!tag) continue; + var p = { + slot:k[i].slot, + tag:tag, + valid:withincodebounds(k[i].codeidx, k[i].length, frame.location.idx) + }; + slots.push(p); + if (p.valid) validslots.push(p); + } + + var x = {method:method, extra:extra, slots:slots}; + + if (!validslots.length) { + return $.Deferred().resolveWith(this, [[], x]); + } + + return this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.GetStackValues(threadid, frame.frameid, validslots), + }); + }) + .then(function(values, x) { + var sv2 = []; + for (var i=0; i < x.slots.length; i++) { + sv2.push(x.slots[i].valid?values.shift():null); + } + return this._mapvalues( + 'local', + x.method.vartable.vars, + sv2, + {frame: frame, slotinfo:null}, + x + ); + }) + .then(function(res, x) { + for (var i=0; i < res.length; i++) + res[i].data.slotinfo = x.slots[i]; + return $.Deferred().resolveWith(this, [res, x.extra]); + }); + }, + + setlocalvalue : function(localvar, data, extra) { + return this.ensureconnected({localvar:localvar, data:data, extra:extra}) + .then(function(x) { + return this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.SetStackValue(x.localvar.data.frame.threadid, x.localvar.data.frame.frameid, x.localvar.data.slotinfo.slot, x.data), + }); + }) + .then(function(success, x) { + return this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.GetStackValues(x.localvar.data.frame.threadid, x.localvar.data.frame.frameid, [x.localvar.data.slotinfo]), + }); + }) + .then(function(stackvalues, x) { + return this._mapvalues( + 'local', + [x.localvar], + stackvalues, + x.localvar.data, + x + ); + }) + .then(function(res, x) { + return $.Deferred().resolveWith(this, [res[0], x.extra]); + }); + }, + + getsupertype : function(local, extra) { + return this.gettypedebuginfo(local.type.signature, {local:local, extra:extra}) + .then(function(dbgtype, x) { + return this._ensuresuper(dbgtype[x.local.type.signature]) + }) + .then(function(typeinfo) { + return $.Deferred().resolveWith(this, [typeinfo.super, extra]); + }); + }, + + createstring : function(string, extra) { + return this.ensureconnected({string:string, extra:extra}) + .then(function(x) { + return this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.CreateStringObject(string), + }); + }) + .then(function(strobjref, x) { + var keys = [{name:'', type:this.JDWP.signaturetotype('Ljava/lang/String;')}]; + return this._mapvalues('literal', keys, [strobjref], null, x); + }) + .then(function(vars, x) { + return $.Deferred().resolveWith(this, [vars[0], x.extra]); + }); + }, + + setstringvalue : function(variable, string, extra) { + return this.createstring(string, {variable:variable, extra:extra}) + .then(function(string_variable, x) { + var value = { + value:string_variable.value, + valuetype:'oref', + }; + return this.setvalue(x.variable, value, x.extra); + }) + }, + + setvalue : function(variable, data, extra) { + if (data.stringliteral) { + return this.setstringvalue(variable, data.value, extra); + } + switch(variable.vtype) { + case 'field': return this.setfieldvalue(variable, data, extra); + case 'local': return this.setlocalvalue(variable, data, extra); + case 'arrelem': + return this.setarrayvalues(variable.data.arrobj, parseInt(variable.name), 1, data, extra) + .then(function(res, extra) { + // setarrayvalues returns an array of updated elements - just return the one + return $.Deferred().resolveWith(this, [res[0], extra]); + }); + } + }, + + setfieldvalue : function(fieldvar, data, extra) { + return this.ensureconnected({fieldvar:fieldvar, data:data, extra:extra}) + .then(function(x) { + return this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.SetFieldValue(x.fieldvar.data.objvar.value, x.fieldvar.data.field, x.data), + }); + }) + .then(function(success, x) { + return this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.GetFieldValues(x.fieldvar.data.objvar.value, [x.fieldvar.data.field]), + }); + }) + .then(function(fieldvalues, x) { + return this._mapvalues('field', [x.fieldvar.data.field], fieldvalues, x.fieldvar.data, x); + }) + .then(function(data, x) { + return $.Deferred().resolveWith(this, [data[0], x.extra]); + }); + }, + + getfieldvalues : function(objvar, extra) { + return this.gettypedebuginfo(objvar.type.signature, {objvar:objvar, extra:extra}) + .then(function(dbgtype, x) { + return this._ensurefields(dbgtype[x.objvar.type.signature], x); + }) + .then(function(typeinfo, x) { + x.typeinfo = typeinfo; + return this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.GetFieldValues(x.objvar.value, typeinfo.fields), + }); + }) + .then(function(fieldvalues, x) { + return this._mapvalues('field', x.typeinfo.fields, fieldvalues, {objvar:x.objvar}, x); + }) + .then(function(res, x) { + for (var i=0; i < res.length; i++) { + res[i].data.field = x.typeinfo.fields[i]; + } + return $.Deferred().resolveWith(this, [res, x.extra]); + }); + }, + + getstringchars : function(stringref, extra) { + return this.session.adbclient.jdwp_command({ + ths: this, + extra: extra, + cmd: this.JDWP.Commands.GetStringValue(stringref), + }); + }, + + _getstringlen : function(stringref, extra) { + return this.gettypedebuginfo('Ljava/lang/String;', {stringref:stringref, extra:extra}) + .then(function(dbgtype, x) { + return this._ensurefields(dbgtype['Ljava/lang/String;'], x); + }) + .then(function(typeinfo, x) { + var countfields = typeinfo.fields.filter(f => f.name==='count'); + if (!countfields.length) return -1; + return this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.GetFieldValues(x.stringref, countfields), + }); + }) + .then(function(countfields, x) { + var len = (countfields && countfields.length===1) ? countfields[0] : -1; + return $.Deferred().resolveWith(this, [len, x.extra]); + }); + }, + + getarrayvalues : function(local, start, count, extra) { + return this.gettypedebuginfo(local.type.elementtype.signature, {local:local, start:start, count:count, extra:extra}) + .then(function(dbgtype, x) { + x.type = dbgtype[x.local.type.elementtype.signature].type; + return this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.GetArrayValues(x.local.value, x.start, x.count), + }); + }) + .then(function(values, x) { + // generate some dummy keys to map against + var keys = []; + for (var i=0; i < x.count; i++) { + keys.push({name:''+(x.start+i), type:x.type}); + } + return this._mapvalues('arrelem', keys, values, {arrobj:x.local}, x.extra); + }); + }, + + setarrayvalues : function(arrvar, start, count, data, extra) { + return this.ensureconnected({arrvar:arrvar, start:start, count:count, data:data, extra:extra}) + .then(function(x) { + return this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.SetArrayElements(x.arrvar.value, x.start, x.count, x.data), + }); + }) + .then(function(success, x) { + return this.session.adbclient.jdwp_command({ + ths: this, + extra: x, + cmd: this.JDWP.Commands.GetArrayValues(x.arrvar.value, x.start, x.count), + }); + }) + .then(function(values, x) { + // generate some dummy keys to map against + var keys = []; + for (var i=0; i < count; i++) { + keys.push({name:''+(x.start+i), type:x.arrvar.type.elementtype}); + } + return this._mapvalues('arrelem', keys, values, {arrobj:x.arrvar}, x.extra); + }); + }, + + _mapvalues : function(vtype, keys, values, data, extra) { + var res = []; + var arrayfields = []; + var stringfields = []; + + if (values && Array.isArray(values)) { + var v = values.slice(0), i=0; + while (v.length) { + var info = { + vtype: vtype, + name: keys[i].name, + value: v.shift(), + type: keys[i].type, + hasnullvalue: false, + valid: true, + data:Object.assign({},data), + }; + info.hasnullvalue = /^0+$/.test(info.value); + info.valid = info.value!==null; + res.push(info); + if (keys[i].type.arraydims) + arrayfields.push(info); + else if (keys[i].type.signature==='Ljava/lang/String;') + stringfields.push(info); + i++; + } + } + var defs = [{dbgr:this, res:res, extra:extra}]; + // for those fields that are (non-null) arrays, retrieve the length + for (var i in arrayfields) { + if (arrayfields[i].hasnullvalue || !arrayfields[i].valid) continue; + var def = this.session.adbclient.jdwp_command({ + ths: this, + extra: arrayfields[i], + cmd: this.JDWP.Commands.GetArrayLength(arrayfields[i].value), + }) + .then(function(arrlen, arrfield) { + arrfield.arraylen = arrlen; + }); + defs.push(def); + } + // for those fields that are strings, retrieve the text + for (var i in stringfields) { + if (stringfields[i].hasnullvalue || !stringfields[i].valid) continue; + var def = this._getstringlen(stringfields[i].value) + .then(function(len) { + if (len > 10000) + return $.Deferred().resolveWith(this, [len, stringfields[i]]); + // retrieve the actual chars + return this.getstringchars(stringfields[i].value, stringfields[i]); + }) + .then(function(str, strfield) { + if (typeof(str)==='number') { + strfield.string = '{string exceeds maximum display length}'; + strfield.biglen = str; + } else { + strfield.string = str; + } + }); + defs.push(def); + } + + return $.when.apply($, defs) + .then(function(x) { + return $.Deferred().resolveWith(x.dbgr, [x.res, x.extra]); + }); + }, + + gettypedebuginfo : function(signature, extra) { + + var info = { + signature:signature, + classes:{}, + ci:{ type: this.JDWP.signaturetotype(signature), }, + extra: extra, + deferred: $.Deferred(), + }; + + if (this.session) { + // see if we've already retrieved the type for this session + var cached = this.session.classes[signature]; + if (cached) { + // are we still retrieving it... + if (cached.promise) { + return cached.promise(); + } + // return the cached entry + var res = {}; res[signature] = cached; + return $.Deferred().resolveWith(this, [res, extra]); + } + // while we're retrieving it, set a deferred in it's place + this.session.classes[signature] = info.deferred; + } + + this.ensureconnected(info) + .then(function(info) { + return this.session.adbclient.jdwp_command({ + ths: this, + extra: info, + cmd: this.JDWP.Commands.classinfo(info.ci), + }); + }) + .then(function(classinfoarr, info) { + if (!classinfoarr || !classinfoarr.length) { + if (this.session) + delete this.session.classes[info.signature]; + return info.deferred.resolveWith(this, [{}, info.extra]); + } + info.ci.info = classinfoarr[0]; + info.ci.name = info.ci.type.typename; + info.classes[info.ci.type.signature] = info.ci; + + // querying the source file for array or primitive types causes the app to crash + return (info.ci.type.signature[0]!=='L' + ? $.Deferred().resolveWith(this, [[null], info]) + : this.session.adbclient.jdwp_command({ + ths: this, + extra: info, + cmd: this.JDWP.Commands.sourcefile(info.ci), + })) + .then(function(srcinfoarr, info) { + info.ci.src = srcinfoarr[0]; + if (this.session) { + Object.assign(this.session.classes, info.classes); + } + return info.deferred.resolveWith(this, [info.classes, info.extra]); // done + }); + }); + + return info.deferred; + }, + + _ensuresuper : function(typeinfo) { + if (typeinfo.super||typeinfo.super===null) { + if (typeinfo.super && typeinfo.super.promise) + return typeinfo.super.promise(); + return $.Deferred().resolveWith(this, [typeinfo]); + } + if (typeinfo.info.reftype.string!=='class'||typeinfo.type.signature[0]!=='L'||typeinfo.type.signature==='Ljava/lang/Object;') { + typeinfo.super=null; + return $.Deferred().resolveWith(this, [typeinfo]); + } + + typeinfo.super = $.Deferred(); + this.session.adbclient.jdwp_command({ + ths: this, + extra: typeinfo, + cmd: this.JDWP.Commands.superclass(typeinfo), + }) + .then(function(superclassref, typeinfo) { + return this.session.adbclient.jdwp_command({ + ths: this, + extra: typeinfo, + cmd: this.JDWP.Commands.signature(superclassref), + }); + }) + .then(function(supertype, typeinfo) { + var def = typeinfo.super; + typeinfo.super=supertype; + def.resolveWith(this, [typeinfo]); + }); + + return typeinfo.super.promise(); + }, + + _ensurefields : function(typeinfo, extra) { + if (typeinfo.fields) { + if (typeinfo.fields.promise) + return typeinfo.fields.promise(); + return $.Deferred().resolveWith(this, [typeinfo, extra]); + } + typeinfo.fields = $.Deferred(); + + this.session.adbclient.jdwp_command({ + ths: this, + extra: {typeinfo:typeinfo, extra:extra} , + cmd: this.JDWP.Commands.fieldsWithGeneric(typeinfo), + }) + .then(function(fields, x) { + var def = x.typeinfo.fields; + x.typeinfo.fields = fields; + def.resolveWith(this, [x.typeinfo, x.extra]); + }); + + return typeinfo.fields.promise(); + }, + + _ensuremethods : function(typeinfo) { + if (typeinfo.methods) { + if (typeinfo.methods.promise) + return typeinfo.methods.promise(); + return $.Deferred().resolveWith(this, [typeinfo]); + } + typeinfo.methods = $.Deferred(); + + this.session.adbclient.jdwp_command({ + ths: this, + extra: typeinfo, + cmd: this.JDWP.Commands.methodsWithGeneric(typeinfo), + }) + .then(function(methods, typeinfo) { + var def = typeinfo.methods; + typeinfo.methods = {}; + for (var i in methods) { + methods[i].owningclass = typeinfo; + typeinfo.methods[methods[i].methodid] = methods[i]; + } + def.resolveWith(this, [typeinfo]); + }); + + return typeinfo.methods.promise(); + }, + + _ensuremethodvars : function(methodinfo) { + if (methodinfo.vartable) { + if (methodinfo.vartable.promise) + return methodinfo.vartable.promise(); + return $.Deferred().resolveWith(this, [methodinfo]); + } + methodinfo.vartable = $.Deferred(); + + this.session.adbclient.jdwp_command({ + ths: this, + extra: methodinfo, + cmd: this.JDWP.Commands.VariableTableWithGeneric(methodinfo.owningclass, methodinfo), + }) + .then(function(vartable, methodinfo) { + var def = methodinfo.vartable; + methodinfo.vartable = vartable; + def.resolveWith(this, [methodinfo]); + }); + + return methodinfo.vartable.promise(); + }, + + _ensuremethodlines : function(methodinfo) { + if (methodinfo.linetable) { + if (methodinfo.linetable.promise) + return methodinfo.linetable.promise(); + return $.Deferred().resolveWith(this, [methodinfo]); + } + methodinfo.linetable = $.Deferred(); + + this.session.adbclient.jdwp_command({ + ths: this, + extra: methodinfo, + cmd: this.JDWP.Commands.lineTable(methodinfo.owningclass, methodinfo), + }) + .then(function(linetable, methodinfo) { + // the linetable does not correlate code indexes with line numbers + // - location searching relies on the table being ordered by code indexes + linetable.lines.sort(function(a,b){ + return (a.linecodeidx===b.linecodeidx)?0:((a.linecodeidx idx) + k=prevk; + // convert the class signature to a file location + var m = method.owningclass.type.signature.match(/^L([^;$]+)[$a-zA-Z0-9_]*;$/); + if (!m) + return null; + return { + qtype:m[1], + linenum:lines[k].linenum, + }; + } + return null; + }, + + _findcmllocation : function(classes, loc) { + // search the classes for a method containing the line + return this._findmethodasync(classes, loc) + .then(function(method) { + if (!method) + return $.Deferred().resolveWith(this, [null]); + return this._ensuremethodlines(method) + .then(function(method) { + var srcloc = this.line_idx_to_source_location(method, loc.idx); + return $.Deferred().resolveWith(this, [srcloc]); + }); + }); + }, + + _findmethodasync : function(classes, location) { + var m = this._findmethod(classes, location.cid, location.mid); + if (m) return $.Deferred().resolveWith(this, [m]); + // convert the classid to a type signature + return this.session.adbclient.jdwp_command({ + ths:this, + extra:{location:location}, + cmd: this.JDWP.Commands.signature(location.cid), + }) + .then(function(type, x) { + return this.gettypedebuginfo(type.signature, x); + }) + .then(function(classes, x) { + var defs = [{dbgr:this, classes:classes, x:x}]; + for(var clz in classes) { + defs.push(this._ensuremethods(classes[clz])); + } + return $.when.apply($, defs).then(function(x) { + return $.Deferred().resolveWith(x.dbgr, [x.classes, x.x]); + }) + }) + .then(function(classes, x) { + var m = this._findmethod(classes, x.location.cid, x.location.mid); + return $.Deferred().resolveWith(this, [m]); + }); + }, + + _findmethod : function(classes, classid, methodid) { + for (var i in classes) { + if (classes[i]._isdeferred) + continue; + if (classes[i].info.typeid !== classid) + continue; + for (var j in classes[i].methods) { + if (classes[i].methods[j].methodid !== methodid) + continue; + return classes[i].methods[j]; + } + } + return null; + }, + + _finitbreakpoints : function() { + this._changebpstate(this.breakpoints.all, 'set'); + this.breakpoints.enabled = {}; + }, + +}; + +exports.Debugger = Debugger; diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..2444d8e --- /dev/null +++ b/extension.js @@ -0,0 +1,31 @@ +// The module 'vscode' contains the VS Code extensibility API +// Import the module and reference it with the alias vscode in your code below +var vscode = require('vscode'); + +// this method is called when your extension is activated +// your extension is activated the very first time the command is executed +function activate(context) { + + /* Nothing is done here. The debugger is launched from debugMain.js */ + + // The commandId parameter must match the command field in package.json + var disposables = [ + /* + vscode.commands.registerCommand('extension.doCommand', config => { + return vscode.window.showInputBox({ + placeHolder: "Enter a value", + value: "a value to display" + }); + }) + */ + ]; + + var spliceparams = [context.subscriptions.length,0].concat(disposables); + Array.prototype.splice.apply(context.subscriptions,spliceparams); +} +exports.activate = activate; + +// this method is called when your extension is deactivated +function deactivate() { +} +exports.deactivate = deactivate; \ No newline at end of file diff --git a/jdwp.js b/jdwp.js new file mode 100644 index 0000000..7ab36f8 --- /dev/null +++ b/jdwp.js @@ -0,0 +1,1056 @@ +const $ = require('./jq-promise'); +const { atob,btoa,D,getutf8bytes,fromutf8bytes,intToHex } = require('./util'); +/* + JDWP - The Java Debug Wire Protocol +*/ +function _JDWP() { + var gCommandId = 0; + var gCommandList = []; + var gEventCallbacks = {}; + + function Command(name, cs, cmd, outdatafn, replydecodefn) { + this.length = 11; + this.id = ++gCommandId; + this.flags = 0; + this.commandset = cs; + this.command = cmd; + this.rawdata = outdatafn?outdatafn():[]; + + this.length = 11 + this.rawdata.length; + gCommandList[this.id] = this; + + this.name = name; + this.replydecodefn = replydecodefn; + this.deferred = $.Deferred(); + } + + Command.prototype = { + promise : function() { + return this.deferred.promise(); + }, + toRawString : function() { + var s = ''; + s += String.fromCharCode((this.length >> 24)&255); + s += String.fromCharCode((this.length >> 16)&255); + s += String.fromCharCode((this.length >> 8)&255); + s += String.fromCharCode((this.length)&255); + s += String.fromCharCode((this.id >> 24)&255); + s += String.fromCharCode((this.id >> 16)&255); + s += String.fromCharCode((this.id >> 8)&255); + s += String.fromCharCode((this.id)&255); + s += String.fromCharCode(this.flags); + s += String.fromCharCode(this.commandset); + s += String.fromCharCode(this.command); + var i=this.rawdata.length, j=0; + while (--i>=0) { + s += String.fromCharCode(this.rawdata[j++]); + } + return s; + }, + tob64 : function() { + return btoa(this.toRawString()); + } + }; + + function Reply(s) { + this.length = s.charCodeAt(0) << 24; + this.length += s.charCodeAt(1) << 16; + this.length += s.charCodeAt(2) << 8; + this.length += s.charCodeAt(3); + this.id = s.charCodeAt(4) << 24; + this.id += s.charCodeAt(5) << 16; + this.id += s.charCodeAt(6) << 8; + this.id += s.charCodeAt(7); + this.flags = s.charCodeAt(8)|0; + this.errorcode = s.charCodeAt(9) << 8; + this.errorcode += s.charCodeAt(10); + this.rawdata = new Array(s.length-11); + var i=0, j=this.rawdata.length; + while (--j>=0) { + this.rawdata[i]=s.charCodeAt(i+11); + i++; + } + this.command = gCommandList[this.id]; + + if (this.errorcode===16484) { + // errorcode===16484 (0x4064) means a composite event command (set 64,cmd 100) sent from the VM + this.errorcode=0; + this.isevent=!0; + this.decoded=DataCoder.decodeCompositeEvent({ + idx:0, + data:this.rawdata.slice() + }); + // call any registered event callbacks + for (var i in this.decoded.events) { + var event = this.decoded.events[i]; + var cbinfo = event.reqid && gEventCallbacks[event.reqid]; + if (cbinfo) { + var e = { + data:cbinfo.callback.data, + event:event, + reply:this, + }; + cbinfo.callback.fn.call(cbinfo.callback.ths, e); + } + } + return; + } + + if (this.errorcode != 0) { + console.error("Command failed: error " + this.errorcode); + } + + if (!this.errorcode && this.command && this.command.replydecodefn) { + // try and decode the values + this.decoded = this.command.replydecodefn({ + idx:0, + data:this.rawdata.slice() + }); + return; + } + + this.decoded = {empty:true}; + } + + this.decodereply = function(ths,s) { + var reply = new Reply(s); + if (reply.command) { + reply.command.deferred.resolveWith(ths, [reply.decoded, reply.command, reply]); + } + return reply; + }; + + this.signaturetotype = function(s) { + return DataCoder.signaturetotype(s); + } + + this.setIDSizes = function(idsizes) { + DataCoder._idsizes = idsizes; + } + + var DataCoder = { + _idsizes:null, + + decodeString: function(o) { + var rd = o.data; + var utf8len=(rd[o.idx++]<<24)+(rd[o.idx++]<<16)+(rd[o.idx++]<<8)+(rd[o.idx++]); + if (utf8len > 10000) + utf8len = 10000; // just to prevent hangs if the decoding is wrong + var res=fromutf8bytes(o.data.slice(o.idx, o.idx+utf8len)); + o.idx+= utf8len; + return res; + }, + decodeLong: function(o, hexstring) { + var rd = o.data; + var res1=(rd[o.idx++]<<24)+(rd[o.idx++]<<16)+(rd[o.idx++]<<8)+(rd[o.idx++]); + var res2=(rd[o.idx++]<<24)+(rd[o.idx++]<<16)+(rd[o.idx++]<<8)+(rd[o.idx++]); + return intToHex(res1,8)+intToHex(res2,8); + }, + decodeInt: function(o) { + var rd = o.data; + var res=(rd[o.idx++]<<24)+(rd[o.idx++]<<16)+(rd[o.idx++]<<8)+(rd[o.idx++]); + return res; + }, + decodeByte: function(o) { + var i = o.data[o.idx++]; + return i<128?i:i-256; + }, + decodeShort: function(o) { + var i = (o.data[o.idx++]<<8)+o.data[o.idx++]; + return i<32768?i:i-65536; + }, + decodeChar: function(o) { + return String.fromCharCode((o.data[o.idx++]<<8)+o.data[o.idx++]); + }, + decodeBoolean: function(o) { + return o.data[o.idx++] != 0; + }, + decodeDecimal: function(bytes, signBits, exponentBits, fractionBits, eMin, eMax, littleEndian) { + var totalBits = (signBits + exponentBits + fractionBits); + + var binary = ""; + for (var i = 0, l = bytes.length; i < l; i++) { + var bits = bytes[i].toString(2); + while (bits.length < 8) + bits = "0" + bits; + + if (littleEndian) + binary = bits + binary; + else + binary += bits; + } + + var sign = (binary.charAt(0) == '1')?-1:1; + var exponent = parseInt(binary.substr(signBits, exponentBits), 2) - eMax; + var significandBase = binary.substr(signBits + exponentBits, fractionBits); + var significandBin = '1'+significandBase; + var i = 0; + var val = 1; + var significand = 0; + + if (exponent+eMax===((eMax*2)+1)) { + if (significandBase.indexOf('1')<0) + return sign>0?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY; + return Number.NaN; + } + if (exponent == -eMax) { + if (significandBase.indexOf('1') == -1) + return 0; + else { + exponent = eMin; + significandBin = '0'+significandBase; + } + } + + while (i < significandBin.length) { + significand += val * parseInt(significandBin.charAt(i)); + val = val / 2; + i++; + } + + return sign * significand * Math.pow(2, exponent); + }, + decodeFloat: function(o) { + var bytes = o.data.slice(o.idx, o.idx+=4); + return this.decodeDecimal(bytes, 1, 8, 23, -126, 127, false); + }, + decodeDouble: function(o) { + var bytes = o.data.slice(o.idx, o.idx+=8); + return this.decodeDecimal(bytes, 1, 11, 52, -1022, 1023, false); + }, + decodeRef: function(o, bytes) { + var rd = o.data; + var res = ''; + while (--bytes>=0) { + res += ('0'+rd[o.idx++].toString(16)).slice(-2); + } + return res; + }, + decodeTRef: function(o) { + return this.decodeRef(o,this._idsizes.reftypeidsize); + }, + decodeORef: function(o) { + return this.decodeRef(o,this._idsizes.objectidsize); + }, + decodeMRef: function(o) { + return this.decodeRef(o,this._idsizes.methodidsize); + }, + decodeRefType : function(o) { + return this.mapvalue(this.decodeByte(o), [null,'class','interface','array']); + }, + decodeStatus : function(o) { + return this.mapflags(this.decodeInt(o), ['verified','prepared','initialized','error']); + }, + decodeValue : function(o) { + var rd = o.data; + return this.tagtodecoder(rd[o.idx++]).call(this, o); + }, + tagtodecoder: function(tag) { + switch (tag) { + case 91: + case 76: + case 115: + case 116: + case 103: + case 108: + case 99: + return this.decodeORef; + case 66: + return this.decodeByte; + case 90: + return this.decodeBoolean; + case 67: + return this.decodeChar; + case 83: + return this.decodeShort; + case 70: + return this.decodeFloat; + case 73: + return this.decodeInt; + case 68: + return this.decodeDouble; + case 74: + return this.decodeLong; + case 86: + return function() { return 'void'; }; + } + }, + mapvalue : function(value,values) { + return {value: value, string:values[value] }; + }, + mapflags : function(value,values) { + var res = {value: value, string:'[]'}; + var flgs=[]; + for (var i=value,j=0;i;i>>=1) { + if ((i&1)&&(values[j])) + flgs.push(values[j]); + j++; + } + res.string = '['+flgs.join('|')+']'; + return res; + }, + decodeList: function(o, list) { + var res = {}; + while (list.length) { + var next = list.shift(); + for ( var key in next) { + switch(next[key]) { + case 'string': res[key]=this.decodeString(o); break; + case 'int': res[key]=this.decodeInt(o); break; + case 'long': res[key]=this.decodeLong(o); break; + case 'byte': res[key]=this.decodeByte(o); break; + case 'fref': res[key]=this.decodeRef(o,this._idsizes.fieldidsize); break; + case 'mref': res[key]=this.decodeRef(o,this._idsizes.methodidsize); break; + case 'oref': res[key]=this.decodeRef(o,this._idsizes.objectidsize); break; + case 'tref': res[key]=this.decodeRef(o,this._idsizes.reftypeidsize); break; + case 'frameid': res[key]=this.decodeRef(o,this._idsizes.frameidsize); break; + case 'reftype': res[key]=this.decodeRefType(o); break; + case 'status': res[key]=this.decodeStatus(o); break; + case 'location': res[key]=this.decodeLocation(o); break; + case 'signature': res[key]=this.decodeTypeFromSignature(o); break; + case 'codeindex': res[key]=this.decodeLong(o, true); break; + } + } + } + return res; + }, + decodeLocation : function(o) { + return { + type: o.data[o.idx++], + cid: this.decodeTRef(o), + mid: this.decodeMRef(o), + idx: this.decodeLong(o, true), + }; + }, + decodeTypeFromSignature : function(o) { + var sig = this.decodeString(o); + return this.signaturetotype(sig); + }, + decodeCompositeEvent: function (o) { + var rd = o.data; + var res = {}; + res.suspend = rd[o.idx++]; + res.events = []; + var arrlen = this.decodeInt(o); + while (--arrlen>=0) { + // all event types return kind+requestid as their first entries + var event = { + kind:{name:'', value:rd[o.idx++]}, + }; + var eventkinds = ['','step','breakpoint','framepop','exception','userdefined','threadstart','threadend','classprepare','classunload','classload']; + event.kind.name = eventkinds[event.kind.value]; + switch(event.kind.value) { + case 1: // step + case 2: // breakpoint + event.reqid = this.decodeInt(o); + event.threadid = this.decodeORef(o); + event.location = this.decodeLocation(o); + break; + case 8: // classprepare + event.reqid = this.decodeInt(o); + event.threadid = this.decodeORef(o); + event.reftype = this.decodeByte(o); + event.typeid = this.decodeTRef(o); + event.type = this.decodeTypeFromSignature(o); + event.status = this.decodeStatus(o); + break; + } + res.events.push(event); + } + return res; + }, + + encodeByte : function(res, i) { + res.push(i&255); + }, + encodeBoolean : function(res, b) { + res.push(b?1:0); + }, + encodeShort : function(res, i) { + res.push((i>>8)&255); + res.push((i)&255); + }, + encodeInt : function(res, i) { + res.push((i>>24)&255); + res.push((i>>16)&255); + res.push((i>>8)&255); + res.push((i)&255); + }, + encodeChar: function(res, c) { + this.encodeShort(res, c.charCodeAt(0)); + }, + encodeString : function(res, s) { + var utf8bytes = getutf8bytes(s); + this.encodeInt(res, utf8bytes.length); + for (var i=0; i < utf8bytes.length; i++) + res.push(utf8bytes[i]); + }, + encodeRef: function(res, ref) { + for(var i=0; i < ref.length; i+=2) { + res.push(parseInt(ref.substring(i,i+2), 16)); + } + }, + encodeLong: function(res, l) { + for(var i=0; i < l.length; i+=2) { + res.push(parseInt(l.substring(i,i+2), 16)); + } + }, + encodeDouble: function(res, value) { + var hiWord = 0, loWord = 0; + switch (value) { + case Number.POSITIVE_INFINITY: hiWord = 0x7FF00000; break; + case Number.NEGATIVE_INFINITY: hiWord = 0xFFF00000; break; + case +0.0: hiWord = 0x00000000; break;//0x40000000; break; + case -0.0: hiWord = 0x80000000; break;//0xC0000000; break; + default: + if (Number.isNaN(value)) { hiWord = 0x7FF80000; break; } + + if (value <= -0.0) { + hiWord = 0x80000000; + value = -value; + } + + var exponent = Math.floor(Math.log(value) / Math.log(2)); + var significand = Math.floor((value / Math.pow(2, exponent)) * Math.pow(2, 52)); + + loWord = significand & 0xFFFFFFFF; + significand /= Math.pow(2, 32); + + exponent += 1023; + if (exponent >= 0x7FF) { + exponent = 0x7FF; + significand = 0; + } else if (exponent < 0) exponent = 0; + + hiWord = hiWord | (exponent << 20); + hiWord = hiWord | (significand & ~(-1 << 20)); + break; + } + this.encodeInt(res, hiWord); + this.encodeInt(res, loWord); + }, + encodeFloat: function(res, value) { + var bytes = 0; + switch (value) { + case Number.POSITIVE_INFINITY: bytes = 0x7F800000; break; + case Number.NEGATIVE_INFINITY: bytes = 0xFF800000; break; + case +0.0: bytes = 0x00000000; break;//0x40000000; break; + case -0.0: bytes = 0x80000000; break;//0xC0000000l + default: + if (Number.isNaN(value)) { bytes = 0x7FC00000; break; } + + if (value <= -0.0) { + bytes = 0x80000000; + value = -value; + } + + var exponent = Math.floor(Math.log(value) / Math.log(2)); + var significand = ((value / Math.pow(2, exponent)) * 0x00800000) | 0; + + exponent += 127; + if (exponent >= 0xFF) { + exponent = 0xFF; + significand = 0; + } else if (exponent < 0) exponent = 0; + + bytes = bytes | (exponent << 23); + bytes = bytes | (significand & ~(-1 << 23)); + break; + } + + this.encodeInt(res, bytes); + }, + encodeValue: function(res, key, data) { + switch(key) { + case 'byte': this.encodeByte(res, data); break; + case 'short': this.encodeShort(res, data); break; + case 'int': this.encodeInt(res, data); break; + case 'long': this.encodeLong(res, data); break; + case 'boolean': this.encodeBoolean(res, data); break; + case 'char': this.encodeChar(res, data); break; + case 'float': this.encodeFloat(res, data); break; + case 'double': this.encodeDouble(res, data); break; + // note that strings are encoded as object references... + case 'oref': this.encodeRef(res,data); break; + } + }, + + encodeTaggedValue: function(res, key, data) { + switch(key) { + case 'byte': res.push(66); break; + case 'short': res.push(83); break; + case 'int': res.push(73); break; + case 'long': res.push(74); break; + case 'boolean': res.push(90); break; + case 'char': res.push(67); break; + case 'float': res.push(70); break; + case 'double': res.push(68); break; + case 'void': res.push(86); break; + // note that strings are encoded as object references... + case 'oref': res.push(76); break; + } + this.encodeValue(res, key, data); + }, + + signaturetotype:function(signature) { + var m = signature.match(/^L([^$]+)\/([^$\/]+)(\$.+)?;$/); + if (m) { + return { + signature: signature, + package: m[1].replace(/\//g,'.'), + typename: (m[2]+(m[3]||'')).replace(/\$(?=[^\d])/g,'.'), + anonymous: /\$\d/.test(m[3]), + } + } + m = signature.match(/^(\[+)(.+)$/); + if (m) { + var elementtype = this.signaturetotype(m[2]); + return { + signature:signature, + arraydims:m[1].length, + elementtype: elementtype, + typename:elementtype.typename+m[1].replace(/\[/g,'[]'), + } + } + var primitivetypes = { + B: { signature:'B', typename:'byte', primitive:true, }, + C: { signature:'C', typename:'char', primitive:true, }, + F: { signature:'F', typename:'float', primitive:true, }, + D: { signature:'D', typename:'double', primitive:true, }, + I: { signature:'I', typename:'int', primitive:true, }, + J: { signature:'J', typename:'long', primitive:true, }, + S: { signature:'S', typename:'short', primitive:true, }, + V: { signature:'V', typename:'void', primitive:true, }, + Z: { signature:'Z', typename:'boolean', primitive:true, }, + } + var res = (signature.length===1)?primitivetypes[signature[0]]:null; + if (res) return res; + return { + signature:signature, + typename:signature, + invalid:true, + } + }, + }; + + //var Commands = { + this.Commands = { + version:function() { + return new Command('version',1, 1, + null, + function (o) { + return DataCoder.decodeList(o, [{description:'string'},{major:'int'},{minor:'int'},{version:'string'},{name:'string'}]); + } + ); + }, + idsizes:function() { + return new Command('IDSizes', 1, 7, + function() { + return []; + }, + function(o) { + return DataCoder.decodeList(o, [{fieldidsize:'int'},{methodidsize:'int'},{objectidsize:'int'},{reftypeidsize:'int'},{frameidsize:'int'}]); + } + ); + }, + classinfo:function(ci) { + return new Command('ClassesBySignature:'+ci.name, 1, 2, + function() { + var res=[]; + DataCoder.encodeString(res, ci.type.signature); + return res; + }, + function(o) { + var arrlen = DataCoder.decodeInt(o); + var res = []; + while (--arrlen>=0) { + res.push(DataCoder.decodeList(o, [{reftype:'reftype'},{typeid:'tref'},{status:'status'}])); + } + return res; + } + ); + }, + fields:function(ci) { + // not supported by Dalvik + return new Command('Fields:'+ci.name, 2, 4, + function() { + var res=[]; + DataCoder.encodeRef(res, ci.info.typeid); + return res; + }, + function(o) { + var arrlen = DataCoder.decodeInt(o); + var res = []; + while (--arrlen>=0) { + res.push(DataCoder.decodeList(o, [{fieldid:'fref'},{name:'string'},{sig:'string'},{modbits:'int'}])); + } + return res; + } + ); + }, + methods:function(ci) { + // not supported by Dalvik - use methodsWithGeneric + return new Command('Methods:'+ci.name, 2, 5, + function() { + var res=[]; + DataCoder.encodeRef(res, ci.info.typeid); + return res; + }, + function(o) { + var arrlen = DataCoder.decodeInt(o); + var res = []; + while (--arrlen>=0) { + res.push(DataCoder.decodeList(o, [{methodid:'mref'},{name:'string'},{sig:'string'},{modbits:'int'}])); + } + return res; + } + ); + }, + sourcefile:function(ci) { + return new Command('SourceFile:'+ci.name, 2, 7, + function() { + var res=[]; + DataCoder.encodeRef(res, ci.info.typeid); + return res; + }, + function(o) { + return [{'sourcefile':DataCoder.decodeString(o)}]; + } + ); + }, + fieldsWithGeneric:function(ci) { + return new Command('FieldsWithGeneric:'+ci.name, 2, 14, + function() { + var res=[]; + DataCoder.encodeRef(res, ci.info.typeid); + return res; + }, + function(o) { + var arrlen = DataCoder.decodeInt(o); + var res = []; + while (--arrlen>=0) { + res.push(DataCoder.decodeList(o, [{fieldid:'fref'},{name:'string'},{type:'signature'},{genericsig:'string'},{modbits:'int'}])); + } + return res; + } + ); + }, + methodsWithGeneric:function(ci) { + return new Command('MethodsWithGeneric:'+ci.name, 2, 15, + function() { + var res=[]; + DataCoder.encodeRef(res, ci.info.typeid); + return res; + }, + function(o) { + var arrlen = DataCoder.decodeInt(o); + var res = []; + while (--arrlen>=0) { + res.push(DataCoder.decodeList(o, [{methodid:'mref'},{name:'string'},{sig:'string'},{genericsig:'string'},{modbits:'int'}])); + } + return res; + } + ); + }, + superclass:function(ci) { + return new Command('Superclass:'+ci.name, 3, 1, + function() { + var res=[]; + DataCoder.encodeRef(res, ci.info.typeid); + return res; + }, + function(o) { + return DataCoder.decodeTRef(o); + } + ); + }, + signature:function(typeid) { + return new Command('Signature:'+typeid, 2, 1, + function() { + var res=[]; + DataCoder.encodeRef(res, typeid); + return res; + }, + function(o) { + return DataCoder.decodeTypeFromSignature(o); + } + ); + }, + nestedTypes:function(ci) { + return new Command('NestedTypes:'+ci.name, 2, 8, + function() { + var res=[]; + DataCoder.encodeRef(res, ci.info.typeid); + return res; + }, + function(o) { + var res=[]; + var arrlen = DataCoder.decodeInt(o); + while (--arrlen>=0) { + var v = DataCoder.decodeList(o, [{reftype:'reftype'},{typeid:'tref'}]); + res.vars.push(v); + } + return res; + } + ); + }, + lineTable:function(ci, mi) { + return new Command('Linetable:'+ci.name+","+mi.name, 6, 1, + function() { + var res=[]; + DataCoder.encodeRef(res, ci.info.typeid); + DataCoder.encodeRef(res, mi.methodid); + return res; + }, + function(o) { + var res = {}; + res.start = DataCoder.decodeLong(o, true); + res.end = DataCoder.decodeLong(o, true); + res.lines = []; + var arrlen = DataCoder.decodeInt(o); + while (--arrlen>=0) { + var line = DataCoder.decodeList(o, [{linecodeidx:'codeindex'},{linenum:'int'}]); + res.lines.push(line); + } + // sort the lines by...um..line number + res.lines.sort(function(a,b) { + return a.linenum-b.linenum + || a.linecodeidx-b.linecodeidx; + }) + return res; + } + ); + }, + VariableTableWithGeneric:function(ci, mi) { + // VariableTable is not supported by Dalvik + return new Command('VariableTableWithGeneric:'+ci.name+","+mi.name, 6, 5, + function() { + var res=[]; + DataCoder.encodeRef(res, ci.info.typeid); + DataCoder.encodeRef(res, mi.methodid); + return res; + }, + function(o) { + var res = {}; + res.argCnt = DataCoder.decodeInt(o); + res.vars = []; + var arrlen = DataCoder.decodeInt(o); + while (--arrlen>=0) { + var v = DataCoder.decodeList(o, [{codeidx:'codeindex'},{name:'string'},{type:'signature'},{genericsig:'string'},{length:'int'},{slot:'int'}]); + res.vars.push(v); + } + return res; + } + ); + }, + Frames:function(threadid, start, count) { + return new Command('Frames:'+threadid, 11, 6, + function() { + var res=[]; + DataCoder.encodeRef(res, threadid); + DataCoder.encodeInt(res, start||0); + DataCoder.encodeInt(res, count||-1); + return res; + }, + function(o) { + var res = []; + var arrlen = DataCoder.decodeInt(o); + while (--arrlen>=0) { + var v = DataCoder.decodeList(o, [{frameid:'frameid'},{location:'location'}]); + res.push(v); + } + return res; + } + ); + }, + GetStackValues:function(threadid, frameid, slots) { + return new Command('GetStackValues:'+threadid, 16, 1, + function() { + var res=[]; + DataCoder.encodeRef(res, threadid); + DataCoder.encodeRef(res, frameid); + DataCoder.encodeInt(res, slots.length); + for (var i in slots) { + DataCoder.encodeInt(res, slots[i].slot); + DataCoder.encodeByte(res, slots[i].tag); + } + return res; + }, + function(o) { + var res = []; + var arrlen = DataCoder.decodeInt(o); + while (--arrlen>=0) { + var v = DataCoder.decodeValue(o); + res.push(v); + } + return res; + } + ); + }, + SetStackValue:function(threadid, frameid, slot, data) { + return new Command('SetStackValue:'+threadid, 16, 2, + function() { + var res=[]; + DataCoder.encodeRef(res, threadid); + DataCoder.encodeRef(res, frameid); + DataCoder.encodeInt(res, 1); + DataCoder.encodeInt(res, slot); + DataCoder.encodeTaggedValue(res, data.valuetype, data.value); + return res; + }, + function(o) { + // there's no return data - if we reach here, the update was successfull + return true; + } + ); + }, + GetFieldValues:function(objectid, fields) { + return new Command('GetFieldValues:'+objectid, 9, 2, + function() { + var res=[]; + DataCoder.encodeRef(res, objectid); + DataCoder.encodeInt(res, fields.length); + for (var i in fields) { + DataCoder.encodeRef(res, fields[i].fieldid); + } + return res; + }, + function(o) { + var res = []; + var arrlen = DataCoder.decodeInt(o); + while (--arrlen>=0) { + var v = DataCoder.decodeValue(o); + res.push(v); + } + return res; + } + ); + }, + SetFieldValue:function(objectid, field, data) { + return new Command('SetFieldValue:'+objectid, 9, 3, + function() { + var res=[]; + DataCoder.encodeRef(res, objectid); + DataCoder.encodeInt(res, 1); + DataCoder.encodeRef(res, field.fieldid); + DataCoder.encodeValue(res, data.valuetype, data.value); + return res; + }, + function(o) { + // there's no return data - if we reach here, the update was successfull + return true; + } + ); + }, + GetArrayLength:function(arrobjid) { + return new Command('GetArrayLength:'+arrobjid, 13, 1, + function() { + var res=[]; + DataCoder.encodeRef(res, arrobjid); + return res; + }, + function(o) { + return DataCoder.decodeInt(o); + } + ); + }, + GetArrayValues:function(arrobjid, idx, count) { + return new Command('GetArrayValues:'+arrobjid, 13, 2, + function() { + var res=[]; + DataCoder.encodeRef(res, arrobjid); + DataCoder.encodeInt(res, idx); + DataCoder.encodeInt(res, count); + return res; + }, + function(o) { + var res = []; + var tag = DataCoder.decodeByte(o); + var decodefn = DataCoder.tagtodecoder(tag); + // objects are decoded as values + if (decodefn===DataCoder.decodeORef) + decodefn = DataCoder.decodeValue; + var arrlen = DataCoder.decodeInt(o); + while (--arrlen>=0) { + var v = decodefn.call(DataCoder, o); + res.push(v); + } + return res; + } + ); + }, + SetArrayElements:function(arrobjid, idx, count, data) { + return new Command('SetArrayElements:'+arrobjid, 13, 3, + function() { + var res=[]; + DataCoder.encodeRef(res, arrobjid); + DataCoder.encodeInt(res, idx); + DataCoder.encodeInt(res, count); + for (var i=0; i < count; i++) + DataCoder.encodeValue(res, data.valuetype, data.value); + return res; + }, + function(o) { + // there's no return data - if we reach here, the update was successfull + return true; + } + ); + }, + GetStringValue:function(strobjid) { + return new Command('GetStringValue:'+strobjid, 10, 1, + function() { + var res=[]; + DataCoder.encodeRef(res, strobjid); + return res; + }, + function(o) { + return DataCoder.decodeString(o); + } + ); + }, + CreateStringObject:function(text) { + return new Command('CreateStringObject:'+text.substring(0,20), 1, 11, + function() { + var res=[]; + DataCoder.encodeString(res, text); + return res; + }, + function(o) { + return DataCoder.decodeORef(o); + } + ); + }, + SetEventRequest:function(kindname, kind, suspend, modifiers, modifiercb, onevent) { + return new Command('SetEventRequest:'+kindname, 15, 1, + function() { + var res=[kind,suspend]; + DataCoder.encodeInt(res, modifiers.length); + for (var i=0;i=0) { + res.push(DataCoder.decodeList(o, [{reftype:'reftype'},{typeid:'tref'},{type:'signature'},{genericSignature:'string'},{status:'status'}])); + } + return res; + } + ); + }, + suspend:function() { + return new Command('suspend',1, 8, null, null); + }, + resume:function() { + return new Command('resume',1, 9, null, null); + }, + allthreads:function() { + return new Command('allthreads',1, 4, + null, + function(o) { + var res = []; + var arrlen = DataCoder.decodeInt(o); + while (--arrlen>=0) { + res.push(DataCoder.decodeTRef(o)); + } + return res; + } + ); + } + }; +} + +exports._JDWP = _JDWP; diff --git a/jq-promise.js b/jq-promise.js new file mode 100644 index 0000000..b6bedb1 --- /dev/null +++ b/jq-promise.js @@ -0,0 +1,124 @@ +// a very stripped down polyfill implementation of jQuery's promise methods +const util = require('util'); // for util.inspect +var $ = this; + +// Deferred wraps a Promise into a jQuery-like object +var Deferred = exports.Deferred = function(p, parent) { + var o = { + _isdeferred:true, + _original:null, + _promise:null, + _fns:null, + _context:null, + _parent:null, + _root:null, + promise() { + return this; + }, + then(fn) { + var thendef = $.Deferred(null, this); + var p = this._promise.then(function(a) { + var res = this.fn.apply(a._ctx, a._args); + if (res === undefined) + return a; + if (res && res._isdeferred) + return res._promise; + return {_ctx:a._ctx, _args:[res]} + }.bind({def:thendef,fn:fn})); + thendef._promise = thendef._original = p; + return thendef; + }, + fail(fn) { + var faildef = $.Deferred(null, this); + var p = this._promise.catch(function(a) { + if (a.stack) { + console.error(a.stack); + a = [a]; + } + if (this.def._context === null && this.def._parent) + this.def._context = this.def._parent._context; + if (this.def._context === null && this.def._root) + this.def._context = this.def._root._context; + var res = this.fn.apply(this.def._context,a); + if (res === undefined) + return a; + return res; + }.bind({def:faildef,fn:fn})); + faildef._promise = faildef._original = p; + return faildef; + }, + state() { + var m = util.inspect(this._original).match(/^Promise\s*\{\s*<(\w+)>/); // urgh! + // anything that's not pending or rejected is resolved + return m ? m[1] : 'resolved'; + }, + resolve:function() { + return this.resolveWith(null, Array.prototype.map.call(arguments,x=>x)); + }, + resolveWith:function(ths, args) { + if (typeof(args) === 'undefined') args = []; + if (!Array.isArray(args)) + throw new Error('resolveWith must be passed an array of arguments'); + if (this._root) { + this._root.resolveWith(ths, args); + return this; + } + if (ths === null || ths === undefined) ths = this; + this._fns[0]({_ctx:ths,_args:args}); + return this; + }, + reject:function() { + return this.rejectWith(null, Array.prototype.map.call(arguments,x=>x)); + }, + rejectWith:function(ths,args) { + if (typeof(args) === 'undefined') args = []; + if (!Array.isArray(args)) + throw new Error('rejectWith must be passed an array of arguments'); + if (this._root) { + this._root.rejectWith(ths, args); + return this; + } + this._context = ths; + this._fns[1](args); + return this; + }, + } + if (parent) { + o._original = o._promise = p; + o._parent = parent; + o._root = parent._root || parent; + } else { + o._original = o._promise = new Promise((res,rej) => { + o._fns = [res,rej]; + }); + } + return o; +} + +// $.when() is jQuery's version of Promise.all() +// - this version just scans the array of arguments waiting on any Deferreds in turn before finally resolving the return Deferred +var when = exports.when = function() { + var x = { + def: $.Deferred(), + args: Array.prototype.map.call(arguments,x=>x), + idx:0, + next(x) { + if (x.idx >= x.args.length) { + return process.nextTick(x => { + x.def.resolveWith(null, x.args); + }, x); + } + if ((x.args[x.idx]||{})._isdeferred) { + x.args[x.idx].then(function() { + var x = this, result = Array.prototype.map.call(arguments,x=>x); + x.args[x.idx] = result; + x.idx++; x.next(x); + }.bind(x)); + return; + } + x.idx++; x.next(x); + }, + }; + x.next(x); + return x.def; +} diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..b7caa7d --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "lib": [ + "es6" + ] + }, + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/minwebsocket.js b/minwebsocket.js new file mode 100644 index 0000000..da0c0e7 --- /dev/null +++ b/minwebsocket.js @@ -0,0 +1,122 @@ +/* + A dummy websocket implementation for passing messages internally using a WS-like protocol +*/ +var Servers = {}; + +function isfn(x) { return typeof(x) === 'function' } + +function WebSocketClient(url) { + // we only support localhost addresses in this implementation + var match = url.match(/^ws:\/\/127\.0\.0\.1:(\d+)$/); + var port = match && parseInt(match[1],10); + if (!port || port <= 0 || port >= 65536) + throw new Error('Invalid websocket url'); + var server = Servers[port]; + if (!server) throw new Error('Connection refused'); // 'port' already in use :) + server.addClient(this); + this._ws = { + port: port, + server: server, + outgoing:[], + }; +} + +WebSocketClient.prototype.send = function(message) { + this._ws.outgoing.push(message); + if (this._ws.outgoing.length > 1) return; + process.nextTick(function(client) { + if (!client || !client._ws || !client._ws.server) + return; + client._ws.server.receive(client, client._ws.outgoing); + client._ws.outgoing = []; + }, this); +} + +WebSocketClient.prototype.receive = function(messages) { + if (isfn(this.onmessage)) + messages.forEach(m => { + this.onmessage({ + data:m + }); + }); +} + +WebSocketClient.prototype.close = function() { + process.nextTick(() => { + this._ws.server.rmClient(this); + this._ws.server = null; + if (isfn(this.onclose)) + this.onclose(this); + this._ws = null; + }); +} + + + +function WebSocketServer(port) { + if (typeof(port) !== 'number' || port <= 0 || port >= 65536) + throw new Error('Invalid websocket server port'); + if (Servers[''+port]) + throw new Error('Address in use'); + this.port = port; + this.clients = []; + Servers[''+port] = this; +} + +WebSocketServer.prototype.addClient = function(client) { + var status; + this.clients.push(status = { + server:this, + client: client, + onmessage:null, + onclose:null, + outgoing:[], + send: function(message) { + this.outgoing.push(message); + if (this.outgoing.length > 1) return; + process.nextTick(function(status) { + if (!status || !status.client) + return; + status.client.receive(status.outgoing); + status.outgoing = []; + }, this); + } + }); + process.nextTick((status) => { + if (isfn(this.onconnection)) + this.onconnection({ + status: status, + accept:function() { + process.nextTick((status) => { + if (isfn(status.client.onopen)) + status.client.onopen(status.client); + }, this.status); + return this.status; + } + }); + }, status); +} + +WebSocketServer.prototype.rmClient = function(client) { + for (var i = this.clients.length-1; i >= 0; --i) { + if (this.clients[i].client === client) { + if (isfn(this.clients[i].onclose)) + this.clients[i].onclose(); + this.clients.splice(i, 1); + } + } +} + +WebSocketServer.prototype.receive = function(client, messages) { + var status = this.clients.filter(c => c.client === client)[0]; + if (!status) return; + if (!isfn(status.onmessage)) return; + messages.forEach(m => { + status.onmessage({ + data: m, + }); + }); +} + +exports.WebSocketClient = WebSocketClient; +exports.WebSocketServer = WebSocketServer; diff --git a/package.json b/package.json new file mode 100644 index 0000000..3dd5aeb --- /dev/null +++ b/package.json @@ -0,0 +1,113 @@ +{ + "name": "android-dev-ext", + "displayName": "Android", + "description": "Android debugging support for VS Code", + "version": "0.1.0", + "publisher": "adelphes", + "preview": true, + "license": "MIT", + "engines": { + "vscode": "^1.8.0" + }, + "categories": [ + "Debuggers" + ], + "activationEvents": [ + ], + "main": "./extension", + "contributes": { + "commands": [ + ], + "breakpoints": [ + { + "language": "java" + } + ], + "debuggers": [{ + "type": "android", + "label": "Android Debug", + "program": "./debugMain.js", + "runtime": "node", + + "configurationAttributes": { + "launch": { + "required": ["appSrcRoot","apkFile","adbPort"], + "properties": { + "appSrcRoot": { + "type": "string", + "description": "Location of the App source files. This value must point to the root of your App source tree (containing AndroidManifest.xml)", + "default": "${workspaceRoot}/app/src/main" + }, + "apkFile": { + "type": "string", + "description": "Fully qualified path to the built APK (Android Application Package)", + "default": "${workspaceRoot}/app/build/outputs/apk/app-debug.apk" + }, + "adbPort": { + "type": "integer", + "description": "Port number to connect to the local ADB (Android Debug Bridge) instance. Default: 5037", + "default": 5037 + }, + "staleBuild": { + "type": "string", + "description": "Launch behaviour if source files have been saved after the APK was built. One of: [\"ignore\" \"warn\" \"stop\"]. Default: \"warn\"", + "default": "warn" + }, + "targetDevice": { + "type": "string", + "description": "Target Device ID (as indicated by 'adb devices'). Use this to specify which device is used for deployment when multiple devices are connected.", + "default": "" + } + } + } + }, + + "initialConfigurations": [ + { + "type": "android", + "name": "Android Debug", + "request": "launch", + "appSrcRoot": "${workspaceRoot}/app/src/main", + "apkFile": "${workspaceRoot}/app/build/outputs/apk/app-debug.apk", + "adbPort": 5037 + } + ], + + "configurationSnippets": [ + { + "label": "Android: Launch Configuration", + "description": "A new configuration for launching an Android app debugging session", + "body": { + "type": "android", + "request": "launch", + "name": "${2:Launch App}", + "appSrcRoot": "^\"\\${workspaceRoot}/app/src/main\"", + "apkFile": "^\"\\${workspaceRoot}/app/build/outputs/apk/app-debug.apk\"", + "adbPort": 5037 + } + } + ], + + "variables": { + } + }] + }, + "scripts": { + "postinstall": "node ./node_modules/vscode/bin/install", + "test": "node ./node_modules/vscode/bin/test" + }, + "dependencies": { + "vscode-debugprotocol": "^1.15.0", + "vscode-debugadapter": "^1.15.0", + "xmldom": "^0.1.27", + "xpath": "^0.0.23" + }, + "devDependencies": { + "typescript": "^2.0.3", + "vscode": "^1.0.0", + "mocha": "^2.3.3", + "eslint": "^3.6.0", + "@types/node": "^6.0.40", + "@types/mocha": "^2.2.32" + } +} diff --git a/services.js b/services.js new file mode 100644 index 0000000..4f1356f --- /dev/null +++ b/services.js @@ -0,0 +1,322 @@ +const chrome = require('./chrome-polyfill').chrome; +const { new_socketfd } = require('./sockets'); +const { create_chrome_socket, accept_chrome_socket, destroy_chrome_socket } = chrome; + +var start_request = function(fd) { + + if (fd.closeState) return; + + // read service passed from client + D('waiting for adb request...'); + readx_with_data(fd, function(err, data) { + if (err) { + D('SS: error %o', err); + return; + } + handle_request(fd, data.asString()); + start_request(fd); + }); +} + +var handle_request = exports.handle_request = function(fd, service) { + if (!service){ + D('SS: no service'); + sendfailmsg(fd, 'No service received'); + return false; + } + D('adb request: %s', service); + + if (service.slice(0,4) === 'host') { + // trim 'host:' + return handle_host_request(service.slice(5), 'kTransportAny', null, fd); + } + + if (!fd.transport) { + D('No transport configured - using any found'); + var t = acquire_one_transport('CS_DEVICE', 'kTransportAny', null); + t = check_one_transport(t, '', fd); + if (!t) return false; + fd.transport = t; + } + + // once we call open_device_service, the fd belongs to the transport + open_device_service(fd.transport, fd, service, function(err, serviceinfo) { + if (err) { + sendfailmsg(fd, 'Device connection failed'); + return; + } + D('device service opened: %o', serviceinfo); + send_okay(fd); + }); + return true; +} + +var sendfailmsg = function(fd, reason) { + reason = reason.slice(0, 0xffff); + var msg = 'FAIL' + intToHex(reason.length,4) + reason; + writex(fd, msg); +} + +var handle_host_request = function(service, ttype, serial, replyfd) { + var transport; + + if (service === 'kill') { + cl('service kill request'); + send_okay(replyfd); + killall_devices(); + //window.close(); + return false; + } + + if (service.slice(0,9) === 'transport') { + var t,serialmatch; + switch(service.slice(9)) { + case '-any': + t = acquire_one_transport('CS_ANY','kTransportAny',null); + break; + case '-local': + t = acquire_one_transport('CS_ANY','kTransportLocal',null); + break; + case '-usb': + t = acquire_one_transport('CS_ANY','kTransportUsb',null); + break; + default: + if (serialmatch = service.slice(9).match(/^:(.+)/)) + t = acquire_one_transport('CS_ANY','kTransportAny',serialmatch[1]); + break; + } + t = check_one_transport(t, serialmatch&&serialmatch[1], replyfd); + if (!t) return false; + + // set the transport in the fd - the client can use it + // to send raw data directly to the device + D('transport configured: %o', t); + replyfd.transport = t; + adb_writebytes(replyfd, "OKAY"); + return false; + } + + if (service.slice(0,7) === 'devices') { + var use_long = service.slice(7)==='-l'; + D('Getting device list'); + var transports = list_transports(use_long); + D('Wrote device list'); + send_msg_with_okay(replyfd, transports); + return false; + } + + if (service === 'version') { + var version = intToHex(ADB_SERVER_VERSION, 4); + send_msg_with_okay(replyfd, version); + return false; + } + + if (service.slice(0,9) === 'emulator:') { + var port = service.slice(9); + port = port&&parseInt(port, 10)||0; + if (!port || port <= 0 || port >= 65536) { + D('Invalid emulator port: %s', service); + return false; + } + local_connect(port, function(err) { + + }); + // no reply needed + return false; + } + + if (service.slice(0,9) === 'get-state') { + transport = acquire_one_transport('CS_ANY', ttype, serial, null); + transport = check_one_transport(transport, serial, replyfd); + if (!transport) return false; + var state = connection_state_name(transport); + send_msg_with_okay(replyfd, state); + return false; + } + + if (service === 'killforward-all') { + remove_all_forward_listeners(); + writex(replyfd, 'OKAY'); + return false; + } + + var fwdmatch = service.match(/^forward:(tcp:\d+);(jdwp:\d+)/); + if (fwdmatch) { + transport = acquire_one_transport('CS_ANY', ttype, serial, null); + transport = check_one_transport(transport, serial, replyfd); + if (!transport) return false; + + install_forward_listener(fwdmatch[1], fwdmatch[2], transport, function(err) { + if (err) return sendfailmsg(replyfd, err.msg); + // on the host, 1st OKAY is connect, 2nd OKAY is status + writex(replyfd, 'OKAY'); + writex(replyfd, 'OKAY'); + }); + return false; + } + + if (service === 'track-devices') { + writex(replyfd, 'OKAY'); + add_device_tracker(replyfd); + // fd now belongs to the tracker + return true; + } + + if (service === 'track-devices-extended') { + writex(replyfd, 'OKAY'); + add_device_tracker(replyfd, true); + // fd now belongs to the tracker + return true; + } + + cl('Ignoring host service request: %s', service); + return false; +} + +var check_one_transport = function(t, serial, replyfd) { + var which = serial||'(null)'; + switch((t||[]).length) { + case 0: + sendfailmsg(replyfd, "device '"+which+"' not found"); + return null; + case 1: t = t[0]; + break; + default: + sendfailmsg(replyfd, 'more than one device/emulator'); + return null; + } + switch(t.connection_state) { + case 'CS_DEVICE': break; + case 'CS_UNAUTHORIZED': + sendfailmsg(replyfd, 'device unauthorized.\r\nCheck for a confirmation dialog on your device or reconnect the device.'); + return null; + default: + sendfailmsg(replyfd, 'Device not ready'); + return null; + } + return t; +} + +var forward_listeners = {}; + +var install_forward_listener = function(local, remote, t, cb) { + var localport = parseInt(local.split(':').pop(), 10); + + var socket = chrome.socket; + + create_chrome_socket('forward listener:'+localport, function(socketInfo) { + if (chrome.runtime.lastError) { + return cb({msg:chrome.runtime.lastError.message||'socket creation failed'}); + } + socket.listen(socketInfo.socketId, '127.0.0.1', localport, 5, + function(result) { + if (chrome.runtime.lastError) { + var err = {msg:chrome.runtime.lastError.message||'socket listen failed'}; + destroy_setup(socketInfo); + return cb(err); + } + if (result < 0) { + destroy_setup(socketInfo); + return cb({msg:'Cannot bind to socket'}); + } + + forward_listeners[localport] = { + port:localport, + socketId: socketInfo.socketId, + connectors_fd: null, + connect_cb:function(){}, + }; + + accept_chrome_socket('forward server:'+localport, socketInfo.socketId, function(acceptInfo) { + accept_forward_connection(socketInfo.socketId, acceptInfo, localport, local, remote, t); + }); + + // listener is ready + D('started forward listener on port %d: %d', localport, socketInfo.socketId); + cb(); + } + ); + }); + + function destroy_setup(socketInfo) { + destroy_chrome_socket(socketInfo.socketId); + } +} + +var connect_forward_listener = exports.connect_forward_listener = function(port, opts, cb) { + + // if we're implementing the adb service, this will already be created + // if we're connecting via the adb executable, we need to create a dummy entry + if (!forward_listeners[port]) { + if (opts && opts.create) { + forward_listeners[port] = { + is_external_adb: true, + port:port, + socketId: null, + connectors_fd: null, + connect_cb:function(){}, + } + } else { + D('Refusing forward connection request - forwarder for port %d does not exist', port); + return cb(); + } + } + + create_chrome_socket('forward client:'+port, function(createInfo) { + // save the receiver info + forward_listeners[port].connectors_fd = new_socketfd(createInfo.socketId); + forward_listeners[port].connect_cb = cb; + + // do the connect - everything from here on is handled in the accept routine + chrome.socket.connect(createInfo.socketId, '127.0.0.1', port, function(result) { + chrome.socket.setNoDelay(createInfo.socketId, true, function(result) { + var x = forward_listeners[port]; + if (x.is_external_adb) { + delete forward_listeners[port]; + x.connect_cb(x.connectors_fd); + } + }); + }); + }); +} + +var accept_forward_connection = exports.accept_forward_connection = function(listenerSocketId, acceptInfo, port, local, remote, t) { + if (chrome.runtime.lastError) { + D('Forward port socket accept failed: '+port); + var listener = remove_forward_listener(listenerSocketId); + return listener.connect_cb(); + } + + // on accept - create the remote connection to the device + D('Binding forward port connection to remote port %s', remote); + var sfd = new_socketfd(acceptInfo.socketId); + + // remove the listener + var listener = remove_forward_listener(listenerSocketId); + + chrome.socket.setNoDelay(acceptInfo.socketId, true, function(result) { + // start the connection as a service + open_device_service(t, sfd, remote, function(err) { + listener.connect_cb(listener.connectors_fd); + }); + }); +} + +var remove_forward_listener = exports.remove_forward_listener = function(socketId) { + for (var port in forward_listeners) { + if (forward_listeners[port].socketId === socketId) { + var x = forward_listeners[port]; + delete forward_listeners[port]; + destroy_chrome_socket(x.socketId); + D('removed forward listener: %d', x.socketId); + return x; + } + } +} + +var remove_all_forward_listeners = exports.remove_all_forward_listeners = function() { + var ports = Object.keys(forward_listeners); + while (ports.length) { + remove_forward_listener(forward_listeners[ports.pop()].socketId); + } +} \ No newline at end of file diff --git a/sockets.js b/sockets.js new file mode 100644 index 0000000..13c3bc6 --- /dev/null +++ b/sockets.js @@ -0,0 +1,290 @@ +const chrome = require('./chrome-polyfill').chrome; +const { create_chrome_socket, destroy_chrome_socket } = chrome; +const { D, remove_from_list } = require('./util'); + +// array of local_sockets +var _local_sockets = []; + +var _new_local_socket_id = 1000; +var new_local_socket = function(t, fd, close_fd_on_local_socket_close) { + var x = { + id:++_new_local_socket_id, + fd:fd, + close_fd_on_local_socket_close: !!close_fd_on_local_socket_close, + transport:t, + enqueue: local_socket_enqueue, + ready: local_socket_ready_notify, + close: local_socket_close, + peer:null, + //socketbuffer: [], + } + _local_sockets.push(x); + return x; +} + +var find_local_socket = function(local_socket_id, peer_socket_id) { + for (var i=0; i < _local_sockets.length; i++) { + var ls = _local_sockets[i]; + if (ls.id === local_socket_id) { + if (!peer_socket_id) return ls; + if (!ls.peer) continue; + if (ls.peer.id === peer_socket_id) return ls; + } + } + return null; +} + +var local_socket_ready = function(s) { + D("LS(%d): ready()\n", s.id); +} + +var local_socket_ready_notify = function(s) { + s.ready = local_socket_ready; + send_okay(s.fd); + s.ready(s); +} + +var local_socket_enqueue = function(s, p) { + D("LS(%d): enqueue()\n", s.id, p.len); + + if (s.fd.closed) return false; + + D("LS: enqueue() - writing %d bytes to fd:%d %o\n", p.len, s.fd.n, s.fd); + adb_writebytes(s.fd, p.data, p.len); + //s.socketbuffer.push({data:p.data, len:p.len}); + return true; +} + +var local_socket_close = function(s) { + // flush the data to the output socket + /*var totallen = s.socketbuffer.reduce(function(n, x) { return n+x.len },0); + adb_writebytes(s.fd, intToHex(totallen,4)); + s.socketbuffer.forEach(function(x) { + adb_writebytes(s.fd, x.data, x.len); + });*/ + + if (s.peer) { + s.peer.peer = null; + s.peer.close(s.peer); + s.peer = null; + } + + if (s.fd && s.close_fd_on_local_socket_close) { + s.fd.close(); + } + + var id = s.id; + var idx = _local_sockets.indexOf(s); + if (idx >= 0) _local_sockets.splice(idx, 1); + D("LS(%d): closed()\n", id); +} + +var local_socket_force_close_all = function(t) { + // called when a transport disconnects without a clean finish + var lsarr = _local_sockets.reduce(function(res, ls) { + if (ls && ls.transport === t) res.push(ls); + return res; + }, []); + lsarr.forEach(function(ls) { + D('force closing socket: %o', ls); + local_socket_close(ls); + }); +} + +var remote_socket_ready = function(s, cb) { + D("entered remote_socket_ready RS(%d) OKAY fd=%d peer.fd=%d\n", + s.id, s.fd, s.peer.fd); + p = get_apacket(); + p.msg.command = A_OKAY; + p.msg.arg0 = s.peer.id; + p.msg.arg1 = s.id; + send_packet(p, s.transport, cb); +} + +var remote_socket_close = function(s) { + if (s.peer) { + s.peer.peer = null; + s.peer.close(s.peer); + } + D("RS(%d): closed\n", s.id); +} + +var create_remote_socket = function(id, t) { + var s = { + id: id, + transport: t, + peer:null, + ready: remote_socket_ready, + close: remote_socket_close, + + // a remote socket is a normal socket with an extra disconnect function + disconnect:null, + } + D("RS(%d): created\n", s.id); + + // when a + return s; +} + + +var loopback_clients = []; + +var get_socket_fd_from_fdn = exports.get_socket_fd_from_fdn = function(n) { + for (var i=0; i < loopback_clients.length; i++) { + if (loopback_clients[i].n === n) + return loopback_clients[i]; + } + return null; +} + +var socket_loopback_client = exports.socket_loopback_client = function(port, cb) { + create_chrome_socket('socket_loopback_client', function(createInfo) { + chrome.socket.connect(createInfo.socketId, '127.0.0.1', port, function(result) { + if (result < 0) { + destroy_chrome_socket(createInfo.socketId); + return cb(); + } + chrome.socket.setNoDelay(createInfo.socketId, true, function(result) { + var x = new_socketfd(createInfo.socketId); + return cb(x); + }); + }); + }); +} + +var new_socketfd = exports.new_socketfd = function(socketId) { + var x = { + n: socketId, + isSocket:true, + connected:true, + closed:false, + // readbytes and writebytes are used by readx and writex + readbytes:function(len, cb) { + slc_read(this, len, function(err, data){ + cb(err, data); + }); + }, + writebytes:function(data, cb) { + slc_write(this, data, cb||function(){}); + }, + close:function() { + slc_close(this, function(){}); + } + }; + loopback_clients.push(x); + return x; +} + +var slc_readwithkick = function(sfd, cb) { + + /*if (sfd.reader_cb_stack.length) { + return cb(null, new Uint8Array(0)); + }*/ + + //var readinfo = {cb:cb, expired:false}; + //sfd.reader_cb_stack.push(readinfo); + + var kicker = setTimeout(function() { + if (!kicker) return; + kicker = null; + D('reader kick expired - retuning nothing'); + //readinfo.expired = true; + cb(null, new Uint8Array(0)); + }, 100); + + slc_read_stacked_(sfd, function(err, data) { + if (!kicker) { + D('Discarding data recevied after kick expired'); + return; + } + clearTimeout(kicker); + kicker = null; + cb(err, data); + }); +}; + +var slc_read = function(sfd, minlen, cb) { + //sfd.reader_cb_stack.push({cb:cb, expired:false}); + slc_read_stacked_(sfd, minlen, cb); +} + +var slc_read_stacked_ = function(sfd, minlen, cb) { + var params = [sfd.n]; + switch(typeof(minlen)) { + case 'number': params.push(minlen); break; + case 'function': cb = minlen; // fall through + default: minlen = 'any'; + }; + var buffer = new Uint8Array(minlen==='any'?65536:minlen); + var buffer_offset = 0; + var onread = function(readInfo) { + if (chrome.runtime.lastError) { + slc_close(sfd, function() { + cb({msg: 'socket read error. Terminating socket'}); + }); + return; + } + if (readInfo.resultCode < 0) return cb(readInfo); + + buffer.set(new Uint8Array(readInfo.data), buffer_offset); + buffer_offset += readInfo.data.byteLength; + if (typeof(minlen)==='number' &&buffer_offset < minlen) { + // read more + params[1] = minlen - buffer_offset; + chrome.socket.read.apply(chrome.socket, params); + return; + } + buffer = buffer.subarray(0, buffer_offset); + buffer.asString = function() { return arrayBufferToString(this); } + return cb(null, buffer); + }; + params.push(onread); + chrome.socket.read.apply(chrome.socket, params); +} + +var slc_write = function(sfd, data, cb) { + var buf = data.buffer; + if (buf.byteLength !== data.byteLength) { + buf = buf.slice(0, data.byteLength); + } + chrome.socket.write(sfd.n, buf, function(writeInfo) { + if (chrome.runtime.lastError) { + slc_close(sfd, function() { + cb({msg: 'socket write error. Terminating socket'}); + }); + return; + } + if (writeInfo.bytesWritten !== data.byteLength) + return cb({msg: 'socket write mismatch. wanted:'+data.byteLength+', sent:'+writeInfo.bytesWritten}); + cb(); + }); +} + +var slc_shutdown = function(sfd, cb) { + if (sfd.connected) { + sfd.connected = false; + chrome.socket.disconnect(sfd.n); + } + cb(); +} + +var slc_close = function(sfd, cb) { + if (sfd.connected) { + sfd.connected = false; + chrome.socket.disconnect(sfd.n); + } + sfd.closed = true; + destroy_chrome_socket(sfd.n); + remove_from_list(loopback_clients, sfd); + cb(); +} + + +var fd_loopback_client = function() { + var s = []; + adb_socketpair(s, 'fd_loopback_client', true); + D('fd_loopback_client created. server fd:%d, client fd:%d', s[1].n, s[0].n); + // return one side and pass the other side to the request handler + start_request(s[1]); + return s[0]; +} diff --git a/test/extension.test.js b/test/extension.test.js new file mode 100644 index 0000000..c3c1517 --- /dev/null +++ b/test/extension.test.js @@ -0,0 +1,24 @@ +/* global suite, test */ + +// +// Note: This example test is leveraging the Mocha test framework. +// Please refer to their documentation on https://mochajs.org/ for help. +// + +// The module 'assert' provides assertion methods from node +var assert = require('assert'); + +// You can import and use all API from the 'vscode' module +// as well as import your extension to test it +var vscode = require('vscode'); +var myExtension = require('../extension'); + +// Defines a Mocha test suite to group tests of similar kind together +suite("Extension Tests", function() { + + // Defines a Mocha unit test + test("Something 1", function() { + assert.equal(-1, [1, 2, 3].indexOf(5)); + assert.equal(-1, [1, 2, 3].indexOf(0)); + }); +}); \ No newline at end of file diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..5604517 --- /dev/null +++ b/test/index.js @@ -0,0 +1,22 @@ +// +// PLEASE DO NOT MODIFY / DELETE UNLESS YOU KNOW WHAT YOU ARE DOING +// +// This file is providing the test runner to use when running extension tests. +// By default the test runner in use is Mocha based. +// +// You can provide your own test runner if you want to override it by exporting +// a function run(testRoot: string, clb: (error:Error) => void) that the extension +// host can call to run the tests. The test runner is expected to use console.log +// to report the results back to the caller. When the tests are finished, return +// a possible error to the callback or null if none. + +var testRunner = require('vscode/lib/testrunner'); + +// You can directly control Mocha options by uncommenting the following lines +// See https://github.com/mochajs/mocha/wiki/Using-mocha-programmatically#set-options for more info +testRunner.configure({ + ui: 'tdd', // the TDD UI is being used in extension.test.js (suite, test, etc.) + useColors: true // colored output from test results +}); + +module.exports = testRunner; \ No newline at end of file diff --git a/transport.js b/transport.js new file mode 100644 index 0000000..2541df7 --- /dev/null +++ b/transport.js @@ -0,0 +1,424 @@ +const D = function(){};// require('./util').D; + +var transport_list = []; +var next_connect_device_service_id = 1; + +var open_device_service = exports.open_device_service = function(t, fd, service, cb) { + D('open_device_service %s on device %s', service, t.serial); + + var p = get_apacket(); + p.msg.command = A_OPEN; + p.msg.arg0 = ++next_connect_device_service_id; + p.msg.data_length = service.length+1; + p.data.set(str2u8arr(service)); + + var serviceinfo = { + service: service, + transport: t, + localid: p.msg.arg0, + remoteid: 0, + state: 'init', + nextokay:null, + nextwrte:null, + nextclse:on_device_close_reply, + clientfd: fd, + isjdwp: /^jdwp\:\d+/.test(service), + islogcat: /^(shell:)?logcat/.test(service), + }; + t.open_services.push(serviceinfo); + + serviceinfo.nextokay = on_device_open_okay; + serviceinfo.state = 'talking'; + send_packet(p, t, function(err) { + if (err) { + serviceinfo.state = 'init-error'; + remove_device_service(serviceinfo); + return cb(err); + } + }); + + function ignore_response(err, p, serviceinfo, receivecb) { + D('ignore_response, p=%o', p); + receivecb(); + } + + function on_device_open_okay(err, p, serviceinfo, receivecb) { + D('on_device_open_okay: %s, err:%o', serviceinfo.service, err); + if (err) { + receivecb(); + cb(err); + return; + } + serviceinfo.state = 'ready'; + serviceinfo.nextokay = ignore_response; + serviceinfo.nextwrte = on_device_write_reply; + + // ack the packet receive callback + receivecb(); + // ack the open_device_service callback + cb(null, serviceinfo); + + // start reading from the client + read_from_client(serviceinfo); + } + + function read_from_client(serviceinfo) { + D('Waiting for client data'); + serviceinfo.clientfd.readbytes(function(err, data) { + if (err) { + // read error - the client probably closed the connection + send_close_device_service(serviceinfo, function(err) { + remove_device_service(serviceinfo); + }); + return; + } + D('client WRTE %d bytes to device', data.byteLength); + // send the data to the device + var p = get_apacket(); + p.msg.command = A_WRTE; + p.msg.arg0 = serviceinfo.localid; + p.msg.arg1 = serviceinfo.remoteid; + p.msg.data_length = data.byteLength; + p.data.set(data); + if (serviceinfo.isjdwp) + print_jdwp_data('out',data); + + serviceinfo.nextokay = function(err, p, serviceinfo, receivecb) { + if (err) { + // if we fail to write, just abort + remove_device_service(serviceinfo); + receivecb(); + return; + } + D('client WRTE - got OKAY'); + serviceinfo.nextokay = ignore_response; + receivecb(); + // read and send more + read_from_client(serviceinfo); + } + + send_packet(p, t, function(err) { + if (err) { + // if we fail to write, just abort + remove_device_service(serviceinfo); + return; + } + // we must wait until the next OKAY until we can write more + D('client WRTE - waiting for OKAY'); + }); + }); + } + + function on_device_write_reply(err, p, serviceinfo, receivecb) { + D('device WRTE received'); + if (err) { + serviceinfo.state = 'write reply error'; + remove_device_service(serviceinfo); + receivecb(); + return; + }; + + // when we receive a WRTE, we must reply with an OKAY as the very next packet. + // - we can't wait for the data to be forwarded because the reader might post + // something in between + D('sending OKAY'); + send_ready(serviceinfo.localid, serviceinfo.remoteid, serviceinfo.transport, function(err){ + if (err) { + serviceinfo.state = 'write okay error'; + remove_device_service(serviceinfo); + return; + } + D('sent OKAY'); + }); + + if (serviceinfo.isjdwp) + print_jdwp_data('dev', p.data); + + // write the data to the client + serviceinfo.clientfd.writebytes(new Uint8Array(p.data.buffer.slice(0, p.msg.data_length)), function(err) { + // ack the packet receive callback + receivecb(); + }); + } + + function on_device_close_reply(err, p, serviceinfo, receivecb) { + var t = serviceinfo.transport; + D('on_device_close_reply %s (by device) on device %s', serviceinfo.service, t.serial); + serviceinfo.state = 'closed (by device)'; + remove_device_service(serviceinfo); + // ack the packet receive callback + receivecb(); + } + +} + +var find_open_device_service = exports.find_open_device_service = function(t, localid, remoteid) { + for (var i=0; i < t.open_services.length; i++) { + var s = t.open_services[i]; + if (s.localid === localid && (!remoteid ||(s.remoteid === remoteid))) { + return s; + } + } + return null; +} + +var send_close_device_service = exports.send_close_device_service = function(serviceinfo, cb) { + D('send_close_device_service: %s, device:%s', serviceinfo.service, serviceinfo.transport.serial); + var p = get_apacket(); + + p.msg.command = A_CLSE; + p.msg.arg0 = serviceinfo.localid; + p.msg.arg1 = serviceinfo.remoteid; + + serviceinfo.nextreply = on_close_request_reply; + serviceinfo.state = 'talking'; + send_packet(p, serviceinfo.transport, function(err) { + if (err) { + serviceinfo.state = 'error'; + } else { + serviceinfo.state = 'closed'; + } + // ack the close_device_service request as soon as we + // send the packet - don't wait for the reply + return cb(err); + }); + + function on_close_request_reply(which, serviceinfo, receivecb) { + // ack the packet receive callback + receivecb(); + } +} + +var remove_device_service = exports.remove_device_service = function(serviceinfo) { + var fd; + if (fd=serviceinfo.clientfd) { + serviceinfo.clientfd=null; + fd.close(); + } + remove_from_list(serviceinfo.transport.open_services, serviceinfo); +} + +var register_transport = exports.register_transport = function(t, cb) { + t.terminated = false; + t.open_services = []; + transport_list.push(t); + + // start the reader + function read_next_packet_from_transport(t, packetcount) { + var p = new_apacket(); + t.read_from_remote(p, t, function(err, p) { + if (t.terminated) { + return; + } + if (err) { + D('Error reading next packet from transport:%s - terminating.', t.serial); + kick_transport(t); + unregister_transport(t); + return; + } + p.which = intToCharString(p.msg.command); + D('Read packet:%d (%s) from transport:%s', packetcount, p.which, t.serial); + var pc = packetcount++; + handle_packet(p, t, function(err) { + D('packet:%d handled, err:%o', pc, err); + read_next_packet_from_transport(t, packetcount); + }); + }); + } + read_next_packet_from_transport(t, 0); + + D("transport: %s registered\n", t.serial); + D('new transport list: %o', transport_list.slice()); + update_transports(); + + ui.update_device_property(t.deviceinfo, 'status', 'Connecting...'); + send_connect(t, cb); +} + +var unregister_transport = exports.unregister_transport = function(t) { + if (t.fd) + t.fd.close(); + // kill any connected services + while (t.open_services.length) { + remove_device_service(t.open_services.pop()); + } + + remove_from_list(transport_list, t); + D("transport: %s unregistered\n", t.serial); + D('remaining transports: %o', transport_list.slice()); + t.serial = 'REMOVED:' + t.serial; + t.terminated = true; + update_transports(); + ui.update_device_property(t.deviceinfo, 'status', 'Disconnected', '#8B0E0E'); + ui.remove_disconnected_device(t.deviceinfo); +} + +var kick_transport = exports.kick_transport = function(t) { + if (t && !t.kicked) { + t.kicked = true; + t.kick(t); + } +} + +var write_packet_to_transport = exports.write_packet_to_transport = function(t, p, cb) { + if (t.terminated) { + D('Refusing to write packet to terminated transport: %s', t.serial); + return cb({msg:'device not found'}); + } + t.write_to_remote(p, t, function(err) { + cb(err); + }); +} + +var send_packet = exports.send_packet = function(p, t, cb) { + p.msg.magic = p.msg.command ^ 0xffffffff; + + var count = p.msg.data_length; + var x = new Uint8Array(p.data); + var sum = 0, i=0; + while(count-- > 0){ + sum += x[i++]; + } + p.msg.data_check = sum; + + write_packet_to_transport(t, p, cb); +} + +var acquire_one_transport = exports.acquire_one_transport = function(connection_state, transport_type, serial) { + var candidates = []; + for (var i=0, tl=transport_list; i < tl.length; i++) { + if (connection_state !== 'CS_ANY' && tl[i].connection_state !== connection_state) + continue; + if (transport_type !== 'kTransportAny' && tl[i].transport_type !== transport_type) + continue; + if (serial && tl[i].serial !== serial) + continue; + candidates.push(tl[i]); + } + return candidates; +} + +var statename = exports.statename = function(t) { + if (/^CS_.+/.test(t.connection_state)) + return t.connection_state.slice(3).toLowerCase(); + return 'unknown state: ' + t.connection_state; +} + +var typename = exports.typename = function(t) { + if (/^kTransport.+/.test(t.type)) + return t.type.slice(10).toLowerCase(); + return 'unknown type: ' + t.type; +} + +var format_transport = exports.format_transport = function(t, format) { + var serial = t.serial || '???????????'; + + if (!format) { + return serial+'\t'+statename(t); + } else if (format === 'extended') { + return '{'+[ + '"device":'+JSON.stringify(t.device), + '"model":'+JSON.stringify(t.model||t.deviceinfo.productName), + '"product":'+JSON.stringify(t.product), + '"serial":'+JSON.stringify(serial), + '"status":'+JSON.stringify(statename(t)), + '"type":'+JSON.stringify(typename(t)), + ].join(',') + '}'; + } else { + return [ + serial+'\t'+statename(t), + t.devpath||'', + t.product?'product:'+t.product.replace(/\s+/,'_'):'', + t.model?'model:'+t.model.replace(/\s+/,'_'):'', + t.device?'device:'+t.device.replace(/\s+/,'_'):'' + ].join(' '); + } +} + +var list_transports = exports.list_transports = function(format) { + return transport_list.map(function(t) { + return format_transport(t, format); + }).join('\n')+'\n'; +} + +var update_transports = exports.update_transports = function() { + write_transports_to_trackers(_device_trackers.normal); + write_transports_to_trackers(_device_trackers.extended, null, true); +} + +var readx_with_data = exports.readx_with_data = function(fd, cb) { + readx(fd, 4, function(err, buf) { + if (err) return cb(err); + var dlen = buf.intFromHex(); + if (dlen < 0 || dlen > 0xffff) + return cb({msg:'Invalid data len: ' + dlen}); + readx(fd, dlen, function(err, buf) { + if (err) return cb(err); + return cb(null, buf); + }); + }); +} + +var readx = exports.readx = function(fd, len, cb) { + D('readx: fd:%o wanted=%d', fd, len); + fd.readbytes(len, function(err, buf) { + if (err) return cb(err); + cb(err, buf); + }); +} + +var writex = exports.writex = function(fd, bytes, len) { + if (typeof(bytes) === 'string') { + var buf = new Uint8Array(bytes.length); + for (var i=0; i < bytes.length; i++) + buf[i] = bytes.charCodeAt(i); + bytes = buf; + } + if (typeof(len) !== 'number') + len = bytes.byteLength; + D('writex: fd:%o writing=%d', fd, len); + fd.writebytes(bytes.subarray(0,len)); +} + +var writex_with_data = exports.writex_with_data = function(fd, data, len) { + if (typeof(len) === 'undefined'); + len = data.byteLength||data.length||0; + writex(fd, intToHex(len, 4)); + writex(fd, data, len); +} + +var _device_trackers = { + normal:[], + extended:[], +} +var add_device_tracker = exports.add_device_tracker = function(fd, extended) { + _device_trackers[extended?'extended':'normal'].push(fd); + write_transports_to_trackers([fd], null, extended); + readtracker(fd, extended); + D('Device tracker added. Trackers: %o', _device_trackers); + + function readtracker(fd, extended) { + chrome.socket.read(fd.n, function(readInfo) { + if (chrome.runtime.lastError || readInfo.resultCode < 0) { + remove_from_list(_device_trackers[extended?'extended':'normal'], fd); + D('Device tracker socket read failed - closing. Trackers: %o', _device_trackers); + fd.close(); + return; + } + D('Ignoring data read from device tracker socket'); + readtracker(fd, extended); + }); + } +} + +var write_transports_to_trackers = exports.write_transports_to_trackers = function(fds, transports, extended) { + if (!fds || !fds.length) + return; + if (!transports) { + return write_transports_to_trackers(fds, list_transports(extended?'extended':''), extended); + } + D('Writing transports: %s', transports); + fds.slice().forEach(function(fd) { + writex_with_data(fd, str2u8arr(transports)); + }); +} diff --git a/util.js b/util.js new file mode 100644 index 0000000..8614e94 --- /dev/null +++ b/util.js @@ -0,0 +1,631 @@ +const crypto = require('crypto'); + +var nofn=function(){}; +var D=exports.D=console.log.bind(console); +var E=exports.E=console.error.bind(console); +var W=exports.W=console.warn.bind(console); +var DD=nofn,cl=D,printf=D; +var print_jdwp_data = nofn;// _print_jdwp_data; +var print_packet = nofn;//_print_packet; + +Array.first = function(arr, fn, defaultvalue) { + var idx = Array.indexOfFirst(arr, fn); + return idx < 0 ? defaultvalue : arr[idx]; +} + +Array.indexOfFirst = function(arr, fn) { + if (!Array.isArray(arr)) return -1; + for (var i=0; i < arr.length; i++) + if (fn(arr[i], i, arr)) + return i; + return -1; +} + +var isEmptyObject = exports.isEmptyObject = function(o) { + return typeof(o)==='object' && !Object.keys(o).length; +} + +var leftpad = exports.leftpad = function(char, len, s) { + while (s.length < len) + s = char + s; + return s; +} + +var intToHex = exports.intToHex = function(i, minlen) { + var s = i.toString(16); + if (minlen) s = leftpad('0', minlen, s); + return s; +} + +var intFromHex = exports.intFromHex = function(s, maxlen, defaultvalue) { + s = s.slice(0, maxlen); + if (!/^[0-9a-fA-F]+$/.test(s)) return defaultvalue; + return parseInt(s, 16); +} + +var fdcache = []; + +var index_of_file_fdn = function(n) { + if (n <= 0) return -1; + for (var i=0; i < fdcache.length; i++) { + if (fdcache[i] && fdcache[i].n === n) + return i; + } + return -1; +} + +var get_file_fd_from_fdn = function(n) { + var idx = index_of_file_fdn(n); + if (idx < 0) return null; + return fdcache[idx]; +} + +var remove_fd_from_cache = function(fd) { + if (!fd) return; + var idx = index_of_file_fdn(fd.n); + if (idx>=0) fdcache.splice(idx, 1); +} + +// add an offset so we don't conflict with tcp socketIds +var min_fd_num = 100000; +var _new_fd_count = 0; +var new_fd = this.new_fd = function(name, raw) { + var rwpipe = raw ? new Uint8Array(0) : []; + var fd = { + name: name, + n: min_fd_num + (++_new_fd_count), + raw: !!raw, + readpipe:rwpipe, + writepipe:rwpipe, + reader:null, + readerlen:0, + kickingreader:false, + total:{read:0,written:0}, + duplex: null, + closed:'', + read:function(cb) { + if (this.raw) + throw 'Cannot read from raw fd'; + if (this.reader && this.reader !== cb) + throw 'multiple readers?'; + this.reader = cb; + this._kickreader(); + }, + write:function(data) { + if (this.closed) { + D('Ignoring attempt to write to closed file: %o', this); + return; + } + if (this.raw) { + D('Ignoring attempt to write object to raw file: %o', this); + return; + } + this.writepipe.push(data); + if (this.duplex) { + this.duplex._kickreader(); + } + }, + + readbytes:function(len, cb) { + if (!this.raw) + throw 'Cannot readbytes from non-raw fd'; + if (this.reader) + throw 'multiple readers?'; + this.reader = cb; + this.readerlen = len; + this._kickreader(); + }, + + writebytes:function(buffer) { + if (this.closed) { + D('Ignoring attempt to write to closed file: %o', this); + return; + } + if (!this.raw) { + D('Ignoring attempt to write bytes to non-raw file: %o', this); + return; + } + if (!buffer || !buffer.byteLength) { + // kick the reader when writing 0 bytes + this._kickreaders(); + return; + } + this.total.written += buffer.byteLength; + var newbuf = new Uint8Array(this.writepipe.byteLength + buffer.byteLength); + newbuf.set(this.writepipe); + newbuf.set(buffer, this.writepipe.byteLength); + this.writepipe = newbuf; + if (this.duplex) + this.duplex.readpipe = newbuf; + else + this.readpipe = newbuf; + D('new buffer size: %d (fd:%d)',this.writepipe.byteLength, this.n); + this._kickreaders(); + }, + + cancelread:function(flushfirst) { + if (flushfirst) + this.flush(); + this.reader = null; + this.readerlen = 0; + }, + + write_eof:function() { + this.flush(); + // eof is only relevant for read-until-close readers + if (this.raw && this.reader && this.readerlen === -1) { + this.reader({err:'eof'}); + } + }, + + flush:function() { + this._doread(); + }, + + close:function() { + if (this.closed) + return; + console.trace('Closing file %d: %o', this.n, this); + this.closed = 'closed'; + if (this.duplex) + this.duplex.close(); + // last kick to finish off any read-until-close readers + this._kickreaders(); + // remove this entry from the cache + remove_fd_from_cache(this); + }, + + _kickreaders:function() { + if (this.duplex) + this.duplex._kickreader(); + else + this._kickreader(); + }, + + _kickreader:function() { + if (!this.reader) return; + if (this.kickingreader) return; + var t = this; + t.kickingreader = setTimeout(function() { + t.kickingreader = false; + t._doreadcheckclose(); + }, 0); + }, + + _doreadcheckclose:function() { + var cs = this.closed; + this._doread(); + if (cs) { + // they've had one last read - no more + var rucreader = this.readerlen === -1; + var rucreadercb = this.reader; + this.reader = null; + this.readerlen = 0; + if (rucreader && rucreadercb) { + // terminate the read-until-close reader + D('terminating ruc reader. fd: %o',this); + rucreadercb({err:'File closed'}); + } + } + }, + + _doread:function() { + if (this.raw) { + if (!this.reader) return; + if (this.readerlen > this.readpipe.byteLength) return; + if (this.readerlen && !this.readpipe.byteLength) return; + var cb = this.reader, len = this.readerlen; + this.reader = null, this.readerlen = 0; + var data; + if (len) { + var readlen = len>0?len:this.readpipe.byteLength; + data = this.readpipe.subarray(0, readlen); + this.readpipe = this.readpipe.subarray(readlen); + if (this.duplex) + this.duplex.writepipe = this.readpipe; + else + this.writepipe = this.readpipe; + this.total.read += readlen; + } else { + data = new Uint8Array(0); + } + + data.asString = function() { + return uint8ArrayToString(this); + }; + data.intFromHex = function(len) { + len = len||this.byteLength; + var x = this.asString().slice(0,len); + if (!/^[0-9a-fA-F]+/.test(x)) return -1; + return parseInt(x, 16); + } + cb(null, data); + + if (len < 0) { + // reset the reader + this.readbytes(len, cb); + } + return; + } + if (this.reader && this.readpipe.length) { + var cb = this.reader; + this.reader = null; + cb(this.readpipe.shift()); + } + } + } + + fdcache.push(fd); + return fd; +} + +var intToCharString = function(n) { + return String.fromCharCode( + (n>>0)&255, + (n>>8)&255, + (n>>16)&255, + (n>>24)&255 + ); +} + +var stringToUint8Array = function(s) { + var x = new Uint8Array(s.length); + for (var i=0; i < s.length; i++) + x[i] = s.charCodeAt(i); + return x; +} + +var uint8ArrayToString = function(a) { + var s = new Array(a.byteLength); + for (var i=0; i < a.byteLength; i++) + s[i] = a[i]; + return String.fromCharCode.apply(String, s); +} + +// asynchronous array iterater +var iterate = function(arr, o) { + var isrange = typeof(arr)==='number'; + if (isrange) + arr = { length: arr<0?0:arr }; + var x = { + value:arr, + isrange:isrange, + first:o.first||nofn, + each:o.each||(function() { this.next(); }), + last:o.last||nofn, + success:o.success||nofn, + error:o.error||nofn, + complete:o.complete||nofn, + _idx:0, + _donefirst:false, + _donelast:false, + abort:function(err) { + this.error(err); + this.complete(); + return; + }, + finish:function(res) { + // finish early + if (typeof(res)!=='undefined') this.result = res; + this.success(res||this.result); + this.complete(); + return; + }, + iteratefirst:function() { + if (!this.value.length) { + this.finish(); + return; + } + this.first(this.value[this._idx],this._idx,this); + this.each(this.value[this._idx],this._idx,this); + }, + iteratenext:function() { + if (++this._idx >= this.value.length) { + this.last(this.value[this._idx],this._idx,this); + this.finish(); + return; + } + this.each(this.value[this._idx],this._idx,this); + }, + next:function() { + var t = this; + setTimeout(function() { + t.iteratenext(); + },0); + }, + nextorabort:function(err) { + if (err) this.abort(err); + else this.next(); + }, + }; + setTimeout(function() { x.iteratefirst(); }, 0); + return x; +}; + +var iterate_repeat = function(arr, count, o, j) { + iterate(arr, { + each: function(value, i, it) { + o.each(value, i, j||0, it); + }, + success: function() { + if (!--count) { + o.success && o.success(); + o.complete && o.complete(); + return; + } + iterate_repeat(arr, count, o, (j||0)+1); + }, + error:function(err) { + o.error && o.error(); + o.complete && o.complete(); + } + }); +} + +/** + * Convert from an ArrayBuffer to a string. + * @param {ArrayBuffer} buffer The array buffer to convert. + * @return {string} The textual representation of the array. + */ +var arrayBufferToString = exports.arrayBufferToString = function(buffer) { + var array = new Uint8Array(buffer); + var str = ''; + for (var i = 0; i < array.length; ++i) { + str += String.fromCharCode(array[i]); + } + return str; +}; + +/** + * Convert from an UTF-8 array to UTF-8 string. + * @param {array} UTF-8 array + * @return {string} UTF-8 string + */ +var ary2utf8 = (function() { + + var patterns = [ + {pattern: '0xxxxxxx', bytes: 1}, + {pattern: '110xxxxx', bytes: 2}, + {pattern: '1110xxxx', bytes: 3}, + {pattern: '11110xxx', bytes: 4}, + {pattern: '111110xx', bytes: 5}, + {pattern: '1111110x', bytes: 6} + ]; + patterns.forEach(function(item) { + item.header = item.pattern.replace(/[^10]/g, ''); + item.pattern01 = item.pattern.replace(/[^10]/g, '0'); + item.pattern01 = parseInt(item.pattern01, 2); + item.mask_length = item.header.length; + item.data_length = 8 - item.header.length; + var mask = ''; + for (var i = 0, len = item.mask_length; i < len; i++) { + mask += '1'; + } + for (var i = 0, len = item.data_length; i < len; i++) { + mask += '0'; + } + item.mask = mask; + item.mask = parseInt(item.mask, 2); + }); + + return function(ary) { + var codes = []; + var cur = 0; + while(cur < ary.length) { + var first = ary[cur]; + var pattern = null; + for (var i = 0, len = patterns.length; i < len; i++) { + if ((first & patterns[i].mask) == patterns[i].pattern01) { + pattern = patterns[i]; + break; + } + } + if (pattern == null) { + throw 'utf-8 decode error'; + } + var rest = ary.slice(cur + 1, cur + pattern.bytes); + cur += pattern.bytes; + var code = ''; + code += ('00000000' + (first & (255 ^ pattern.mask)).toString(2)).slice(-pattern.data_length); + for (var i = 0, len = rest.length; i < len; i++) { + code += ('00000000' + (rest[i] & parseInt('111111', 2)).toString(2)).slice(-6); + } + codes.push(parseInt(code, 2)); + } + return String.fromCharCode.apply(null, codes); + }; + +})(); + +/** + * Convert from an UTF-8 string to UTF-8 array. + * @param {string} UTF-8 string + * @return {array} UTF-8 array + */ +var utf82ary = (function() { + + var patterns = [ + {pattern: '0xxxxxxx', bytes: 1}, + {pattern: '110xxxxx', bytes: 2}, + {pattern: '1110xxxx', bytes: 3}, + {pattern: '11110xxx', bytes: 4}, + {pattern: '111110xx', bytes: 5}, + {pattern: '1111110x', bytes: 6} + ]; + patterns.forEach(function(item) { + item.header = item.pattern.replace(/[^10]/g, ''); + item.mask_length = item.header.length; + item.data_length = 8 - item.header.length; + item.max_bit_length = (item.bytes - 1) * 6 + item.data_length; + }); + + var code2utf8array = function(code) { + var pattern = null; + var code01 = code.toString(2); + for (var i = 0, len = patterns.length; i < len; i++) { + if (code01.length <= patterns[i].max_bit_length) { + pattern = patterns[i]; + break; + } + } + if (pattern == null) { + throw 'utf-8 encode error'; + } + var ary = []; + for (var i = 0, len = pattern.bytes - 1; i < len; i++) { + ary.unshift(parseInt('10' + ('000000' + code01.slice(-6)).slice(-6), 2)); + code01 = code01.slice(0, -6); + } + ary.unshift(parseInt(pattern.header + ('00000000' + code01).slice(-pattern.data_length), 2)); + return ary; + }; + + return function(str) { + var codes = []; + for (var i = 0, len = str.length; i < len; i++) { + var code = str.charCodeAt(i); + Array.prototype.push.apply(codes, code2utf8array(code)); + } + return codes; + }; + +})(); + +/** + * Convert a string to an ArrayBuffer. + * @param {string} string The string to convert. + * @return {ArrayBuffer} An array buffer whose bytes correspond to the string. + */ +var stringToArrayBuffer = exports.stringToArrayBuffer = function(string) { + var buffer = new ArrayBuffer(string.length); + var bufferView = new Uint8Array(buffer); + for (var i = 0; i < string.length; i++) { + bufferView[i] = string.charCodeAt(i); + } + return buffer; +}; + +var str2ab = exports.str2ab = stringToArrayBuffer; +var ab2str = exports.ab2str = arrayBufferToString; +var str2u8arr = exports.str2u8arr = function(s) { + return new Uint8Array(str2ab(s)); +} + +exports.getutf8bytes = function(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; +} + +exports.fromutf8bytes = function(array) { + var out, i, len, c; + var char2, char3; + + out = ""; + len = array.length; + i = 0; + while(i < len) { + c = array[i++]; + switch(c >> 4) + { + case 0: case 1: case 2: case 3: case 4: case 5: case 6: case 7: + // 0xxxxxxx + out += String.fromCharCode(c); + break; + case 12: case 13: + // 110x xxxx 10xx xxxx + char2 = array[i++]; + out += String.fromCharCode(((c & 0x1F) << 6) | (char2 & 0x3F)); + break; + case 14: + // 1110 xxxx 10xx xxxx 10xx xxxx + char2 = array[i++]; + char3 = array[i++]; + out += String.fromCharCode(((c & 0x0F) << 12) | + ((char2 & 0x3F) << 6) | + ((char3 & 0x3F) << 0)); + break; + } + } + + return out; +} + +exports.arraybuffer_concat = function() { + var bufs=[], total=0; + for (var i=0; i < arguments.length; i++) { + var a = arguments[i]; + if (!a || !a.byteLength) continue; + bufs.push(a); + total += a.byteLength; + } + switch (bufs.length) { + case 0: return new Uint8Array(0); + case 1: return new Uint8Array(bufs[0]); + } + var res = new Uint8Array(total); + for (var i=0, j=0; i < bufs.length; i++) { + res.set(bufs[i], j); + j += bufs[i].byteLength; + } + return res; +} + +exports.remove_from_list = function(arr, item, searchfn) { + if (!searchfn) searchfn = function(a,b) { return a===b; }; + for (var i=0; i < arr.length; i++) { + var found = searchfn(arr[i], item); + if (found) { + return { + item: arr.splice(i, 1)[0], + index: i, + } + } + } + D('Object %o not removed from list %o', item, arr); +} + +exports.dumparr = function(arr, offset, count) { + offset=offset||0; + count = count||(count===0?0:arr.length); + if (count > arr.length-offset) + count = arr.length-offset; + var s = ''; + while (count--) { + s += ' '+('00'+arr[offset++].toString(16)).slice(-2); + } + return s.slice(1); +} + +exports.btoa = function(arr) { + return new Buffer(arr,'binary').toString('base64'); +} + +exports.atob = function(base64) { + return new Buffer(base64, 'base64').toString('binary'); +} diff --git a/wsproxy.js b/wsproxy.js new file mode 100644 index 0000000..56e4997 --- /dev/null +++ b/wsproxy.js @@ -0,0 +1,440 @@ +const WebSocketServer = require('./minwebsocket').WebSocketServer; +const { atob, btoa, ab2str, str2u8arr, arrayBufferToString, intFromHex, intToHex, D,E,W, get_file_fd_from_fdn } = require('./util'); +const { connect_forward_listener } = require('./services'); +const { get_socket_fd_from_fdn, socket_loopback_client } = require('./sockets'); +const { readx, writex } = require('./transport'); + +var dprintfln = ()=>{};//D; +WebSocketServer.DEFAULT_ADB_PORT = 5037; + +var proxy = { + + Server: function(port, adbport) { + // Listen for websocket connections. + var wsServer = new WebSocketServer(port); + wsServer.adbport = adbport; + wsServer.setADBPort = function(port) { + if (typeof(port) === 'undefined') + return this.adbport = WebSocketServer.DEFAULT_ADB_PORT; + return this.adbport = port; + } + + // A list of connected websockets. + var connectedSockets = []; + + function indexof_connected_socket(socketinfo) { + if (!socketinfo) return -1; + for (var i=0; i < connectedSockets.length; i++) + if (connectedSockets[i] === socketinfo) + return i; + return -1; + } + + wsServer.onconnection = function(req) { + + var ws = req.accept(); + var si = { + wsServer: wsServer, + ws: ws, + fn: check_client_version, + fdarr: [], + }; + connectedSockets.push(si); + + ws.onmessage = function(e) { + si.fn(si, e); + }; + + // When a socket is closed, remove it from the list of connected sockets. + ws.onclose = function() { + while (si.fdarr.length) { + si.fdarr.pop().close(); + } + var idx = indexof_connected_socket(si); + if (idx>=0) connectedSockets.splice(idx, 1); + else D('Cannot find disconnected socket in connectedSockets'); + }; + + return true; + }; + + D('WebSocketServer started. Listening on port: %d', port); + + return wsServer; + } +} + +var check_client_version = function(si, e) { + if (e.data !== 'vscadb client version 1') { + D('Wrong client version: ', e.data); + return end_of_connection(si); + } + si.fn = handle_proxy_command; + si.ws.send('vscadb proxy version 1'); +} + +var end_of_connection = function(si) { + if (!si || !si.ws) return; + si.ws.close(); +} + +var handle_proxy_command = function(si, e) { + if (!e || !e.data || e.data.length<2) return end_of_connection(si); + var cmd = e.data.slice(0,2); + var fn = proxy_command_fns[cmd]; + if (!fn) { + E('Unknown command: %s', e.data); + return end_of_connection(si); + } + fn(si, e); +} + +function end_of_command(si, respfmt) { + if (!si || !si.ws || !respfmt) return; + // format the response - we allow %s, %d and %xX + var response = respfmt; + var fmtidx = 0; + for (var i=2; i < arguments.length; i++) { + var fmt = response.slice(fmtidx).match(/%([sdxX])/); + if (!fmt) break; + response = [response.slice(0,fmt.index),arguments[i],response.slice(fmt.index+2)]; + switch(fmt[1]) { + case 'x': response[1] = response[1].toString(16).toLowerCase(); break; + case 'X': response[1] = response[1].toString(16).toUpperCase(); break; + } + response = response.join(''); + fmtidx = fmt.index + arguments[i].length; + } + si.ws.send(response); +} + +function readsckt(fd, n, cb) { + readx(fd, n, cb); +} + +function write_adb_command(fd, cmd) { + dprintfln('write_adb_command: %s',cmd); + // write length in hex first + writex(fd, intToHex(cmd.length, 4)); + // then the command + writex(fd, cmd); +} + +function read_adb_status(adbfd, extra, cb) { + + // read back the status + readsckt(adbfd, 4+extra, function(err, data) { + if (err) return cb(); + var status = ab2str(data); + dprintfln("adb status: %s", status); + cb(status); + }); +} + +function read_adb_reply(adbfd, b64encode, cb) { + + // read reply length + readsckt(adbfd, 4, function(err, data) { + if (err) return cb(); + var n = intFromHex(ab2str(data)); + dprintfln("adb expected reply: %d bytes", n); + // read reply + readsckt(adbfd, n, function(err, data) { + if (err) return cb(); + var n = data.byteLength; + dprintfln("adb reply: %d bytes", n); + var response = ab2str(data); + if (n === 0) response = '\n'; // always send something + dprintfln("%s",response); + if (b64encode) response = btoa(response); + return cb(response); + }); + }); +} + +const min_fd_num = 1000; +var fdn_to_fd = function(n) { + var fd; + if (n >= min_fd_num) fd = get_file_fd_from_fdn(n); + else fd = get_socket_fd_from_fdn(n); + if (!fd) throw new Error('Invalid file descriptor number: '+n); + return fd; +} + +var retryread = function(fd, len, cb) { + fd.readbytes(len, cb); +} + +var retryreadfill = function(fd, len, cb) { + var buf = new Uint8Array(len); + var totalread = 0; + var readmore = function(amount) { + fd.readbytes(amount, function(err, data) { + if (err) return cb(err); + buf.set(data, totalread); + totalread += data.byteLength; + var diff = len - totalread; + if (diff > 0) return readmore(diff); + cb(err, buf); + }); + }; + readmore(len); +} + +var be2le = function(buf) { + var x = new Uint8Array(buf); + var a = x[0]; + a = (a<<8)+x[1]; + a = (a<<8)+x[2]; + a = (a<<8)+x[3]; + return a; +} + +var jdwpReplyMonitor = function(fd, si, packets) { + if (!packets) { + packets = 0; + dprintfln("jdwpReplyMonitor thread started. jdwpfd:%d.", fd.n); + } + + //dprintfln("WAITING FOR JDWP DATA...."); + //int* pjdwpdatalen = (int*)&buffer[0]; + //*pjdwpdatalen=0; + retryread(fd, 4, function(err, data) { + if (err) return terminate(); + + var m = data.byteLength; + if (m != 4) { + dprintfln("rj %d len read", m); + return terminate(); + } + m = be2le(data.buffer.slice(0,4)); + //dprintfln("STARTING JDWP DATA: %.8x....", m); + + var lenstr = arrayBufferToString(data.buffer); + + retryreadfill(fd, m-4, function(err, data) { + if (err) return terminate(); + + var n = data.byteLength + 4; + if (n != m) { + dprintfln("rj read incomplete %d/%d", (n+4),m); + return terminate(); + } + //dprintfln("GOT JDWP DATA...."); + dprintfln("rj encoding %d bytes", n); + var response = "rj ok "; + response += btoa(lenstr + arrayBufferToString(data.buffer)); + + si.ws.send(response); + //dprintfln("SENT JDWP REPLY...."); + packets++; + + jdwpReplyMonitor(fd, si, packets); + }); + }); + + function terminate() { + // try and send a final event reply indicating the VM has disconnected + var vmdisconnect = [ + 0,0,0,17, // len + 100,100,100,100, // id + 0, //flags + 0x40,0x64, // errcode = composite event + 0, //suspend + 0,0,0,1, // eventcount + 100, // eventkind=VM_DISCONNECTED + ]; + var response = "rj ok "; + response += btoa(ab2str(new Uint8Array(vmdisconnect))); + si.ws.send(response); + dprintfln("jdwpReplyMonitor thread finished. Sent:%d packets.", packets); + } +} + + +var stdoutMonitor = function(fd, si, packets) { + if (!packets) { + packets = 0; + dprintfln("stdoutMonitor thread started. jdwpfd:%d, wsfd:%o.", fd.n, si); + } + + retryread(fd, function(err, data) { + if (err) return terminate(); + var response = 'so ok '+btoa(ab2str(new Uint8Array(data))); + si.ws.send(response); + packets++; + + stdoutMonitor(fd, si, packets); + }); + + function terminate() { + // send a unique terminating string to indicate the stdout monitor has finished + var eoso = "eoso:d10d9798-1351-11e5-bdd9-5b316631f026"; + var response = "so ok " + btoa(eoso); + si.ws.send(response); + dprintfln("stdoutMonitor thread finished. Sent:%d packets.", packets); + } +} + +// commands are: +// cn - create adb socket +// cp - create custom-port socket +// wa - write_adb_command +// rs [extra] - read_adb_status +// ra - read_adb_reply +// rj - read jdwp-formatted reply +// rx - read raw data from adb socket +// wx - write raw data to adb socket +// dc - disconnect adb sockets + +var proxy_command_fns = { + cn:function(si, e) { + // create adb socket + socket_loopback_client(si.wsServer.adbport, function(fd) { + if (!fd) { + return end_of_command(si, 'cn error connection failed'); + } + si.fdarr.push(fd); + return end_of_command(si, 'cn ok %d', fd.n); + }); + }, + + cp:function(si, e) { + var x = e.data.split(' '), port; + port = parseInt(x[1], 10); + connect_forward_listener(port, {create:true}, function(sfd) { + return end_of_command(si, 'cp ok %d', sfd.n); + }); + }, + + wa:function(si, e) { + var x = e.data.split(' '), fd, buffer; + try { + var fdn = parseInt(x[1], 10); + fd = fdn_to_fd(fdn); + buffer = atob(x[2]); + } catch(err) { + return end_of_command(si, 'wa error wrong parameters'); + } + write_adb_command(fd, buffer); + return end_of_command(si, 'wa ok'); + }, + + // rs fd [extra] + rs:function(si, e) { + var x = e.data.split(' '), fd, extra; + try { + var fdn = parseInt(x[1], 10); + fd = fdn_to_fd(fdn); + // optional additional bytes - used for sync-responses which + // send status+length as 8 bytes + extra = parseInt(atob(x[2]||'MA==')); + } catch(err) { + return end_of_command(si, 'rs error wrong parameters'); + } + read_adb_status(fd, extra, function(status) { + return end_of_command(si, 'rs ok %s', status||''); + }) + }, + + ra:function(si, e) { + var x = e.data.split(' '), fd; + try { + var fdn = parseInt(x[1], 10); + fd = fdn_to_fd(fdn); + } catch(err) { + return end_of_command(si, 'ra error wrong parameters'); + } + read_adb_reply(fd, true, function(b64adbreply) { + if (!b64adbreply) { + return end_of_command('ra error read failed'); + } + return end_of_command(si, 'ra ok %s', b64adbreply); + }); + }, + + rj:function(si, e) { + var x = e.data.split(' '), fd; + try { + var fdn = parseInt(x[1], 10); + fd = fdn_to_fd(fdn); + } catch(err) { + return end_of_command(si, 'rj error wrong parameters'); + } + jdwpReplyMonitor(fd, si); + return end_of_command(si, 'rj ok'); + }, + + rx:function(si, e) { + var x = e.data.split(' '), fd; + try { + var fdn = parseInt(x[1], 10); + fd = fdn_to_fd(fdn); + } catch(err) { + return end_of_command(si, 'rx error wrong parameters'); + } + if (fd.isSocket) { + fd.readbytes(doneread); + } else { + fd.readbytes(fd.readpipe.byteLength, doneread); + } + function doneread(err, data) { + if (err) { + return end_of_command(si, 'rx ok nomore'); + } + end_of_command(si, 'rx ok ' + btoa(ab2str(data))); + } + }, + + so:function(si, e) { + var x = e.data.split(' '), fd; + try { + var fdn = parseInt(x[1], 10); + fd = fdn_to_fd(fdn); + } catch(err) { + return end_of_command(si, 'so error wrong parameters'); + } + stdoutMonitor(fd, si); + return end_of_command(si, 'so ok'); + }, + + wx:function(si, e) { + var x = e.data.split(' '), fd, buffer; + try { + var fdn = parseInt(x[1], 10); + fd = fdn_to_fd(fdn); + buffer = atob(x[2]); + } catch(err) { + return end_of_command(si, 'wx error wrong parameters'); + } + + fd.writebytes(str2u8arr(buffer), function(err) { + if (err) + return end_of_command(si, 'wx error device write failed'); + end_of_command(si, 'wx ok'); + }); + }, + + dc:function(si, e) { + var x = e.data.split(' '); + if (x[1] === 'all') { + while (si.fdarr.length) { + si.fdarr.pop().close(); + } + return end_of_command(si, 'dc ok'); + } + + var n = parseInt(x[1],10); + for (var i=0; i < si.fdarr.length; i++) { + if (si.fdarr[i].n === n) { + var fd = si.fdarr.splice(i,1)[0]; + fd.close(); + break; + } + } + return end_of_command(si, 'dc ok'); + } + +} + +exports.proxy = proxy;