diff --git a/extension.js b/extension.js index 4fc8331..6e9cc79 100644 --- a/extension.js +++ b/extension.js @@ -1,23 +1,43 @@ // 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'); +const vscode = require('vscode'); +const { AndroidContentProvider, openLogcatWindow } = require('./src/logcat'); + +function getADBPort() { + var adbPort = 5037; + // there's surely got to be a better way than this... + var configs = vscode.workspace.getConfiguration('launch.configurations'); + for (var i=0,config; config=configs.get(''+i); i++) { + if (config.type!=='android') continue; + if (config.request!=='launch') continue; + if (typeof config.adbPort === 'number' && config.adbPort === (config.adbPort|0)) + adbPort = config.adbPort; + break; + } + return adbPort; +} // 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 src/debugMain.js */ + /* Only the logcat stuff is configured here. The debugger is launched from src/debugMain.js */ + AndroidContentProvider.register(context, vscode.workspace); + + // logcat connections require the (fake) websocket proxy to be up + // - take the ADB port from launch.json + const wsproxyserver = require('./src/wsproxy').proxy.Server(6037, getADBPort()); // 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" - }); + // add the view logcat handler + vscode.commands.registerCommand('android-dev-ext.view_logcat', () => { + openLogcatWindow(vscode); + }), + // watch for changes in the launch config + vscode.workspace.onDidChangeConfiguration(e => { + wsproxyserver.setADBPort(getADBPort()); }) - */ ]; var spliceparams = [context.subscriptions.length,0].concat(disposables); diff --git a/package.json b/package.json index c01fa0d..cb7f540 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,21 @@ "color": "#5c2d91", "theme": "dark" }, - "activationEvents": [], + "activationEvents": [ + "onCommand:android-dev-ext.view_logcat" + ], "repository": { "type": "git", "url": "https://github.com/adelphes/android-dev-ext" }, "main": "./extension", "contributes": { - "commands": [], + "commands": [ + { + "command": "android-dev-ext.view_logcat", + "title": "Android: View Logcat" + } + ], "breakpoints": [ { "language": "java" @@ -107,6 +114,7 @@ "dependencies": { "vscode-debugprotocol": "^1.15.0", "vscode-debugadapter": "^1.15.0", + "ws":"^1.1.1", "xmldom": "^0.1.27", "xpath": "^0.0.23" }, diff --git a/src/jq-promise.js b/src/jq-promise.js index b6bedb1..a0c726f 100644 --- a/src/jq-promise.js +++ b/src/jq-promise.js @@ -28,6 +28,14 @@ var Deferred = exports.Deferred = function(p, parent) { thendef._promise = thendef._original = p; return thendef; }, + always(fn) { + var thendef = this.then(fn); + this.fail(function() { + // we cannot bind thendef to the function because we need the caller's this to resolve the thendef + thendef.resolveWith(this, Array.prototype.map.call(arguments,x=>x)); + }); + return thendef; + }, fail(fn) { var faildef = $.Deferred(null, this); var p = this._promise.catch(function(a) { diff --git a/src/logcat.js b/src/logcat.js new file mode 100644 index 0000000..5440768 --- /dev/null +++ b/src/logcat.js @@ -0,0 +1,335 @@ +'use strict' +// vscode stuff +const { EventEmitter, Uri } = require('vscode'); +// node and external modules +const os = require('os'); +const path = require('path'); +const WebSocketServer = require('ws').Server; +// our stuff +const { ADBClient } = require('./adbclient'); +const $ = require('./jq-promise'); +const { D } = require('./util'); + +/* + Class to setup and store logcat data + */ +class LogcatContent { + + constructor(provider/*: AndroidContentProvider*/, uri/*: Uri*/) { + this._provider = provider; + this._uri = uri; + this._logcatid = uri.query; + this._logs = []; + this._htmllogs = []; + this._oldhtmllogs = []; + this._prevlogs = null; + this._notifying = 0; + this._refreshRate = 200; // ms + this._state = ''; + this._adbclient = new ADBClient(uri.query); + 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(x => { + this._state = 'connected'; + this._initwait = null; + resolve(this.content); + }).fail(e => { + this._state = 'connect_failed'; + reject(e) + this._provider._notifyLogDisconnected(this); + }) + }); + } + get content() { + if (this._initwait) return this._initwait; + if (this._state !== 'disconnected') + return this.htmlBootstrap(true, ''); + // 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(x => { + // 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(false, 'Device disconnected'); + resolve(cached_content); + }) + }); + } + sendDisconnectMsg() { + var clients = LogcatContent._wss.clients.filter(client => client._logcatid === this._logcatid); + clients.forEach(client => client.send(':disconnect')); + } + 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, 5000); + this._htmllogs = [], this._logs = []; + } + htmlBootstrap(connected, statusmsg) { + return ` + + + +
${statusmsg}
+
${this._oldhtmllogs.join(os.EOL)}
+ + + `; + } + 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 mrfirst = e.logs.slice().reverse(); + this._logs = mrfirst.concat(this._logs); + mrfirst.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(); + } +} + +LogcatContent.initWebSocketServer = function () { + if (LogcatContent._wssdone) { + // already inited + return LogcatContent._wssdone; + } + LogcatContent._wssdone = $.Deferred(); + ({ + wss: null, + port: 31100, + 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._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]; + // we're not really interested in anything the client sends + /*client.on('message', message => { + console.log('ws received: %s', message); + }); + client.on('close', e => { + console.log('ws close'); + });*/ + }); + 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; +} + +class AndroidContentProvider /*extends TextDocumentContentProvider*/ { + + constructor() { + this._logs = {}; // hashmap + this._onDidChange = new EventEmitter(); + } + + dispose() { + this._onDidChange.dispose(); + } + + /** + * An event to signal a resource has changed. + */ + get onDidChange() { + return this._onDidChange.event; + } + + /** + * Provide textual content for a given uri. + * + * The editor will use the returned string-content to create a readonly + * [document](TextDocument). Resources allocated should be released when + * the corresponding document has been [closed](#workspace.onDidCloseTextDocument). + * + * @param uri An uri which scheme matches the scheme this provider was [registered](#workspace.registerTextDocumentContentProvider) for. + * @param token A cancellation token. + * @return A string or a thenable that resolves to such. + */ + provideTextDocumentContent(uri/*: Uri*/, token/*: CancellationToken*/)/*: string | Thenable;*/ { + var doc = this._logs[uri]; + if (doc) return this._logs[uri].content; + switch (uri.authority) { + case 'logcat': return this.provideLogcatDocumentContent(uri); + } + throw new Error('Document Uri not recognised'); + } + + provideLogcatDocumentContent(uri) { + var doc = this._logs[uri] = new LogcatContent(this, uri); + return doc.content; + } +} + +// the statics +AndroidContentProvider.SCHEME = 'android-dev-ext'; // android-dev-ext://logcat/read?device= +AndroidContentProvider.register = (ctx, workspace) => { + var provider = new AndroidContentProvider(); + var registration = workspace.registerTextDocumentContentProvider(AndroidContentProvider.SCHEME, provider); + ctx.subscriptions.push(registration); + ctx.subscriptions.push(provider); +} +AndroidContentProvider.getReadLogcatUri = (deviceId) => { + var uri = Uri.parse(`${AndroidContentProvider.SCHEME}://logcat/logcat-${deviceId}.txt`); + return uri.with({ + query: deviceId + }); +} + +function openLogcatWindow(vscode) { + 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 => { + 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.AndroidContentProvider = AndroidContentProvider; +exports.openLogcatWindow = openLogcatWindow;