1
0
Fork 0
mirror of https://github.com/openstf/stf synced 2025-10-05 10:39:25 +02:00

Integrate new minicap along with a moderate rewrite. What's currently missing is recovering from socket death.

This commit is contained in:
Simo Kinnunen 2015-04-15 18:55:46 +09:00
parent 6fe4f8ae1b
commit 95e9dd0b82
43 changed files with 1138 additions and 438 deletions

View file

@ -17,7 +17,8 @@ module.exports = function(options) {
log.info('Preparing device') log.info('Preparing device')
return syrup.serial() return syrup.serial()
.dependency(require('./plugins/solo')) .dependency(require('./plugins/solo'))
.dependency(require('./plugins/screen')) .dependency(require('./plugins/screen/stream'))
.dependency(require('./plugins/screen/capture'))
.dependency(require('./plugins/service')) .dependency(require('./plugins/service'))
.dependency(require('./plugins/display')) .dependency(require('./plugins/display'))
.dependency(require('./plugins/browser')) .dependency(require('./plugins/browser'))

View file

@ -1,25 +1,71 @@
var util = require('util')
var syrup = require('stf-syrup') var syrup = require('stf-syrup')
var EventEmitter = require('eventemitter3').EventEmitter
var logger = require('../../../util/logger') var logger = require('../../../util/logger')
var streamutil = require('../../../util/streamutil')
module.exports = syrup.serial() module.exports = syrup.serial()
.dependency(require('../support/adb'))
.dependency(require('../resources/minicap'))
.dependency(require('./service')) .dependency(require('./service'))
.dependency(require('./screen')) .dependency(require('./screen/options'))
.define(function(options, service, screen) { .define(function(options, adb, minicap, service, screenOptions) {
var log = logger.createLogger('device:plugins:display') var log = logger.createLogger('device:plugins:display')
function fetch() { function Display(id, properties) {
log.info('Fetching display info') this.id = id
return service.getDisplay(0) this.properties = properties
.catch(function() { }
log.info('Falling back to screen API')
return screen.info(0) util.inherits(Display, EventEmitter)
})
.then(function(display) { Display.prototype.updateRotation = function(newRotation) {
display.url = screen.publicUrl log.info('Rotation changed to %d', newRotation)
return display this.properties.rotation = newRotation
this.emit('rotationChange', newRotation)
}
function infoFromMinicap(id) {
return minicap.run(util.format('-d %d -i', id))
.then(streamutil.readAll)
.then(function(out) {
var match
if ((match = /^ERROR: (.*)$/.exec(out))) {
throw new Error(match[1])
}
try {
return JSON.parse(out)
}
catch (e) {
throw new Error(out.toString())
}
}) })
} }
return fetch() function infoFromService(id) {
return service.getDisplay(id)
}
function readInfo(id) {
log.info('Reading display info')
return infoFromService(id)
.catch(function() {
return infoFromMinicap(id)
})
.then(function(properties) {
properties.url = screenOptions.publicUrl
return new Display(id, properties)
})
}
return readInfo(0).then(function(display) {
service.on('rotationChange', function(data) {
display.updateRotation(data.rotation)
})
return display
})
}) })

View file

@ -13,7 +13,7 @@ module.exports = syrup.serial()
function solve() { function solve() {
log.info('Solving identity') log.info('Solving identity')
var identity = devutil.makeIdentity(options.serial, properties) var identity = devutil.makeIdentity(options.serial, properties)
identity.display = display identity.display = display.properties
identity.phone = phone identity.phone = phone
return identity return identity
} }

View file

@ -1,195 +0,0 @@
var util = require('util')
var path = require('path')
var http = require('http')
var Promise = require('bluebird')
var syrup = require('stf-syrup')
var httpProxy = require('http-proxy')
var adbkit = require('adbkit')
var _ = require('lodash')
var logger = require('../../../util/logger')
var lifecycle = require('../../../util/lifecycle')
var streamutil = require('../../../util/streamutil')
var wire = require('../../../wire')
var wireutil = require('../../../wire/util')
module.exports = syrup.serial()
.dependency(require('../support/adb'))
.dependency(require('../support/router'))
.dependency(require('../support/push'))
.dependency(require('../support/storage'))
.dependency(require('../resources/minicap'))
.define(function(options, adb, router, push, storage, minicap) {
var log = logger.createLogger('device:plugins:screen')
var plugin = Object.create(null)
plugin.devicePort = 9002
plugin.privatePort = options.ports.pop()
plugin.privateUrl = util.format(
'ws://127.0.0.1:%s'
, plugin.privatePort
)
plugin.publicPort = options.ports.pop()
plugin.publicUrl = _.template(options.screenWsUrlPattern)({
publicIp: options.publicIp
, publicPort: plugin.publicPort
, privatePort: plugin.privatePort
, serial: options.serial
})
function run(cmd) {
return adb.shell(options.serial, util.format(
'LD_LIBRARY_PATH=%s exec %s %s'
, path.dirname(minicap.lib)
, minicap.bin
, cmd
))
}
function startService() {
log.info('Launching screen service')
return run(util.format('-p %d', plugin.devicePort))
.timeout(10000)
.then(function(out) {
lifecycle.share('Screen shell', out)
streamutil.talk(log, 'Screen shell says: "%s"', out)
})
}
function forwardService() {
log.info('Opening WebSocket service on port %d', plugin.privatePort)
return adb.forward(
options.serial
, util.format('tcp:%d', plugin.privatePort)
, util.format('tcp:%d', plugin.devicePort)
)
.timeout(10000)
}
function startProxy() {
log.info('Starting WebSocket proxy on %s', plugin.publicUrl)
var resolver = Promise.defer()
function resolve() {
lifecycle.share('Proxy server', proxyServer, {
end: false
})
resolver.resolve()
}
function reject(err) {
resolver.reject(err)
}
function ignore() {
// No-op
}
var proxy = httpProxy.createProxyServer({
target: plugin.privateUrl
, ws: true
, xfwd: false
})
proxy.on('error', ignore)
var proxyServer = http.createServer()
proxyServer.on('listening', resolve)
proxyServer.on('error', reject)
proxyServer.on('request', function(req, res) {
proxy.web(req, res)
})
proxyServer.on('upgrade', function(req, socket, head) {
proxy.ws(req, socket, head)
})
proxyServer.listen(plugin.publicPort)
return resolver.promise.finally(function() {
proxyServer.removeListener('listening', resolve)
proxyServer.removeListener('error', reject)
})
}
plugin.capture = function() {
log.info('Capturing screenshot')
var file = util.format('/data/local/tmp/minicap_%d.jpg', Date.now())
return run(util.format('-s >%s', file))
.then(adbkit.util.readAll)
.then(function() {
return adb.stat(options.serial, file)
})
.then(function(stats) {
if (stats.size === 0) {
throw new Error('Empty screenshot; possibly secure screen?')
}
return adb.pull(options.serial, file)
.then(function(transfer) {
return storage.store('image', transfer, {
filename: util.format('%s.jpg', options.serial)
, contentType: 'image/jpeg'
, knownLength: stats.size
})
})
})
.finally(function() {
return adb.shell(options.serial, ['rm', '-f', file])
.then(adbkit.util.readAll)
})
}
router.on(wire.ScreenCaptureMessage, function(channel) {
var reply = wireutil.reply(options.serial)
plugin.capture()
.then(function(file) {
push.send([
channel
, reply.okay('success', file)
])
})
.catch(function(err) {
log.error('Screen capture failed', err.stack)
push.send([
channel
, reply.fail(err.message)
])
})
})
return startService()
.then(forwardService)
.then(function() {
if (!options.disableScreenPublicProxy) {
return startProxy()
}
})
.then(function() {
plugin.info = function(id) {
return run(util.format('-d %d -i', id))
.then(streamutil.readAll)
.then(function(out) {
var match
if ((match = /^ERROR: (.*)$/.exec(out))) {
throw new Error(match[1])
}
try {
return JSON.parse(out)
}
catch (e) {
throw new Error(out.toString())
}
})
}
})
.return(plugin)
})

View file

@ -0,0 +1,81 @@
var util = require('util')
var syrup = require('stf-syrup')
var adbkit = require('adbkit')
var logger = require('../../../../util/logger')
var wire = require('../../../../wire')
var wireutil = require('../../../../wire/util')
/*jshint maxlen:90*/
module.exports = syrup.serial()
.dependency(require('../../support/adb'))
.dependency(require('../../support/router'))
.dependency(require('../../support/push'))
.dependency(require('../../support/storage'))
.dependency(require('../../resources/minicap'))
.dependency(require('../display'))
.define(function(options, adb, router, push, storage, minicap, display) {
var log = logger.createLogger('device:plugins:screen:capture')
var plugin = Object.create(null)
function projectionFormat() {
return util.format(
'%dx%d@%dx%d/%d'
, display.width
, display.height
, display.width
, display.height
, display.rotation
)
}
plugin.capture = function() {
log.info('Capturing screenshot')
var file = util.format('/data/local/tmp/minicap_%d.jpg', Date.now())
return minicap.run(util.format('-P %s -s >%s', projectionFormat(), file))
.then(adbkit.util.readAll)
.then(function() {
return adb.stat(options.serial, file)
})
.then(function(stats) {
if (stats.size === 0) {
throw new Error('Empty screenshot; possibly secure screen?')
}
return adb.pull(options.serial, file)
.then(function(transfer) {
return storage.store('image', transfer, {
filename: util.format('%s.jpg', options.serial)
, contentType: 'image/jpeg'
, knownLength: stats.size
})
})
})
.finally(function() {
return adb.shell(options.serial, ['rm', '-f', file])
.then(adbkit.util.readAll)
})
}
router.on(wire.ScreenCaptureMessage, function(channel) {
var reply = wireutil.reply(options.serial)
plugin.capture()
.then(function(file) {
push.send([
channel
, reply.okay('success', file)
])
})
.catch(function(err) {
log.error('Screen capture failed', err.stack)
push.send([
channel
, reply.fail(err.message)
])
})
})
return plugin
})

View file

@ -0,0 +1,17 @@
var syrup = require('stf-syrup')
var _ = require('lodash')
module.exports = syrup.serial()
.define(function(options) {
var plugin = Object.create(null)
plugin.devicePort = 9002
plugin.publicPort = options.ports.pop()
plugin.publicUrl = _.template(options.screenWsUrlPattern)({
publicIp: options.publicIp
, publicPort: plugin.publicPort
, serial: options.serial
})
return plugin
})

View file

@ -0,0 +1,394 @@
var util = require('util')
var Promise = require('bluebird')
var syrup = require('stf-syrup')
var WebSocketServer = require('ws').Server
var uuid = require('node-uuid')
var EventEmitter = require('eventemitter3').EventEmitter
var split = require('split')
var adbkit = require('adbkit')
var logger = require('../../../../util/logger')
var lifecycle = require('../../../../util/lifecycle')
var bannerutil = require('./util/banner')
var FrameParser = require('./util/frameparser')
var FrameConfig = require('./util/frameconfig')
var BroadcastSet = require('./util/broadcastset')
var StateQueue = require('./util/statequeue')
module.exports = syrup.serial()
.dependency(require('../../support/adb'))
.dependency(require('../../resources/minicap'))
.dependency(require('../display'))
.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) {
this.actionQueue = []
this.runningState = FrameProducer.STATE_STOPPED
this.desiredState = new StateQueue()
this.output = null
this.socket = null
this.banner = null
this.frameConfig = config
}
util.inherits(FrameProducer, EventEmitter)
FrameProducer.STATE_STOPPED = 1
FrameProducer.STATE_STARTING = 2
FrameProducer.STATE_STARTED = 3
FrameProducer.STATE_STOPPING = 4
FrameProducer.prototype._ensureState = function() {
switch (this.runningState) {
case FrameProducer.STATE_STARTING:
case FrameProducer.STATE_STOPPING:
// Just wait.
break
case FrameProducer.STATE_STOPPED:
if (this.desiredState.next() === FrameProducer.STATE_STARTED) {
this.runningState = FrameProducer.STATE_STARTING
this._startService().bind(this)
.then(function(out) {
this.output = out
return this._readOutput(out)
})
.then(function() {
return this._connectService()
})
.then(function(socket) {
this.socket = socket
return this._readBanner(socket)
})
.then(function(banner) {
this.banner = banner
return this._readFrames()
})
.then(function() {
this.runningState = FrameProducer.STATE_STARTED
this.emit('start')
})
.catch(function(err) {
this.runningState = FrameProducer.STATE_STOPPED
this.emit('error', err)
})
.finally(function() {
this._ensureState()
})
}
break
case FrameProducer.STATE_STARTED:
if (this.desiredState.next() === FrameProducer.STATE_STOPPED) {
this.runningState = FrameProducer.STATE_STOPPING
this._disconnectService().bind(this)
.timeout(2000)
.then(function() {
return this._stopService().timeout(10000)
})
.then(function() {
this.runningState = FrameProducer.STATE_STOPPED
this.emit('stop')
})
.catch(function(err) {
// In practice we _should_ never get here due to _stopService()
// being quite aggressive. But if we do, well... assume it
// stopped anyway for now.
this.runningState = FrameProducer.STATE_STOPPED
this.emit('error', err)
this.emit('stop')
})
.finally(function() {
this.output = null
this.socket = null
this.banner = null
this._ensureState()
})
}
break
}
}
FrameProducer.prototype.start = function() {
log.info('Requesting frame producer to start')
this.desiredState.push(FrameProducer.STATE_STARTED)
this._ensureState()
}
FrameProducer.prototype.stop = function() {
log.info('Requesting frame producer to stop')
this.desiredState.push(FrameProducer.STATE_STOPPED)
this._ensureState()
}
FrameProducer.prototype.updateRotation = function(rotation) {
if (this.frameConfig.rotation === rotation) {
log.info('Keeping %d as current frame producer rotation', rotation)
return
}
log.info('Setting frame producer rotation to %d', rotation)
this.frameConfig.rotation = rotation
this._configChanged()
}
FrameProducer.prototype.updateProjection = function(width, height) {
if (this.frameConfig.virtualWidth === width &&
this.frameConfig.virtualHeight === height) {
log.info(
'Keeping %dx%d as current frame producer projection', width, height)
return
}
log.info('Setting frame producer projection to %dx%d', width, height)
this.frameConfig.virtualWidth = width
this.frameConfig.virtualHeight = height
this._configChanged()
}
FrameProducer.prototype._configChanged = function() {
switch (this.runningState) {
case FrameProducer.STATE_STARTED:
case FrameProducer.STATE_STARTING:
this.desiredState.push(FrameProducer.STATE_STOPPED)
this.desiredState.push(FrameProducer.STATE_STARTED)
this._ensureState()
break
}
}
FrameProducer.prototype._startService = function() {
log.info('Launching screen service')
return minicap.run(util.format('-P %s', this.frameConfig.toString()))
.timeout(10000)
}
FrameProducer.prototype._readOutput = function(out) {
out.pipe(split()).on('data', function(line) {
var trimmed = line.toString().trim()
if (trimmed === '') {
return
}
if (/ERROR/.test(line)) {
log.fatal('minicap error: "%s"', line)
return lifecycle.fatal()
}
log.info('minicap says: "%s"', line)
})
}
FrameProducer.prototype._connectService = function() {
function tryConnect(times, delay) {
return adb.openLocal(options.serial, 'localabstract:minicap')
.timeout(10000)
.then(function(out) {
return out
})
.catch(function(err) {
if (/closed/.test(err.message) && times > 1) {
return Promise.delay(delay)
.then(function() {
return tryConnect(--times, delay * 2)
})
}
return Promise.reject(err)
})
}
log.info('Connecting to minicap service')
return tryConnect(5, 100)
}
FrameProducer.prototype._disconnectService = function() {
log.info('Disconnecting from minicap service')
var socket = this.socket
var endListener
return new Promise(function(resolve/*, reject*/) {
socket.on('end', endListener = function() {
resolve(true)
})
socket.end()
})
.finally(function() {
socket.removeListener('end', endListener)
})
}
FrameProducer.prototype._stopService = function() {
log.info('Stopping minicap service')
var pid = this.banner ? this.banner.pid : -1
var output = this.output
function waitForEnd() {
var endListener
return new Promise(function(resolve/*, reject*/) {
output.on('end', endListener = function() {
resolve(true)
})
})
.finally(function() {
output.removeListener('end', endListener)
})
}
function kindKill() {
if (pid <= 0) {
return Promise.reject(new Error('Minicap service pid is unknown'))
}
log.info('Sending SIGTERM to minicap')
return Promise.all([
waitForEnd()
, adb.shell(options.serial, ['kill', pid])
.then(adbkit.util.readAll)
.timeout(2000)
.return(true)
])
}
function forceKill() {
if (pid <= 0) {
return Promise.reject(new Error('Minicap service pid is unknown'))
}
log.info('Sending SIGKILL to minicap')
return Promise.all([
waitForEnd()
, adb.shell(options.serial, ['kill', '-9', pid])
.then(adbkit.util.readAll)
.timeout(2000)
.return(true)
])
}
function forceEnd() {
log.info('Ending minicap I/O as a last resort')
output.end()
return Promise.resolve(true)
}
return kindKill()
.catch(Promise.TimeoutError, forceKill)
.catch(forceEnd)
}
FrameProducer.prototype._readBanner = function(out) {
log.info('Reading minicap banner')
return bannerutil.read(out).timeout(2000)
}
FrameProducer.prototype._readFrames = function() {
var parser = this.socket.pipe(new FrameParser())
var emit = this.emit.bind(this)
function tryRead() {
for (var frame; (frame = parser.read());) {
emit('frame', frame)
}
}
tryRead()
parser.on('readable', tryRead)
}
function createServer() {
log.info('Starting WebSocket server on port %d', screenOptions.publicPort)
var wss = new WebSocketServer({
port: screenOptions.publicPort
, perMessageDeflate: false
})
var listeningListener, errorListener
return new Promise(function(resolve, reject) {
listeningListener = function() {
return resolve(wss)
}
errorListener = function(err) {
return reject(err)
}
wss.on('listening', listeningListener)
wss.on('error', errorListener)
})
.finally(function() {
wss.removeListener('listening', listeningListener)
wss.removeListener('error', errorListener)
})
}
return createServer()
.then(function(wss) {
var broadcastSet = new BroadcastSet()
var frameProducer = new FrameProducer(
new FrameConfig(display.properties, display.properties))
broadcastSet.on('nonempty', function() {
frameProducer.start()
})
broadcastSet.on('empty', function() {
frameProducer.stop()
})
display.on('rotationChange', function(newRotation) {
frameProducer.updateRotation(newRotation)
})
frameProducer.on('frame', function(frame) {
broadcastSet.each(function(ws) {
ws.send(frame, {
binary: true
})
})
})
frameProducer.on('error', function(err) {
log.fatal('Frame producer had an error', err.stack)
lifecycle.fatal()
})
wss.on('connection', function(ws) {
var id = uuid.v4()
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)
break
case 'off':
broadcastSet.remove(id)
break
case 'size':
frameProducer.updateProjection(+match[3], +match[4])
break
}
}
})
ws.on('close', function() {
broadcastSet.remove(id)
})
})
lifecycle.observe(function() {
wss.close()
})
lifecycle.observe(function() {
frameProducer.stop()
})
})
.return(plugin)
})

View file

@ -0,0 +1,108 @@
var Promise = require('bluebird')
module.exports.read = function parseBanner(out) {
var tryRead
return new Promise(function(resolve, reject) {
var readBannerBytes = 0
var needBannerBytes = 2
var banner = out.banner = {
version: 0
, length: 0
, pid: 0
, realWidth: 0
, realHeight: 0
, virtualWidth: 0
, virtualHeight: 0
, orientation: 0
, quirks: 0
}
tryRead = function() {
for (var chunk; (chunk = out.read(needBannerBytes - readBannerBytes));) {
for (var cursor = 0, len = chunk.length; cursor < len;) {
if (readBannerBytes < needBannerBytes) {
switch (readBannerBytes) {
case 0:
// version
banner.version = chunk[cursor]
break
case 1:
// length
banner.length = needBannerBytes = chunk[cursor]
break
case 2:
case 3:
case 4:
case 5:
// pid
banner.pid +=
(chunk[cursor] << ((readBannerBytes - 2) * 8)) >>> 0
break
case 6:
case 7:
case 8:
case 9:
// real width
banner.realWidth +=
(chunk[cursor] << ((readBannerBytes - 6) * 8)) >>> 0
break
case 10:
case 11:
case 12:
case 13:
// real height
banner.realHeight +=
(chunk[cursor] << ((readBannerBytes - 10) * 8)) >>> 0
break
case 14:
case 15:
case 16:
case 17:
// virtual width
banner.virtualWidth +=
(chunk[cursor] << ((readBannerBytes - 14) * 8)) >>> 0
break
case 18:
case 19:
case 20:
case 21:
// virtual height
banner.virtualHeight +=
(chunk[cursor] << ((readBannerBytes - 18) * 8)) >>> 0
break
case 22:
// orientation
banner.orientation += chunk[cursor] * 90
break
case 23:
// quirks
banner.quirks = chunk[cursor]
break
}
cursor += 1
readBannerBytes += 1
if (readBannerBytes === needBannerBytes) {
return resolve(banner)
}
}
else {
reject(new Error(
'Supposedly impossible error parsing banner'
))
}
}
}
}
tryRead()
out.on('readable', tryRead)
})
.finally(function() {
out.removeListener('readable', tryRead)
})
}

View file

@ -0,0 +1,40 @@
var util = require('util')
var EventEmitter = require('eventemitter3').EventEmitter
function BroadcastSet() {
this.set = Object.create(null)
this.count = 0
}
util.inherits(BroadcastSet, EventEmitter)
BroadcastSet.prototype.insert = function(id, ws) {
if (!(id in this.set)) {
this.set[id] = ws
this.count += 1
this.emit('insert', id)
if (this.count === 1) {
this.emit('nonempty')
}
}
}
BroadcastSet.prototype.remove = function(id) {
if (id in this.set) {
delete this.set[id]
this.count -= 1
this.emit('remove', id)
if (this.count === 0) {
this.emit('empty')
}
}
}
BroadcastSet.prototype.each = function(fn) {
return Object.keys(this.set).forEach(function(id) {
return fn(this.set[id])
}, this)
}
module.exports = BroadcastSet

View file

@ -0,0 +1,22 @@
var util = require('util')
function FrameConfig(real, virtual) {
this.realWidth = real.width
this.realHeight = real.height
this.virtualWidth = virtual.width
this.virtualHeight = virtual.height
this.rotation = virtual.rotation
}
FrameConfig.prototype.toString = function() {
return util.format(
'%dx%d@%dx%d/%d'
, this.realWidth
, this.realHeight
, this.virtualWidth
, this.virtualHeight
, this.rotation
)
}
module.exports = FrameConfig

View file

@ -0,0 +1,56 @@
var stream = require('stream')
var util = require('util')
function FrameParser() {
this.readFrameBytes = 0
this.frameBodyLength = 0
this.frameBody = new Buffer(0)
stream.Transform.call(this)
}
util.inherits(FrameParser, stream.Transform)
FrameParser.prototype._transform = function(chunk, encoding, done) {
var cursor, len, bytesLeft
for (cursor = 0, len = chunk.length; cursor < len;) {
if (this.readFrameBytes < 4) {
this.frameBodyLength +=
(chunk[cursor] << (this.readFrameBytes * 8)) >>> 0
cursor += 1
this.readFrameBytes += 1
}
else {
bytesLeft = len - cursor
if (bytesLeft >= this.frameBodyLength) {
this.frameBody = Buffer.concat([
this.frameBody
, chunk.slice(cursor, cursor + this.frameBodyLength)
])
this.push(this.frameBody)
cursor += this.frameBodyLength
this.frameBodyLength = this.readFrameBytes = 0
this.frameBody = new Buffer(0)
}
else {
// @todo Consider/benchmark continuation frames to prevent
// potential Buffer thrashing.
this.frameBody = Buffer.concat([
this.frameBody
, chunk.slice(cursor, len)
])
this.frameBodyLength -= bytesLeft
this.readFrameBytes += bytesLeft
cursor = len
}
}
}
return done()
}
module.exports = FrameParser

View file

@ -0,0 +1,26 @@
function StateQueue() {
this.queue = []
}
StateQueue.prototype.next = function() {
return this.queue.shift()
}
StateQueue.prototype.push = function(state) {
var found = false
// Not super efficient, but this shouldn't be running all the time anyway.
for (var i = 0, l = this.queue.length; i < l; ++i) {
if (this.queue[i] === state) {
this.queue.splice(i + 1)
found = true
break
}
}
if (!found) {
this.queue.push(state)
}
}
module.exports = StateQueue

View file

@ -1,4 +1,5 @@
var util = require('util') var util = require('util')
var path = require('path')
var Promise = require('bluebird') var Promise = require('bluebird')
var syrup = require('stf-syrup') var syrup = require('stf-syrup')
@ -87,6 +88,14 @@ module.exports = syrup.serial()
return { return {
bin: resources.bin.dest bin: resources.bin.dest
, lib: resources.lib.dest , lib: resources.lib.dest
, run: function(cmd) {
return adb.shell(options.serial, util.format(
'LD_LIBRARY_PATH=%s exec %s %s'
, path.dirname(resources.lib.dest)
, resources.bin.dest
, cmd
))
}
} }
}) })
}) })

View file

@ -0,0 +1,63 @@
var rotator = require('./rotator')
var tests = [
[0, 0, +0]
, [0, 90, -90]
, [0, 180, -180]
, [0, 270, +90]
, [90, 0, +90]
, [90, 90, +0]
, [90, 180, -90]
, [90, 270, +180]
, [180, 0, +180]
, [180, 90, +90]
, [180, 180, +0]
, [180, 270, -90]
, [270, 0, -90]
, [270, 90, -180]
, [270, 180, +90]
, [270, 270, +0]
, [360, 0, +0]
, [360, 90, -90]
, [360, 180, -180]
, [360, 270, +90]
, [-90, 0, -90]
, [-90, 90, -180]
, [-90, 180, +90]
, [-90, 270, 0]
, [-180, 0, +180]
, [-180, 90, +90]
, [-180, 180, +0]
, [-180, 270, -90]
, [-270, 0, +90]
, [-270, 90, 0]
, [-270, 180, -90]
, [-270, 270, +180]
, [720, 0, +0]
, [720, 90, -90]
, [720, 180, -180]
, [720, 270, +90]
, [450, 0, +90]
, [450, 90, +0]
, [450, 180, -90]
, [450, 270, +180]
, [540, 0, +180]
, [540, 90, +90]
, [540, 180, +0]
, [540, 270, -90]
, [630, 0, -90]
, [630, 90, -180]
, [630, 180, +90]
, [630, 270, +0]
]
tests.forEach(function(values) {
var msg = values[0] + ' -> ' + values[1] + ' should be ' + values[2]
if (rotator(values[0], values[1]) === values[2]) {
console.log('pass: ' + msg)
}
else {
console.error('FAIL: ' + msg)
}
})

View file

@ -0,0 +1,33 @@
var mapping = {
0: {
0: 0
, 90: -90
, 180: -180
, 270: 90
}
, 90: {
0: 90
, 90: 0
, 180: -90
, 270: 180
}
, 180: {
0: 180
, 90: 90
, 180: 0
, 270: -90
}
, 270: {
0: -90
, 90: -180
, 180: 90
, 270: 0
}
}
module.exports = function rotator(oldRotation, newRotation) {
var r1 = oldRotation < 0 ? 360 + oldRotation % 360 : oldRotation % 360
, r2 = newRotation < 0 ? 360 + newRotation % 360 : newRotation % 360
return mapping[r1][r2]
}

View file

@ -1,4 +1,5 @@
var _ = require('lodash') var _ = require('lodash')
var rotator = require('./rotator')
module.exports = function DeviceScreenDirective( module.exports = function DeviceScreenDirective(
$document $document
@ -47,15 +48,42 @@ module.exports = function DeviceScreenDirective(
* This section should deal with updating the screen ONLY. * This section should deal with updating the screen ONLY.
*/ */
;(function() { ;(function() {
function stop() {
try {
ws.onerror = ws.onclose = ws.onmessage = ws.onopen = null
ws.close()
ws = null
}
catch (err) { /* noop */ }
}
var ws = new WebSocket(device.display.url)
ws.binaryType = 'blob'
var cleanupList = []
ws.onerror = function errorListener() {
// @todo Handle
}
ws.onclose = function closeListener() {
// @todo Maybe handle
}
ws.onopen = function openListener() {
var canvas = element.find('canvas')[0] var canvas = element.find('canvas')[0]
, g = canvas.getContext('2d') , g = canvas.getContext('2d')
var devicePixelRatio = window.devicePixelRatio || 1 function vendorBackingStorePixelRatio(g) {
, backingStoreRatio = g.webkitBackingStorePixelRatio || return g.webkitBackingStorePixelRatio ||
g.mozBackingStorePixelRatio || g.mozBackingStorePixelRatio ||
g.msBackingStorePixelRatio || g.msBackingStorePixelRatio ||
g.oBackingStorePixelRatio || g.oBackingStorePixelRatio ||
g.backingStorePixelRatio || 1 g.backingStorePixelRatio || 1
}
var devicePixelRatio = window.devicePixelRatio || 1
, backingStoreRatio = vendorBackingStorePixelRatio(g)
, frontBackRatio = devicePixelRatio / backingStoreRatio , frontBackRatio = devicePixelRatio / backingStoreRatio
var options = { var options = {
@ -64,8 +92,6 @@ module.exports = function DeviceScreenDirective(
, minscale: 0.36 , minscale: 0.36
} }
var updating = false
var cachedScreen = { var cachedScreen = {
rotation: 0 rotation: 0
, bounds: { , bounds: {
@ -76,46 +102,14 @@ module.exports = function DeviceScreenDirective(
} }
} }
var adjustedBoundSize
var cachedImageWidth = 0 var cachedImageWidth = 0
, cachedImageHeight = 0 , cachedImageHeight = 0
, cachedEnabled = false
, cssRotation = 0
function updateBounds() { function updateBounds() {
// TODO: element is an object HTMLUnknownElement in IE9
screen.bounds.w = element[0].offsetWidth
screen.bounds.h = element[0].offsetHeight
// Developer error, let's try to reduce debug time
if (!screen.bounds.w || !screen.bounds.h) {
throw new Error(
'Unable to update display size; container must have dimensions'
)
}
}
function maybeLoadScreen() {
var size
if (shouldUpdateScreen()) {
switch (screen.rotation) {
case 0:
case 180:
size = adjustBoundedSize(
screen.bounds.w
, screen.bounds.h
)
break
case 90:
case 270:
size = adjustBoundedSize(
screen.bounds.h
, screen.bounds.w
)
break
}
updating = true
ws.send('j ' + size.w + ' ' + size.h)
}
}
function adjustBoundedSize(w, h) { function adjustBoundedSize(w, h) {
var sw = w * options.density var sw = w * options.density
, sh = h * options.density , sh = h * options.density
@ -137,22 +131,82 @@ module.exports = function DeviceScreenDirective(
} }
} }
// FIXME: element is an object HTMLUnknownElement in IE9
var w = screen.bounds.w = element[0].offsetWidth
, h = screen.bounds.h = element[0].offsetHeight
// Developer error, let's try to reduce debug time
if (!w || !h) {
throw new Error(
'Unable to read bounds; container must have dimensions'
)
}
var newAdjustedBoundSize = (function() {
switch (screen.rotation) {
case 90:
case 270:
return adjustBoundedSize(h, w)
case 0:
case 180:
/* falls through */
default:
return adjustBoundedSize(w, h)
}
})()
if (!adjustedBoundSize
|| newAdjustedBoundSize.w !== adjustedBoundSize.w
|| newAdjustedBoundSize.h !== adjustedBoundSize.h) {
adjustedBoundSize = newAdjustedBoundSize
onScreenInterestAreaChanged()
}
}
function shouldUpdateScreen() { function shouldUpdateScreen() {
return ( return (
// NO if we're updating already.
!updating &&
// NO if the user has disabled the screen. // NO if the user has disabled the screen.
scope.$parent.showScreen && scope.$parent.showScreen &&
// NO if we're not even using the device anymore. // NO if we're not even using the device anymore.
device.using && device.using &&
// NO if the page invisible to the user? // NO if the page is not visible (e.g. background tab).
!PageVisibilityService.hidden && !PageVisibilityService.hidden
// NO if we don't have a connection yet.
ws.readyState === WebSocket.OPEN
// YES otherwise // YES otherwise
) )
} }
function checkEnabled() {
var newEnabled = shouldUpdateScreen()
if (newEnabled === cachedEnabled) {
updateBounds()
}
else if (newEnabled) {
updateBounds()
onScreenInterestGained()
}
else {
g.clearRect(0, 0, canvas.width, canvas.height)
onScreenInterestLost()
}
cachedEnabled = newEnabled
}
function onScreenInterestGained() {
ws.send('size ' + adjustedBoundSize.w + 'x' + adjustedBoundSize.h)
ws.send('on')
}
function onScreenInterestAreaChanged() {
ws.send('size ' + adjustedBoundSize.w + 'x' + adjustedBoundSize.h)
}
function onScreenInterestLost() {
ws.send('off')
}
ws.onmessage = function messageListener(message) {
function hasImageAreaChanged(img) { function hasImageAreaChanged(img) {
return cachedScreen.bounds.w !== screen.bounds.w || return cachedScreen.bounds.w !== screen.bounds.w ||
cachedScreen.bounds.h !== screen.bounds.h || cachedScreen.bounds.h !== screen.bounds.h ||
@ -166,14 +220,9 @@ module.exports = function DeviceScreenDirective(
return return
} }
cachedScreen.bounds.w = screen.bounds.w
cachedScreen.bounds.h = screen.bounds.h
cachedImageWidth = img.width cachedImageWidth = img.width
cachedImageHeight = img.height cachedImageHeight = img.height
cachedScreen.rotation = screen.rotation
if (options.autoScaleForRetina) { if (options.autoScaleForRetina) {
canvas.width = cachedImageWidth * frontBackRatio canvas.width = cachedImageWidth * frontBackRatio
canvas.height = cachedImageHeight * frontBackRatio canvas.height = cachedImageHeight * frontBackRatio
@ -190,65 +239,16 @@ module.exports = function DeviceScreenDirective(
, screen.rotation , screen.rotation
) )
cssRotation += rotator(cachedScreen.rotation, screen.rotation)
canvas.style.width = projectedSize.width + 'px' canvas.style.width = projectedSize.width + 'px'
canvas.style.height = projectedSize.height + 'px' canvas.style.height = projectedSize.height + 'px'
canvas.style[cssTransform] = 'rotate(' + cssRotation + 'deg)'
// @todo Make sure that each position is able to rotate smoothly cachedScreen.bounds.h = screen.bounds.h
// to the next one. This current setup doesn't work if rotation cachedScreen.bounds.w = screen.bounds.w
// changes from 180 to 270 (it will do a reverse rotation). cachedScreen.rotation = screen.rotation
switch (screen.rotation) {
case 0:
canvas.style[cssTransform] = 'rotate(0deg)'
break
case 90:
canvas.style[cssTransform] = 'rotate(-90deg)'
break
case 180:
canvas.style[cssTransform] = 'rotate(-180deg)'
break
case 270:
canvas.style[cssTransform] = 'rotate(90deg)'
break
} }
}
function checkEnabled() {
if (shouldUpdateScreen()) {
updating = false
updateBounds()
maybeLoadScreen()
}
else {
g.clearRect(0, 0, canvas.width, canvas.height)
}
}
function stop() {
try {
ws.onerror = ws.onclose = ws.onmessage = ws.onopen = null
ws.close()
ws = null
}
catch (err) { /* noop */ }
}
var ws = new WebSocket(device.display.url)
ws.binaryType = 'blob'
ws.onerror = function errorListener() {
// @todo Handle
}
ws.onclose = function closeListener() {
// @todo Maybe handle
}
ws.onopen = function openListener() {
checkEnabled()
}
ws.onmessage = function messageListener(message) {
updating = false
if (shouldUpdateScreen()) { if (shouldUpdateScreen()) {
screen.rotation = device.display.rotation screen.rotation = device.display.rotation
@ -272,7 +272,10 @@ module.exports = function DeviceScreenDirective(
g.drawImage(img, 0, 0) g.drawImage(img, 0, 0)
// Try to forcefully clean everything to get rid of memory // Try to forcefully clean everything to get rid of memory
// leaks. // leaks. Note that despite this effort, Chrome will still
// leak huge amounts of memory when the developer tools are
// open, probably to save the resources for inspection. When
// the developer tools are closed no memory is leaked.
img.onload = img.onerror = null img.onload = img.onerror = null
img.src = BLANK_IMG img.src = BLANK_IMG
img = null img = null
@ -298,26 +301,22 @@ module.exports = function DeviceScreenDirective(
break break
} }
} }
// Next please
maybeLoadScreen()
} }
} }
// NOTE: instead of fa-pane-resize, a fa-child-pane-resize could be better // NOTE: instead of fa-pane-resize, a fa-child-pane-resize could be better
scope.$on('fa-pane-resize', _.throttle(updateBounds, 16)) cleanupList.push(scope.$on('fa-pane-resize', _.throttle(updateBounds, 1000)))
cleanupList.push(scope.$watch('device.using', checkEnabled))
cleanupList.push(scope.$on('visibilitychange', checkEnabled))
cleanupList.push(scope.$watch('$parent.showScreen', checkEnabled))
}
scope.retryLoadingScreen = function () { scope.retryLoadingScreen = function () {
if (scope.displayError === 'secure') { if (scope.displayError === 'secure') {
control.home() control.home()
} }
$timeout(maybeLoadScreen, 3000)
} }
scope.$watch('device.using', checkEnabled)
scope.$on('visibilitychange', checkEnabled)
scope.$watch('$parent.showScreen', checkEnabled)
scope.$on('guest-portrait', function () { scope.$on('guest-portrait', function () {
control.rotate(0) control.rotate(0)
}) })

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.