'use strict'
// node and external modules
const fs = require('fs');
const os = require('os');
const path = require('path');
const WebSocketServer = require('ws').Server;
// our stuff
const { ADBClient } = require('./adbclient');
const { AndroidContentProvider } = require('./contentprovider');
const $ = require('./jq-promise');
const { D } = require('./util');
/*
Class to setup and store logcat data
*/
class LogcatContent {
constructor(deviceid) {
this._logcatid = deviceid;
this._logs = [];
this._htmllogs = [];
this._oldhtmllogs = [];
this._prevlogs = null;
this._notifying = 0;
this._refreshRate = 200; // ms
this._state = '';
this._htmltemplate = '';
this._adbclient = new ADBClient(deviceid);
this._initwait = new Promise((resolve, reject) => {
this._state = 'connecting';
LogcatContent.initWebSocketServer()
.then(() => {
return this._adbclient.logcat({
onlog: this.onLogcatContent.bind(this),
onclose: this.onLogcatDisconnect.bind(this),
});
}).then(() => {
this._state = 'connected';
this._initwait = null;
resolve(this.content);
}).fail(e => {
this._state = 'connect_failed';
reject(e);
})
});
LogcatContent.byLogcatID[this._logcatid] = this;
}
get content() {
if (this._initwait) return this._initwait;
if (this._state !== 'disconnected')
return this.htmlBootstrap({connected:true, status:'',oldlogs:''});
// if we're in the disconnected state, and this.content is called, it means the user has requested
// this logcat again - check if the device has reconnected
return this._initwait = new Promise((resolve/*, reject*/) => {
// clear the logs first - if we successfully reconnect, we will be retrieving the entire logcat again
this._prevlogs = {_logs: this._logs, _htmllogs: this._htmllogs, _oldhtmllogs: this._oldhtmllogs };
this._logs = []; this._htmllogs = []; this._oldhtmllogs = [];
this._adbclient.logcat({
onlog: this.onLogcatContent.bind(this),
onclose: this.onLogcatDisconnect.bind(this),
}).then(() => {
// we successfully reconnected
this._state = 'connected';
this._prevlogs = null;
this._initwait = null;
resolve(this.content);
}).fail((/*e*/) => {
// reconnection failed - put the logs back and return the cached info
this._logs = this._prevlogs._logs;
this._htmllogs = this._prevlogs._htmllogs;
this._oldhtmllogs = this._prevlogs._oldhtmllogs;
this._prevlogs = null;
this._initwait = null;
var cached_content = this.htmlBootstrap({connected:false, status:'Device disconnected',oldlogs: this._oldhtmllogs.join(os.EOL)});
resolve(cached_content);
})
});
}
sendClientMessage(msg) {
var clients = LogcatContent._wss.clients.filter(client => client._logcatid === this._logcatid);
clients.forEach(client => client.send(msg+'\n')); // include a newline to try and persuade a buffer write
}
sendDisconnectMsg() {
this.sendClientMessage(':disconnect');
}
onClientConnect(client) {
if (this._oldhtmllogs.length) {
var lines = '
' + this._oldhtmllogs.join(os.EOL) + '
';
client.send(lines);
}
// if the window is tabbed away and then returned to, vscode assumes the content
// has not changed from the original bootstrap. So it proceeds to load the html page (with no data),
// causing a connection to the WSServer as if the connection is still valid (which it was, originally).
// If it's not, tell the client (again) that the device has disconnected
if (this._state === 'disconnected')
this.sendDisconnectMsg();
}
onClientMessage(client, message) {
if (message === 'cmd:clear_logcat') {
if (this._state !== 'connected') return;
new ADBClient(this._adbclient.deviceid).shell_cmd({command:'logcat -c'})
.then(() => {
// clear everything and tell the clients
this._logs = []; this._htmllogs = []; this._oldhtmllogs = [];
this.sendClientMessage(':logcat_cleared');
})
.fail(e => {
D('Clear logcat command failed: ' + e.message);
})
}
}
updateLogs() {
// no point in formatting the data if there are no connected clients
var clients = LogcatContent._wss.clients.filter(client => client._logcatid === this._logcatid);
if (clients.length) {
var lines = '' + this._htmllogs.join('') + '
';
clients.forEach(client => client.send(lines));
}
// once we've updated all the clients, discard the info
this._oldhtmllogs = this._htmllogs.concat(this._oldhtmllogs).slice(0, 10000);
this._htmllogs = [], this._logs = [];
}
htmlBootstrap(vars) {
if (!this._htmltemplate)
this._htmltemplate = fs.readFileSync(path.join(__dirname,'res/logcat.html'), 'utf8');
vars = Object.assign({
logcatid: this._logcatid,
wssport: LogcatContent._wssport,
}, vars);
// simple value replacement using !{name} as the placeholder
var html = this._htmltemplate.replace(/!\{(.*?)\}/g, (match,expr) => ''+(vars[expr.trim()]||''));
return html;
}
renotify() {
if (++this._notifying > 1) return;
this.updateLogs();
setTimeout(() => {
if (--this._notifying) {
this._notifying = 0;
this.renotify();
}
}, this._refreshRate);
}
onLogcatContent(e) {
if (e.logs.length) {
var mrlast = e.logs.slice();
this._logs = this._logs.concat(mrlast);
mrlast.forEach(log => {
if (!(log = log.trim())) return;
// replace html-interpreted chars
var m = log.match(/^\d\d-\d\d\s+?\d\d:\d\d:\d\d\.\d+?\s+?(.)/);
var style = (m && m[1]) || '';
log = log.replace(/[&"'<>]/g, c => ({ '&': '&', '"': '"', "'": ''', '<': '<', '>': '>' }[c]));
this._htmllogs.unshift(`${log}
`);
});
this.renotify();
}
}
onLogcatDisconnect(/*e*/) {
if (this._state === 'disconnected') return;
this._state = 'disconnected';
this.sendDisconnectMsg();
}
}
// hashmap of all LogcatContent instances, keyed on device id
LogcatContent.byLogcatID = {};
LogcatContent.initWebSocketServer = function () {
if (LogcatContent._wssdone) {
// already inited
return LogcatContent._wssdone;
}
// retrieve the logcat websocket port
var default_wssport = 7038;
var wssport = AndroidContentProvider.getLaunchConfigSetting('logcatPort', default_wssport);
if (typeof wssport !== 'number' || wssport <= 0 || wssport >= 65536 || wssport !== (wssport|0))
wssport = default_wssport;
LogcatContent._wssdone = $.Deferred();
({
wss: null,
startport: wssport,
port: wssport,
retries: 0,
tryCreateWSS() {
this.wss = new WebSocketServer({ host: '127.0.0.1', port: this.port }, () => {
// success - save the info and resolve the deferred
LogcatContent._wssport = this.port;
LogcatContent._wssstartport = this.startport;
LogcatContent._wss = this.wss;
this.wss.on('connection', client => {
// the client uses the url path to signify which logcat data it wants
client._logcatid = client.upgradeReq.url.match(/^\/?(.*)$/)[1];
var lc = LogcatContent.byLogcatID[client._logcatid];
if (lc) lc.onClientConnect(client);
else client.close();
client.on('message', function(message) {
var lc = LogcatContent.byLogcatID[this._logcatid];
if (lc) lc.onClientMessage(this, message);
}.bind(client));
/*client.on('close', e => {
console.log('client close');
});*/
// try and make sure we don't delay writes
client._socket && typeof(client._socket.setNoDelay)==='function' && client._socket.setNoDelay(true);
});
this.wss = null;
LogcatContent._wssdone.resolveWith(LogcatContent, []);
});
this.wss.on('error', (/*err*/) => {
if (!LogcatContent._wss) {
// listen failed -try the next port
this.retries++ , this.port++;
this.tryCreateWSS();
}
})
}
}).tryCreateWSS();
return LogcatContent._wssdone;
}
function getADBPort() {
var defaultPort = 5037;
var adbPort = AndroidContentProvider.getLaunchConfigSetting('adbPort', defaultPort);
if (typeof adbPort === 'number' && adbPort === (adbPort|0))
return adbPort;
return defaultPort;
}
function openLogcatWindow(vscode) {
new ADBClient().test_adb_connection()
.then(err => {
// if adb is not running, see if we can start it ourselves using ANDROID_HOME (and a sensible port number)
var adbport = getADBPort();
var autoStartADB = AndroidContentProvider.getLaunchConfigSetting('autoStartADB', true);
if (err && autoStartADB!==false && process.env.ANDROID_HOME && typeof adbport === 'number' && adbport > 0 && adbport < 65536) {
var adbpath = path.join(process.env.ANDROID_HOME, 'platform-tools', /^win/.test(process.platform)?'adb.exe':'adb');
var adbargs = ['-P',''+adbport,'start-server'];
try {
/*var stdout = */require('child_process').execFileSync(adbpath, adbargs, {cwd:process.env.ANDROID_HOME, encoding:'utf8'});
} catch (ex) {} // if we fail, it doesn't matter - the device query will fail and the user will have to work it out themselves
}
})
.then(() => new ADBClient().list_devices())
.then(devices => {
switch(devices.length) {
case 0:
vscode.window.showInformationMessage('Logcat cannot be displayed. No Android devices are currently connected');
return null;
case 1:
return devices; // only one device - just show it
}
var multidevicewait = $.Deferred(), prefix = 'Android: View Logcat - ', all = '[ Display All ]';
var devicelist = devices.map(d => prefix + d.serial);
//devicelist.push(prefix + all);
vscode.window.showQuickPick(devicelist)
.then(which => {
if (!which) return; // user cancelled
which = which.slice(prefix.length);
new ADBClient().list_devices()
.then(devices => {
if (which === all) return multidevicewait.resolveWith(this,[devices]);
var found = devices.find(d => d.serial===which);
if (found) return multidevicewait.resolveWith(this,[[found]]);
vscode.window.showInformationMessage('Logcat cannot be displayed. The device is disconnected');
});
});
return multidevicewait;
})
.then(devices => {
if (!Array.isArray(devices)) return; // user cancelled (or no devices connected)
devices.forEach(device => {
if (vscode.window.createWebviewPanel) {
const panel = vscode.window.createWebviewPanel(
'androidlogcat', // Identifies the type of the webview. Used internally
`logcat-${device.serial}`, // Title of the panel displayed to the user
vscode.ViewColumn.One, // Editor column to show the new webview panel in.
{
enableScripts: true,
}
);
const logcat = new LogcatContent(device.serial);
logcat.content.then(html => {
panel.webview.html = html;
});
return;
}
var uri = AndroidContentProvider.getReadLogcatUri(device.serial);
return vscode.commands.executeCommand("vscode.previewHtml",uri,vscode.ViewColumn.Two);
});
})
.fail((/*e*/) => {
vscode.window.showInformationMessage('Logcat cannot be displayed. Querying the connected devices list failed. Is ADB running?');
});
}
exports.LogcatContent = LogcatContent;
exports.openLogcatWindow = openLogcatWindow;