Version 1 (#83)

* replace jq-promises with native Promises

* updates to use native promises and async await

* Fix variable errors, remove extra parameters and correct export declaratons

* refactor launch request to use async/await

* fix running debugger on custom ADB port

* remove unused files

* move socket_ended check to ensure we don't loop reading 0 bytes

* refactor logcat code and ensure disconnect status is passed on to webview

* Fix warnings

* Clean up util and remove unused functions

* convert Debugger into a class

* update jsconfig target to es2018 and enable checkJS

* more updates to use async/await and more readable refactoring.

- added type definitions and debugger classes
- improved expression evaluation
- refactored expressions into parsing, evaluation and variable assignment
- fixed invoking methods with parameters
- added support for static method invokes
- improved exception display reliability
- refactored launch into smaller functions
- refactored utils into smaller modules
- removed redundant code
- converted JDWP functions to classes

* set version 1.0.0 and update dependencies

* add changelog notes
This commit is contained in:
Dave Holoway
2020-04-20 12:53:08 +01:00
committed by GitHub
parent f92f247ef6
commit 0672e54401
45 changed files with 8050 additions and 9829 deletions

143
src/sockets/adbsocket.js Normal file
View File

@@ -0,0 +1,143 @@
const AndroidSocket = require('./androidsocket');
/**
* Manages a socket connection to Android Debug Bridge
*/
class ADBSocket extends AndroidSocket {
/**
* The port number to run ADB on.
* The value can be overriden by the adbPort value in each configuration.
*/
static ADBPort = 5037;
constructor() {
super('ADBSocket');
}
/**
* Reads and checks the reply from an ADB command
* @param {boolean} [throw_on_fail] true if the function should throw on non-OKAY status
*/
async read_adb_status(throw_on_fail = true) {
// read back the status
const status = await this.read_bytes(4, 'latin1')
if (status !== 'OKAY' && throw_on_fail) {
throw new Error(`ADB command failed. Status: '${status}'`);
}
return status;
}
/**
* Reads and decodes an ADB reply. The reply is always in the form XXXXnnnn where XXXX is a 4 digit ascii hex length
*/
async read_adb_reply() {
const hexlen = await this.read_bytes(4, 'latin1');
if (/[^\da-fA-F]/.test(hexlen)) {
throw new Error('Bad ADB reply - invalid length data');
}
return this.read_bytes(parseInt(hexlen, 16), 'latin1');
}
/**
* Writes a command to the ADB socket
* @param {string} command
*/
write_adb_command(command) {
const command_bytes = Buffer.from(command);
const command_length = Buffer.from(('000' + command_bytes.byteLength.toString(16)).slice(-4));
return this.write_bytes(Buffer.concat([command_length, command_bytes]));
}
/**
* Sends an ADB command and checks the returned status
* @param {String} command ADB command to send
* @returns {Promise<string>} OKAY status or rejected
*/
async cmd_and_status(command) {
await this.write_adb_command(command);
return this.read_adb_status();
}
/**
* Sends an ADB command, checks the returned status and then reads the return reply
* @param {String} command ADB command to send
* @returns {Promise<string>} reply string or rejected if the status is not OKAY
*/
async cmd_and_reply(command) {
await this.cmd_and_status(command);
return this.read_adb_reply();
}
/**
* Sends an ADB command, checks the returned status and then reads raw data from the socket
* @param {string} command
*/
async cmd_and_read_stdout(command) {
await this.cmd_and_status(command);
return this.read_stdout();
}
/**
* Copies a file to the device, setting the file time and permissions
* @param {ADBFileTransferParams} file file parameters
*/
async transfer_file(file) {
await this.cmd_and_status('sync:');
// initiate the file send
const filename_and_perms = `${file.pathname},${file.perms}`;
const send_and_fileinfo = Buffer.from(`SEND\0\0\0\0${filename_and_perms}`);
send_and_fileinfo.writeUInt32LE(filename_and_perms.length, 4);
await this.write_bytes(send_and_fileinfo);
// send the file data
await this.write_file_data(file.data);
// send the DONE message with the new filetime
const done_and_mtime = Buffer.from('DONE\0\0\0\0');
done_and_mtime.writeUInt32LE(file.mtime, 4);
await this.write_bytes(done_and_mtime);
// read the final status and any error message
const result = await this.read_adb_status(false);
const failmsg = await this.read_le_length_data('latin1');
// finish the transfer mode
await this.write_bytes('QUIT\0\0\0\0');
if (result !== 'OKAY') {
throw new Error(`File transfer failed. ${failmsg}`);
}
return true;
}
/**
* @param {Buffer} data
*/
async write_file_data(data) {
const dtinfo = {
transferred: 0,
transferring: 0,
chunk_size: 10240,
};
for (;;) {
dtinfo.transferred += dtinfo.transferring;
const remaining = data.byteLength - dtinfo.transferred;
if (remaining <= 0 || isNaN(remaining)) {
return dtinfo.transferred;
}
const datalen = Math.min(remaining, dtinfo.chunk_size);
const cmd = Buffer.concat([Buffer.from(`DATA\0\0\0\0`), data.slice(dtinfo.transferred, dtinfo.transferred + datalen)]);
cmd.writeUInt32LE(datalen, 4);
dtinfo.transferring = datalen;
await this.write_bytes(cmd);
}
}
}
module.exports = ADBSocket;

View File

@@ -0,0 +1,159 @@
const net = require('net');
const EventEmitter = require('events');
/**
* Common socket class for ADBSocket and JDWPSocket
*/
class AndroidSocket extends EventEmitter {
constructor(which) {
super()
this.which = which;
this.socket = null;
this.socket_error = null;
this.socket_ended = false;
this.readbuffer = Buffer.alloc(0);
}
connect(port, hostname) {
return new Promise((resolve, reject) => {
if (this.socket) {
return reject(new Error(`${this.which} Socket connect failed. Socket already connected.`));
}
const connection_error = err => {
return reject(new Error(`${this.which} Socket connect failed. ${err.message}.`));
}
const post_connection_error = err => {
this.socket_error = err;
this.socket.end();
}
let error_handler = connection_error;
this.socket = new net.Socket()
.once('connect', () => {
error_handler = post_connection_error;
this.socket
.on('data', buffer => {
this.readbuffer = Buffer.concat([this.readbuffer, buffer]);
this.emit('data-changed');
})
.once('end', () => {
this.socket_ended = true;
this.emit('socket-ended');
if (!this.socket_disconnecting) {
this.socket_disconnecting = this.socket_error ? Promise.reject(this.socket_error) : Promise.resolve();
}
});
resolve();
})
.on('error', err => error_handler(err));
this.socket.connect(port, hostname);
});
}
disconnect() {
if (!this.socket_disconnecting) {
this.socket_disconnecting = new Promise(resolve => {
this.socket.end();
this.socket = null;
this.once('socket-ended', resolve);
});
}
return this.socket_disconnecting;
}
/**
*
* @param {number|'length+data'|undefined} length
* @param {string} [format]
*/
async read_bytes(length, format) {
//D(`reading ${length} bytes`);
let actual_length = length;
if (typeof actual_length === 'undefined') {
if (this.readbuffer.byteLength > 0 || this.socket_ended) {
actual_length = this.readbuffer.byteLength;
}
}
if (actual_length < 0) {
throw new Error(`${this.which} socket read failed. Attempt to read ${actual_length} bytes.`);
}
if (length === 'length+data' && this.readbuffer.byteLength >= 4) {
length = actual_length = this.readbuffer.readUInt32BE(0);
}
if (this.socket_ended) {
if (actual_length <= 0 || (this.readbuffer.byteLength < actual_length)) {
this.check_socket_active('read');
}
}
// do we have enough data in the buffer?
if (this.readbuffer.byteLength >= actual_length) {
//D(`got ${actual_length} bytes`);
let data = this.readbuffer.slice(0, actual_length);
this.readbuffer = this.readbuffer.slice(actual_length);
if (format) {
data = data.toString(format);
}
return Promise.resolve(data);
}
// wait for the socket to update and then retry the read
await this.wait_for_socket_data();
return this.read_bytes(length, format);
}
wait_for_socket_data() {
return new Promise((resolve, reject) => {
let done = 0;
let onDataChanged = () => {
if ((done += 1) !== 1) return;
this.off('socket-ended', onSocketEnded);
resolve();
}
let onSocketEnded = () => {
if ((done += 1) !== 1) return;
this.off('data-changed', onDataChanged);
reject(new Error(`${this.which} socket read failed. Socket closed.`));
}
this.once('data-changed', onDataChanged);
this.once('socket-ended', onSocketEnded);
});
}
async read_le_length_data(format) {
const len = await this.read_bytes(4);
return this.read_bytes(len.readUInt32LE(0), format);
}
read_stdout(format = 'latin1') {
return this.read_bytes(undefined, format);
}
/**
* Writes a raw command to the socket
* @param {string|Buffer} bytes
*/
write_bytes(bytes) {
return new Promise((resolve, reject) => {
this.check_socket_active('write');
try {
const flushed = this.socket.write(bytes, () => {
flushed ? resolve() : this.socket.once('drain', resolve);
});
} catch (e) {
this.socket_error = e;
reject(new Error(`${this.which} socket write failed. ${e.message}`));
}
});
}
/**
*
* @param {'read'|'write'} action
*/
check_socket_active(action) {
if (this.socket_ended) {
throw new Error(`${this.which} socket ${action} failed. Socket closed.`);
}
}
}
module.exports = AndroidSocket;

122
src/sockets/jdwpsocket.js Normal file
View File

@@ -0,0 +1,122 @@
const AndroidSocket = require('./androidsocket');
/**
* Manages a JDWP connection to the device
* The debugger uses ADB to setup JDWP port forwarding to the device - this class
* connects to the local forwarding port
*/
class JDWPSocket extends AndroidSocket {
/**
* @param {(data)=>*} decode_reply function used for decoding raw JDWP data
* @param {()=>void} on_disconnect function called when the socket disconnects
*/
constructor(decode_reply, on_disconnect) {
super('JDWP')
this.decode_reply = decode_reply;
this.on_disconnect = on_disconnect;
/** @type {Map<*,function>} */
this.cmds_in_progress = new Map();
this.cmd_queue = [];
}
/**
* Performs the JDWP handshake and begins reading the socket for JDWP events/replies
*/
async start() {
const handshake = 'JDWP-Handshake';
await this.write_bytes(handshake);
const handshake_reply = await this.read_bytes(handshake.length, 'latin1');
if (handshake_reply !== handshake) {
throw new Error('JDWP handshake failed');
}
this.start_jdwp_reply_reader();
return true;
}
/**
* Continuously reads replies from the JDWP socket. After each reply is read,
* it's matched up with its corresponding command using the request ID.
*/
async start_jdwp_reply_reader() {
for (;;) {
let data;
try {
data = await this.read_bytes('length+data'/* , 'latin1' */)
} catch (e) {
// ignore socket closed errors (sent when the debugger disconnects)
if (!/socket closed/i.test(e.message))
throw e;
if (typeof this.on_disconnect === 'function') {
this.on_disconnect();
}
return;
}
const reply = this.decode_reply(data);
const on_reply = this.cmds_in_progress.get(reply.command);
if (on_reply) {
on_reply(reply);
}
}
}
/**
* Send a single command to the device and wait for the reply
* @param {*} command
*/
process_cmd(command) {
return new Promise(resolve => {
// add the command to the in-progress set
this.cmds_in_progress.set(command, reply => {
// once the command has completed, delete it from in-progress and resolve the promise
this.cmds_in_progress.delete(command);
resolve(reply);
});
// send the raw command bytes to the device
this.write_bytes(command.toBuffer());
});
}
/**
* Drain the queue of JDWP commands waiting to be sent to the device
*/
async run_cmd_queue() {
for (;;) {
if (this.cmd_queue.length === 0) {
return;
}
const { command, resolve, reject } = this.cmd_queue[0];
const reply = await this.process_cmd(command);
if (reply.errorcode) {
class JDWPCommandError extends Error {
constructor(reply) {
super(`JDWP command failed '${reply.command.name}'. Error ${reply.errorcode}`);
this.command = reply.command;
this.errorcode = reply.errorcode;
}
}
reject(new JDWPCommandError(reply));
} else {
resolve(reply);
}
this.cmd_queue.shift();
}
}
/**
* Queue a command to be sent to the device and wait for the reply
* @param {*} command
*/
async cmd_and_reply(command) {
return new Promise((resolve, reject) => {
const queuelen = this.cmd_queue.push({
command,
resolve, reject
})
if (queuelen === 1) {
this.run_cmd_queue();
}
})
}
}
module.exports = JDWPSocket;