diff --git a/.travis.yml b/.travis.yml index 8e633bb4..de2cc8d2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,6 +10,7 @@ addons: - libprotobuf-dev - graphicsmagick - rethinkdb + - yasm script: - gulp build before_script: diff --git a/Dockerfile b/Dockerfile index a698121b..03bf8af9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM openstf/base:v1.0.2 +FROM openstf/base:v1.0.4 # Sneak the stf executable into $PATH. ENV PATH /app/bin:$PATH diff --git a/README.md b/README.md index 90a5fe0d..53c2ace0 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ It is currently being used at [CyberAgent](https://www.cyberagent.co.jp/en/) to * Run any `adb` command locally, including shell access * [Android Studio](http://developer.android.com/tools/studio/index.html) and other IDE support, debug your app while watching the device screen on your browser * Supports [Chrome remote debug tools](https://developer.chrome.com/devtools/docs/remote-debugging) + - Experimental VNC support (work in progress) * Manage your device inventory - See which devices are connected, offline/unavailable (indicating a weak USB connection), unauthorized or unplugged - See who's using a device @@ -65,14 +66,15 @@ As the product has evolved from an internal tool running in our internal network * [GraphicsMagick](http://www.graphicsmagick.org/) (for resizing screenshots) * [ZeroMQ](http://zeromq.org/) libraries installed * [Protocol Buffers](https://github.com/google/protobuf) libraries installed +* [yasm](http://yasm.tortall.net/) installed (for compiling embedded [libjpeg-turbo](https://github.com/sorccu/node-jpeg-turbo)) * [pkg-config](http://www.freedesktop.org/wiki/Software/pkg-config/) so that Node.js can find the libraries -Note that you need these dependencies even if you've installed STF directly from [NPM](https://www.npmjs.com/), because they can't be included. +Note that you need these dependencies even if you've installed STF directly from [NPM](https://www.npmjs.com/), because they can't be included in the package. On OS X, you can use [homebrew](http://brew.sh/) to install most of the dependencies: ```bash -brew install rethinkdb graphicsmagick zeromq protobuf pkg-config +brew install rethinkdb graphicsmagick zeromq protobuf yasm pkg-config ``` On Windows you're on your own. In theory you might be able to get STF installed via [Cygwin](https://www.cygwin.com/) or similar, but we've never tried. In principle we will not provide any Windows installation support, but please do send a documentation pull request if you figure out what to do. diff --git a/lib/cli.js b/lib/cli.js index 9b0ed5c9..ddd9c5b4 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -68,6 +68,10 @@ program , 'adb connect URL pattern' , String , '${publicIp}:${publicPort}') + .option('--vnc-initial-size ' + , 'initial VNC size' + , cliutil.size + , [600, 800]) .option('--mute-master' , 'whether to mute master volume when devices are being used') .option('--lock-rotation' @@ -103,6 +107,7 @@ program , '--connect-push', options.connectPush.join(',') , '--screen-port', ports.shift() , '--connect-port', ports.shift() + , '--vnc-port', ports.shift() , '--public-ip', options.publicIp , '--group-timeout', options.groupTimeout , '--storage-url', options.storageUrl @@ -111,6 +116,7 @@ program , '--screen-ws-url-pattern', options.screenWsUrlPattern , '--connect-url-pattern', options.connectUrlPattern , '--heartbeat-interval', options.heartbeatInterval + , '--vnc-initial-size', options.vncInitialSize.join('x') ] .concat(options.muteMaster ? ['--mute-master'] : []) .concat(options.lockRotation ? ['--lock-rotation'] : [])) @@ -142,6 +148,13 @@ program .option('--connect-port ' , 'port allocated to adb connect' , Number) + .option('--vnc-port ' + , 'port allocated to vnc' + , Number) + .option('--vnc-initial-size ' + , 'initial VNC size' + , cliutil.size + , [600, 800]) .option('--connect-url-pattern ' , 'adb connect URL pattern' , String @@ -193,6 +206,9 @@ program if (!options.connectPort) { this.missingArgument('--connect-port') } + if (!options.vncPort) { + this.missingArgument('--vnc-port') + } if (!options.storageUrl) { this.missingArgument('--storage-url') } @@ -213,6 +229,8 @@ program , screenPort: options.screenPort , connectUrlPattern: options.connectUrlPattern , connectPort: options.connectPort + , vncPort: options.vncPort + , vncInitialSize: options.vncInitialSize , heartbeatInterval: options.heartbeatInterval , muteMaster: options.muteMaster , lockRotation: options.lockRotation @@ -946,6 +964,10 @@ program .option('--user-profile-url ' , 'URL to external user profile page' , String) + .option('--vnc-initial-size ' + , 'initial VNC size' + , cliutil.size + , [600, 800]) .option('--mute-master' , 'whether to mute master volume when devices are being used') .option('--lock-rotation' @@ -1013,6 +1035,7 @@ program , util.format('http://localhost:%d/', options.poorxyPort) , '--adb-host', options.adbHost , '--adb-port', options.adbPort + , '--vnc-initial-size', options.vncInitialSize.join('x') ] .concat(options.allowRemote ? ['--allow-remote'] : []) .concat(options.muteMaster ? ['--mute-master'] : []) diff --git a/lib/units/device/index.js b/lib/units/device/index.js index 62ef8de8..08be946b 100644 --- a/lib/units/device/index.js +++ b/lib/units/device/index.js @@ -20,6 +20,7 @@ module.exports = function(options) { .dependency(require('./plugins/solo')) .dependency(require('./plugins/screen/stream')) .dependency(require('./plugins/screen/capture')) + .dependency(require('./plugins/vnc')) .dependency(require('./plugins/service')) .dependency(require('./plugins/browser')) .dependency(require('./plugins/store')) diff --git a/lib/units/device/plugins/screen/stream.js b/lib/units/device/plugins/screen/stream.js index f75069fa..f8a6f25d 100644 --- a/lib/units/device/plugins/screen/stream.js +++ b/lib/units/device/plugins/screen/stream.js @@ -25,7 +25,6 @@ module.exports = syrup.serial() .dependency(require('./options')) .define(function(options, adb, minicap, display, screenOptions) { var log = logger.createLogger('device:plugins:screen:stream') - var plugin = Object.create(null) function FrameProducer(config) { EventEmitter.call(this) @@ -443,9 +442,9 @@ module.exports = syrup.serial() return createServer() .then(function(wss) { - var broadcastSet = new BroadcastSet() var frameProducer = new FrameProducer( new FrameConfig(display.properties, display.properties)) + var broadcastSet = frameProducer.broadcastSet = new BroadcastSet() broadcastSet.on('nonempty', function() { frameProducer.start() @@ -455,37 +454,26 @@ module.exports = syrup.serial() frameProducer.stop() }) + broadcastSet.on('insert', function(id) { + // If two clients join a session in the middle, one of them + // may not release the initial size because the projection + // doesn't necessarily change, and the producer doesn't Getting + // restarted. Therefore we have to call onStart() manually + // if the producer is already up and running. + switch (frameProducer.runningState) { + case FrameProducer.STATE_STARTED: + broadcastSet.get(id).onStart(frameProducer) + break + } + }) + display.on('rotationChange', function(newRotation) { frameProducer.updateRotation(newRotation) }) frameProducer.on('start', function() { - var message = util.format( - 'start %s' - , JSON.stringify(frameProducer.banner) - ) - - broadcastSet.keys().forEach(function(id) { - var ws = broadcastSet.get(id) - switch (ws.readyState) { - case WebSocket.OPENING: - // This should never happen. - log.warn('Unable to send banner to OPENING client "%s"', id) - break - case WebSocket.OPEN: - // This is what SHOULD happen. - ws.send(message) - break - case WebSocket.CLOSING: - // Ok, a 'close' event should remove the client from the set - // soon. - break - case WebSocket.CLOSED: - // This should never happen. - log.warn('Unable to send banner to CLOSED client "%s"', id) - broadcastSet.remove(id) - break - } + broadcastSet.keys().map(function(id) { + return broadcastSet.get(id).onStart(frameProducer) }) }) @@ -493,32 +481,7 @@ module.exports = syrup.serial() var frame if ((frame = frameProducer.nextFrame())) { Promise.settle([broadcastSet.keys().map(function(id) { - return new Promise(function(resolve, reject) { - var ws = broadcastSet.get(id) - switch (ws.readyState) { - case WebSocket.OPENING: - // This should never happen. - return reject(new Error(util.format( - 'Unable to send frame to OPENING client "%s"', id))) - case WebSocket.OPEN: - // This is what SHOULD happen. - ws.send(frame, { - binary: true - }, function(err) { - return err ? reject(err) : resolve() - }) - return - case WebSocket.CLOSING: - // Ok, a 'close' event should remove the client from the set - // soon. - return - case WebSocket.CLOSED: - // This should never happen. - broadcastSet.remove(id) - return reject(new Error(util.format( - 'Unable to send frame to CLOSED client "%s"', id))) - } - }) + return broadcastSet.get(id).onFrame(frame) })]).then(next) } else { @@ -534,12 +497,74 @@ module.exports = syrup.serial() wss.on('connection', function(ws) { var id = uuid.v4() + function wsStartNotifier() { + return new Promise(function(resolve, reject) { + var message = util.format( + 'start %s' + , JSON.stringify(frameProducer.banner) + ) + + switch (ws.readyState) { + case WebSocket.OPENING: + // This should never happen. + log.warn('Unable to send banner to OPENING client "%s"', id) + break + case WebSocket.OPEN: + // This is what SHOULD happen. + ws.send(message, function(err) { + return err ? reject(err) : resolve() + }) + break + case WebSocket.CLOSING: + // Ok, a 'close' event should remove the client from the set + // soon. + break + case WebSocket.CLOSED: + // This should never happen. + log.warn('Unable to send banner to CLOSED client "%s"', id) + broadcastSet.remove(id) + break + } + }) + } + + function wsFrameNotifier(frame) { + return new Promise(function(resolve, reject) { + switch (ws.readyState) { + case WebSocket.OPENING: + // This should never happen. + return reject(new Error(util.format( + 'Unable to send frame to OPENING client "%s"', id))) + case WebSocket.OPEN: + // This is what SHOULD happen. + ws.send(frame, { + binary: true + }, function(err) { + return err ? reject(err) : resolve() + }) + return + case WebSocket.CLOSING: + // Ok, a 'close' event should remove the client from the set + // soon. + return + case WebSocket.CLOSED: + // This should never happen. + broadcastSet.remove(id) + return reject(new Error(util.format( + 'Unable to send frame to CLOSED client "%s"', id))) + } + }) + } + ws.on('message', function(data) { var match if ((match = /^(on|off|(size) ([0-9]+)x([0-9]+))$/.exec(data))) { switch (match[2] || match[1]) { case 'on': - broadcastSet.insert(id, ws) + broadcastSet.insert(id, { + onStart: wsStartNotifier + , onFrame: wsFrameNotifier + }) break case 'off': broadcastSet.remove(id) @@ -563,6 +588,7 @@ module.exports = syrup.serial() lifecycle.observe(function() { frameProducer.stop() }) + + return frameProducer }) - .return(plugin) }) diff --git a/lib/units/device/plugins/vnc/index.js b/lib/units/device/plugins/vnc/index.js new file mode 100644 index 00000000..0a896993 --- /dev/null +++ b/lib/units/device/plugins/vnc/index.js @@ -0,0 +1,201 @@ +var net = require('net') +var util = require('util') +var os = require('os') + +var syrup = require('stf-syrup') +var Promise = require('bluebird') +var uuid = require('node-uuid') +var jpeg = require('jpeg-turbo') + +var logger = require('../../../../util/logger') +var lifecycle = require('../../../../util/lifecycle') + +var VncServer = require('./util/server') +var VncConnection = require('./util/connection') +var PointerTranslator = require('./util/pointertranslator') + +module.exports = syrup.serial() + .dependency(require('../screen/stream')) + .dependency(require('../touch')) + .define(function(options, screenStream, touch) { + var log = logger.createLogger('device:plugins:vnc') + + function createServer() { + log.info('Starting VNC server on port %d', options.vncPort) + + var opts = { + name: options.serial + , width: options.vncInitialSize[0] + , height: options.vncInitialSize[1] + } + + var vnc = new VncServer(net.createServer({ + allowHalfOpen: true + }), opts) + + var listeningListener, errorListener + return new Promise(function(resolve, reject) { + listeningListener = function() { + return resolve(vnc) + } + + errorListener = function(err) { + return reject(err) + } + + vnc.on('listening', listeningListener) + vnc.on('error', errorListener) + + vnc.listen(options.vncPort) + }) + .finally(function() { + vnc.removeListener('listening', listeningListener) + vnc.removeListener('error', errorListener) + }) + } + + return createServer() + .then(function(vnc) { + vnc.on('connection', function(conn) { + var id = util.format('vnc-%s', uuid.v4()) + + var connState = { + lastFrame: null + , lastFrameTime: null + , frameWidth: 0 + , frameHeight: 0 + , sentFrameTime: null + , updateRequests: 0 + , frameConfig: { + format: jpeg.FORMAT_RGB + } + } + + var pointerTranslator = new PointerTranslator() + + pointerTranslator.on('touchdown', function(event) { + touch.touchDown(event) + }) + + pointerTranslator.on('touchmove', function(event) { + touch.touchMove(event) + }) + + pointerTranslator.on('touchup', function(event) { + touch.touchUp(event) + }) + + pointerTranslator.on('touchcommit', function() { + touch.touchCommit() + }) + + function vncStartListener(frameProducer) { + return new Promise(function(resolve/*, reject*/) { + connState.frameWidth = frameProducer.banner.virtualWidth + connState.frameHeight = frameProducer.banner.virtualHeight + resolve() + }) + } + + function vncFrameListener(frame) { + return new Promise(function(resolve/*, reject*/) { + connState.lastFrame = frame + connState.lastFrameTime = Date.now() + maybeSendFrame() + resolve() + }) + } + + function maybeSendFrame() { + if (!connState.updateRequests) { + return + } + + if (!connState.lastFrame) { + return + } + + if (connState.lastFrameTime === connState.sentFrameTime) { + return + } + + var decoded = jpeg.decompressSync( + connState.lastFrame, connState.frameConfig) + + conn.writeFramebufferUpdate([ + { xPosition: 0 + , yPosition: 0 + , width: decoded.width + , height: decoded.height + , encodingType: VncConnection.ENCODING_RAW + , data: decoded.data + } + , { xPosition: 0 + , yPosition: 0 + , width: decoded.width + , height: decoded.height + , encodingType: VncConnection.ENCODING_DESKTOPSIZE + } + ]) + + connState.updateRequests = 0 + connState.sentFrameTime = connState.lastFrameTime + } + + conn.on('authenticated', function() { + screenStream.updateProjection( + options.vncInitialSize[0], options.vncInitialSize[1]) + screenStream.broadcastSet.insert(id, { + onStart: vncStartListener + , onFrame: vncFrameListener + }) + }) + + conn.on('fbupdaterequest', function() { + connState.updateRequests += 1 + maybeSendFrame() + }) + + conn.on('formatchange', function(format) { + var same = os.endianness() == 'BE' == format.bigEndianFlag + switch (format.bitsPerPixel) { + case 8: + connState.frameConfig = { + format: jpeg.FORMAT_GRAY + } + break + case 24: + connState.frameConfig = { + format: ((format.redShift > format.blueShift) === same) + ? jpeg.FORMAT_BGR + : jpeg.FORMAT_RGB + } + break + case 32: + connState.frameConfig = { + format: ((format.redShift > format.blueShift) === same) + ? (format.blueShift === 0 + ? jpeg.FORMAT_BGRX + : jpeg.FORMAT_XBGR) + : (format.redShift === 0 + ? jpeg.FORMAT_RGBX + : jpeg.FORMAT_XRGB) + } + break + } + }) + + conn.on('pointer', function(event) { + pointerTranslator.push(event) + }) + + conn.on('close', function() { + screenStream.broadcastSet.remove(id) + }) + }) + + lifecycle.observe(function() { + vnc.close() + }) + }) + }) diff --git a/lib/units/device/plugins/vnc/util/connection.js b/lib/units/device/plugins/vnc/util/connection.js new file mode 100644 index 00000000..7d7868ae --- /dev/null +++ b/lib/units/device/plugins/vnc/util/connection.js @@ -0,0 +1,474 @@ +var util = require('util') +var os = require('os') + +var EventEmitter = require('eventemitter3').EventEmitter +var debug = require('debug')('vnc:connection') + +var PixelFormat = require('./pixelformat') + +function VncConnection(conn, options) { + this.options = options + + this._bound = { + _errorListener: this._errorListener.bind(this) + , _readableListener: this._readableListener.bind(this) + , _endListener: this._endListener.bind(this) + , _closeListener: this._closeListener.bind(this) + } + + this._buffer = null + this._state = 0 + this._changeState(VncConnection.STATE_NEED_CLIENT_VERSION) + + this._serverVersion = VncConnection.V3_008 + this._serverSupportedSecurity = [VncConnection.SECURITY_NONE] + this._serverWidth = this.options.width + this._serverHeight = this.options.height + this._serverPixelFormat = new PixelFormat({ + bitsPerPixel: 32 + , depth: 24 + , bigEndianFlag: os.endianness() == 'BE' ? 1 : 0 + , trueColorFlag: 1 + , redMax: 255 + , greenMax: 255 + , blueMax: 255 + , redShift: 16 + , greenShift: 8 + , blueShift: 0 + }) + this._serverName = this.options.name + + this._clientVersion = null + this._clientShare = false + this._clientPixelFormat = this._serverPixelFormat + this._clientEncodingCount = 0 + this._clientEncodings = [] + this._clientCutTextLength = 0 + + this.conn = conn + .on('error', this._bound._errorListener) + .on('readable', this._bound._readableListener) + .on('end', this._bound._endListener) + .on('close', this._bound._closeListener) + + this._writeServerVersion() + this._read() +} + +util.inherits(VncConnection, EventEmitter) + +VncConnection.V3_003 = 3003 +VncConnection.V3_007 = 3007 +VncConnection.V3_008 = 3008 + +VncConnection.SECURITY_NONE = 1 +VncConnection.SECURITY_VNC = 2 + +VncConnection.SECURITYRESULT_OK = 0 +VncConnection.SECURITYRESULT_FAIL = 1 + +VncConnection.CLIENT_MESSAGE_SETPIXELFORMAT = 0 +VncConnection.CLIENT_MESSAGE_SETENCODINGS = 2 +VncConnection.CLIENT_MESSAGE_FBUPDATEREQUEST = 3 +VncConnection.CLIENT_MESSAGE_KEYEVENT = 4 +VncConnection.CLIENT_MESSAGE_POINTEREVENT = 5 +VncConnection.CLIENT_MESSAGE_CLIENTCUTTEXT = 6 + +VncConnection.SERVER_MESSAGE_FBUPDATE = 0 + +var StateReverse = Object.create(null), State = { + STATE_NEED_CLIENT_VERSION: 10 +, STATE_NEED_CLIENT_SECURITY: 20 +, STATE_NEED_CLIENT_INIT: 30 +, STATE_NEED_CLIENT_MESSAGE: 40 +, STATE_NEED_CLIENT_MESSAGE_SETPIXELFORMAT: 50 +, STATE_NEED_CLIENT_MESSAGE_SETENCODINGS: 60 +, STATE_NEED_CLIENT_MESSAGE_SETENCODINGS_VALUE: 61 +, STATE_NEED_CLIENT_MESSAGE_FBUPDATEREQUEST: 70 +, STATE_NEED_CLIENT_MESSAGE_KEYEVENT: 80 +, STATE_NEED_CLIENT_MESSAGE_POINTEREVENT: 90 +, STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT: 100 +, STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT_VALUE: 101 +} + +VncConnection.ENCODING_RAW = 0 +VncConnection.ENCODING_DESKTOPSIZE = -223 + +Object.keys(State).map(function(name) { + VncConnection[name] = State[name] + StateReverse[State[name]] = name +}) + +VncConnection.prototype.end = function() { + this.conn.end() +} + +VncConnection.prototype.writeFramebufferUpdate = function(rectangles) { + var chunk = new Buffer(4) + chunk[0] = VncConnection.SERVER_MESSAGE_FBUPDATE + chunk[1] = 0 + chunk.writeUInt16BE(rectangles.length, 2) + this._write(chunk) + + rectangles.forEach(function(rect) { + var chunk = new Buffer(12) + chunk.writeUInt16BE(rect.xPosition, 0) + chunk.writeUInt16BE(rect.yPosition, 2) + chunk.writeUInt16BE(rect.width, 4) + chunk.writeUInt16BE(rect.height, 6) + chunk.writeInt32BE(rect.encodingType, 8) + this._write(chunk) + + switch (rect.encodingType) { + case VncConnection.ENCODING_RAW: + this._write(rect.data) + break + case VncConnection.ENCODING_DESKTOPSIZE: + this._serverWidth = rect.width + this._serverHeight = rect.height + break + default: + throw new Error(util.format( + 'Unsupported encoding type', rect.encodingType)) + } + }, this) +} + +VncConnection.prototype._error = function(err) { + this.emit('error', err) + this.end() +} + +VncConnection.prototype._errorListener = function(err) { + this._error(err) +} + +VncConnection.prototype._endListener = function() { + this.emit('end') +} + +VncConnection.prototype._closeListener = function() { + this.emit('close') +} + +VncConnection.prototype._writeServerVersion = function() { + // Yes, we could just format the string instead. Didn't feel like it. + switch (this._serverVersion) { + case VncConnection.V3_003: + this._write(new Buffer('RFB 003.003\n')) + break + case VncConnection.V3_007: + this._write(new Buffer('RFB 003.007\n')) + break + case VncConnection.V3_008: + this._write(new Buffer('RFB 003.008\n')) + break + } +} + +VncConnection.prototype._writeSupportedSecurity = function() { + var chunk = new Buffer(1 + this._serverSupportedSecurity.length) + + chunk[0] = this._serverSupportedSecurity.length + this._serverSupportedSecurity.forEach(function(security, i) { + chunk[1 + i] = security + }) + + this._write(chunk) +} + +VncConnection.prototype._writeSelectedSecurity = function() { + var chunk = new Buffer(4) + chunk.writeUInt32BE(VncConnection.SECURITY_NONE, 0) + this._write(chunk) +} + +VncConnection.prototype._writeSecurityResult = function(result, reason) { + var chunk + switch (result) { + case VncConnection.SECURITYRESULT_OK: + chunk = new Buffer(4) + chunk.writeUInt32BE(result, 0) + this._write(chunk) + break + case VncConnection.SECURITYRESULT_FAIL: + chunk = new Buffer(4 + 4 + reason.length) + chunk.writeUInt32BE(result, 0) + chunk.writeUInt32BE(reason.length, 4) + chunk.write(reason, 8, reason.length) + this._write(chunk) + break + } +} + +VncConnection.prototype._writeServerInit = function() { + debug('server pixel format', this._serverPixelFormat) + var chunk = new Buffer(2 + 2 + 16 + 4 + this._serverName.length) + chunk.writeUInt16BE(this._serverWidth, 0) + chunk.writeUInt16BE(this._serverHeight, 2) + chunk[4] = this._serverPixelFormat.bitsPerPixel + chunk[5] = this._serverPixelFormat.depth + chunk[6] = this._serverPixelFormat.bigEndianFlag + chunk[7] = this._serverPixelFormat.trueColorFlag + chunk.writeUInt16BE(this._serverPixelFormat.redMax, 8) + chunk.writeUInt16BE(this._serverPixelFormat.greenMax, 10) + chunk.writeUInt16BE(this._serverPixelFormat.blueMax, 12) + chunk[14] = this._serverPixelFormat.redShift + chunk[15] = this._serverPixelFormat.greenShift + chunk[16] = this._serverPixelFormat.blueShift + chunk[17] = 0 // padding + chunk[18] = 0 // padding + chunk[19] = 0 // padding + chunk.writeUInt32BE(this._serverName.length, 20) + chunk.write(this._serverName, 24, this._serverName.length) + this._write(chunk) +} + +VncConnection.prototype._readableListener = function() { + this._read() +} + +VncConnection.prototype._read = function() { + var chunk, lo, hi + while (this._append(this.conn.read())) { + do { + debug('state', StateReverse[this._state]) + chunk = null + switch (this._state) { + case VncConnection.STATE_NEED_CLIENT_VERSION: + if ((chunk = this._consume(12))) { + if ((this._clientVersion = this._parseVersion(chunk)) === null) { + this.end() + return + } + debug('client version', this._clientVersion) + this._writeSupportedSecurity() + this._changeState(VncConnection.STATE_NEED_CLIENT_SECURITY) + } + break + case VncConnection.STATE_NEED_CLIENT_SECURITY: + if ((chunk = this._consume(1))) { + if ((this._clientSecurity = this._parseSecurity(chunk)) === null) { + this._writeSecurityResult( + VncConnection.SECURITYRESULT_FAIL, 'Unsupported security type') + this.end() + return + } + debug('client security', this._clientSecurity) + this._writeSecurityResult(VncConnection.SECURITYRESULT_OK) + this.emit('authenticated') + this._changeState(VncConnection.STATE_NEED_CLIENT_INIT) + } + break + case VncConnection.STATE_NEED_CLIENT_INIT: + if ((chunk = this._consume(1))) { + this._clientShare = chunk[0] + debug('client shareFlag', this._clientShare) + this._writeServerInit() + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE: + if ((chunk = this._consume(1))) { + switch (chunk[0]) { + case VncConnection.CLIENT_MESSAGE_SETPIXELFORMAT: + this._changeState( + VncConnection.STATE_NEED_CLIENT_MESSAGE_SETPIXELFORMAT) + break + case VncConnection.CLIENT_MESSAGE_SETENCODINGS: + this._changeState( + VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS) + break + case VncConnection.CLIENT_MESSAGE_FBUPDATEREQUEST: + this._changeState( + VncConnection.STATE_NEED_CLIENT_MESSAGE_FBUPDATEREQUEST) + break + case VncConnection.CLIENT_MESSAGE_KEYEVENT: + this._changeState( + VncConnection.STATE_NEED_CLIENT_MESSAGE_KEYEVENT) + break + case VncConnection.CLIENT_MESSAGE_POINTEREVENT: + this._changeState( + VncConnection.STATE_NEED_CLIENT_MESSAGE_POINTEREVENT) + break + case VncConnection.CLIENT_MESSAGE_CLIENTCUTTEXT: + this._changeState( + VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT) + break + default: + this._error(new Error(util.format( + 'Unsupported message type %d', chunk[0]))) + return + } + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_SETPIXELFORMAT: + if ((chunk = this._consume(19))) { + // [0b, 3b) padding + this._clientPixelFormat = new PixelFormat({ + bitsPerPixel: chunk[3] + , depth: chunk[4] + , bigEndianFlag: chunk[5] + , trueColorFlag: chunk[6] + , redMax: chunk.readUInt16BE(7, true) + , greenMax: chunk.readUInt16BE(9, true) + , blueMax: chunk.readUInt16BE(11, true) + , redShift: chunk[13] + , greenShift: chunk[14] + , blueShift: chunk[15] + }) + // [16b, 19b) padding + debug('client pixel format', this._clientPixelFormat) + this.emit('formatchange', this._clientPixelFormat) + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS: + if ((chunk = this._consume(3))) { + // [0b, 1b) padding + this._clientEncodingCount = chunk.readUInt16BE(1, true) + this._changeState( + VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS_VALUE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS_VALUE: + lo = 0 + hi = 4 * this._clientEncodingCount + if ((chunk = this._consume(hi))) { + this._clientEncodings = [] + while (lo < hi) { + this._clientEncodings.push(chunk.readInt32BE(lo, true)) + lo += 4 + } + debug('client encodings', this._clientEncodings) + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_FBUPDATEREQUEST: + if ((chunk = this._consume(9))) { + this.emit('fbupdaterequest', { + incremental: chunk[0] + , xPosition: chunk.readUInt16BE(1, true) + , yPosition: chunk.readUInt16BE(3, true) + , width: chunk.readUInt16BE(5, true) + , height: chunk.readUInt16BE(7, true) + }) + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_KEYEVENT: + if ((chunk = this._consume(7))) { + // downFlag = chunk[0] + // [1b, 3b) padding + // key = chunk.readUInt32BE(3, true) + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_POINTEREVENT: + if ((chunk = this._consume(5))) { + this.emit('pointer', { + buttonMask: chunk[0] + , xPosition: chunk.readUInt16BE(1, true) / this._serverWidth + , yPosition: chunk.readUInt16BE(3, true) / this._serverHeight + }) + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT: + if ((chunk = this._consume(7))) { + // [0b, 3b) padding + this._clientCutTextLength = chunk.readUInt32BE(3) + this._changeState( + VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT_VALUE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT_VALUE: + if ((chunk = this._consume(this._clientCutTextLength))) { + // value = chunk + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + default: + throw new Error(util.format('Impossible state %d', this._state)) + } + } + while (chunk) + } +} + +VncConnection.prototype._parseVersion = function(chunk) { + if (chunk.equals(new Buffer('RFB 003.008\n'))) { + return VncConnection.V3_008 + } + + if (chunk.equals(new Buffer('RFB 003.007\n'))) { + return VncConnection.V3_007 + } + + if (chunk.equals(new Buffer('RFB 003.003\n'))) { + return VncConnection.V3_003 + } + + return null +} + +VncConnection.prototype._parseSecurity = function(chunk) { + switch (chunk[0]) { + case VncConnection.SECURITY_NONE: + case VncConnection.SECURITY_VNC: + return chunk[0] + default: + return null + } +} + +VncConnection.prototype._changeState = function(state) { + this._state = state +} + +VncConnection.prototype._append = function(chunk) { + if (!chunk) { + return false + } + + debug('in', chunk) + + if (this._buffer) { + this._buffer = Buffer.concat( + [this._buffer, chunk], this._buffer.length + chunk.length) + } + else { + this._buffer = chunk + } + + return true +} + +VncConnection.prototype._consume = function(n) { + var chunk + + if (!this._buffer) { + return null + } + + if (n < this._buffer.length) { + chunk = this._buffer.slice(0, n) + this._buffer = this._buffer.slice(n) + return chunk + } + + if (n === this._buffer.length) { + chunk = this._buffer + this._buffer = null + return chunk + } + + return null +} + +VncConnection.prototype._write = function(chunk) { + debug('out', chunk) + this.conn.write(chunk) +} + +module.exports = VncConnection diff --git a/lib/units/device/plugins/vnc/util/pixelformat.js b/lib/units/device/plugins/vnc/util/pixelformat.js new file mode 100644 index 00000000..9a1c4273 --- /dev/null +++ b/lib/units/device/plugins/vnc/util/pixelformat.js @@ -0,0 +1,14 @@ +function PixelFormat(values) { + this.bitsPerPixel = values.bitsPerPixel + this.depth = values.depth + this.bigEndianFlag = values.bigEndianFlag + this.trueColorFlag = values.trueColorFlag + this.redMax = values.redMax + this.greenMax = values.greenMax + this.blueMax = values.blueMax + this.redShift = values.redShift + this.greenShift = values.greenShift + this.blueShift = values.blueShift +} + +module.exports = PixelFormat diff --git a/lib/units/device/plugins/vnc/util/pointertranslator.js b/lib/units/device/plugins/vnc/util/pointertranslator.js new file mode 100644 index 00000000..8161efa8 --- /dev/null +++ b/lib/units/device/plugins/vnc/util/pointertranslator.js @@ -0,0 +1,66 @@ +var util = require('util') + +var EventEmitter = require('eventemitter3').EventEmitter + +function PointerTranslator() { + this.previousEvent = null +} + +util.inherits(PointerTranslator, EventEmitter) + +PointerTranslator.prototype.push = function(event) { + if (event.buttonMask & 0xFE) { + // Non-primary buttons included, ignore. + return + } + + if (this.previousEvent) { + var buttonChanges = event.buttonMask ^ this.previousEvent.buttonMask + + // If the primary button changed, we have an up/down event. + if (buttonChanges & 1) { + // If it's pressed now, that's a down event. + if (event.buttonMask & 1) { + this.emit('touchdown', { + contact: 1 + , x: event.xPosition + , y: event.yPosition + }) + this.emit('touchcommit') + } + // It's not pressed, so we have an up event. + else { + this.emit('touchup', { + contact: 1 + }) + this.emit('touchcommit') + } + } + // Otherwise, if we're still holding the primary button down, + // that's a move event. + else if (event.buttonMask & 1) { + this.emit('touchmove', { + contact: 1 + , x: event.xPosition + , y: event.yPosition + }) + this.emit('touchcommit') + } + } + else { + // If it's the first event we get and the primary button's pressed, + // it's a down event. + if (event.buttonMask & 1) { + this.emit('touchdown', { + contact: 1 + , x: event.xPosition + , y: event.yPosition + }) + this.emit('touchcommit') + } + } + + this.previousEvent = event +} + +module.exports = PointerTranslator diff --git a/lib/units/device/plugins/vnc/util/server.js b/lib/units/device/plugins/vnc/util/server.js new file mode 100644 index 00000000..5a8b2382 --- /dev/null +++ b/lib/units/device/plugins/vnc/util/server.js @@ -0,0 +1,52 @@ +var util = require('util') + +var EventEmitter = require('eventemitter3').EventEmitter +var debug = require('debug')('vnc:server') + +var VncConnection = require('./connection') + +function VncServer(server, options) { + this.options = options + + this._bound = { + _listeningListener: this._listeningListener.bind(this) + , _connectionListener: this._connectionListener.bind(this) + , _closeListener: this._closeListener.bind(this) + , _errorListener: this._errorListener.bind(this) + } + + this.server = server + .on('listening', this._bound._listeningListener) + .on('connection', this._bound._connectionListener) + .on('close', this._bound._closeListener) + .on('error', this._bound._errorListener) +} + +util.inherits(VncServer, EventEmitter) + +VncServer.prototype.close = function() { + this.server.close() +} + +VncServer.prototype.listen = function() { + this.server.listen.apply(this.server, arguments) +} + +VncServer.prototype._listeningListener = function() { + this.emit('listening') +} + +VncServer.prototype._connectionListener = function(conn) { + debug('connection', conn.remoteAddress, conn.remotePort) + this.emit('connection', new VncConnection(conn, this.options)) +} + +VncServer.prototype._closeListener = function() { + this.emit('close') +} + +VncServer.prototype._errorListener = function(err) { + this.emit('error', err) +} + +module.exports = VncServer diff --git a/lib/units/provider/index.js b/lib/units/provider/index.js index 981875a0..3d196b41 100644 --- a/lib/units/provider/index.js +++ b/lib/units/provider/index.js @@ -316,7 +316,7 @@ module.exports = function(options) { // Spawn a device worker function spawn() { - var allocatedPorts = ports.splice(0, 2) + var allocatedPorts = ports.splice(0, 4) , proc = options.fork(device, allocatedPorts) , resolver = Promise.defer() diff --git a/lib/util/cliutil.js b/lib/util/cliutil.js index 12260802..818aac01 100644 --- a/lib/util/cliutil.js +++ b/lib/util/cliutil.js @@ -2,6 +2,11 @@ module.exports.list = function(val) { return val.split(/\s*,\s*/g).filter(Boolean) } +module.exports.size = function(val) { + var match = /^(\d+)x(\d+)$/.exec(val) + return match ? [+match[1], +match[2]] : undefined +} + module.exports.allUnknownArgs = function(args) { return [].slice.call(args, 0, -1).filter(Boolean) } diff --git a/package.json b/package.json index afe9dd32..806b01a8 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "compression": "^1.5.2", "cookie-session": "^1.2.0", "csurf": "^1.7.0", + "debug": "^2.2.0", "eventemitter3": "^0.1.6", "express": "^4.13.3", "express-validator": "^2.17.1", @@ -49,6 +50,7 @@ "http-proxy": "^1.11.2", "in-publish": "^2.0.0", "jade": "^1.9.2", + "jpeg-turbo": "^0.3.0", "jws": "^3.1.0", "ldapjs": "git+https://github.com/mcavage/node-ldapjs.git#acc1ca8f4314fd9d67561feabc8ce4c235076a5e", "lodash": "^3.10.1",