diff --git a/lib/units/device/plugins/touch/index.js b/lib/units/device/plugins/touch/index.js deleted file mode 100644 index e3256c771b..0000000000 --- a/lib/units/device/plugins/touch/index.js +++ /dev/null @@ -1,473 +0,0 @@ -import util from 'util' -import Promise from 'bluebird' -import syrup from '@devicefarmer/stf-syrup' -import split from 'split' -import EventEmitter from 'eventemitter3' -import {Parser, Adb} from '@u4/adbkit' -import wire from '../../../../wire/index.js' -import logger from '../../../../util/logger.js' -import lifecycle from '../../../../util/lifecycle.js' -import SeqQueue from '../../../../wire/seqqueue.js' -import StateQueue from '../../../../util/statequeue.js' -import RiskyStream from '../../../../util/riskystream.js' -import FailCounter from '../../../../util/failcounter.js' -import adb from '../../support/adb.js' -import router from '../../../base-device/support/router.js' -import minitouch from '../../resources/minitouch.js' -import flags from '../util/flags.js' -import {GestureStartMessage, GestureStopMessage, TouchCommitMessage, TouchDownMessage, TouchMoveMessage, TouchResetMessage, TouchUpMessage} from '../../../../wire/wire.js' -export default syrup.serial() - .dependency(adb) - .dependency(router) - .dependency(minitouch) - .dependency(flags) - .define(function(options, adb, router, minitouch, flags) { - var log = logger.createLogger('device:plugins:touch') - function TouchConsumer(config) { - EventEmitter.call(this) - this.actionQueue = [] - this.runningState = TouchConsumer.STATE_STOPPED - this.desiredState = new StateQueue() - this.output = null - this.socket = null - this.banner = null - this.touchConfig = config - this.starter = Promise.resolve(true) - this.failCounter = new FailCounter(3, 10000) - this.failCounter.on('exceedLimit', this._failLimitExceeded.bind(this)) - this.failed = false - this.readableListener = this._readableListener.bind(this) - this.writeQueue = [] - } - util.inherits(TouchConsumer, EventEmitter) - TouchConsumer.STATE_STOPPED = 1 - TouchConsumer.STATE_STARTING = 2 - TouchConsumer.STATE_STARTED = 3 - TouchConsumer.STATE_STOPPING = 4 - TouchConsumer.prototype._queueWrite = function(writer) { - switch (this.runningState) { - case TouchConsumer.STATE_STARTED: - writer.call(this) - break - default: - this.writeQueue.push(writer) - break - } - } - TouchConsumer.prototype.touchDown = function(point) { - this._queueWrite(function() { - return this._write(util.format('d %s %s %s %s\n', point.contact, Math.ceil(this.touchConfig.origin.x(point) * this.banner.maxX), Math.ceil(this.touchConfig.origin.y(point) * this.banner.maxY), Math.ceil((point.pressure || 0.5) * this.banner.maxPressure))) - }) - } - TouchConsumer.prototype.touchMove = function(point) { - this._queueWrite(function() { - return this._write(util.format('m %s %s %s %s\n', point.contact, Math.ceil(this.touchConfig.origin.x(point) * this.banner.maxX), Math.ceil(this.touchConfig.origin.y(point) * this.banner.maxY), Math.ceil((point.pressure || 0.5) * this.banner.maxPressure))) - }) - } - TouchConsumer.prototype.touchUp = function(point) { - this._queueWrite(function() { - return this._write(util.format('u %s\n', point.contact)) - }) - } - TouchConsumer.prototype.touchCommit = function() { - this._queueWrite(function() { - return this._write('c\n') - }) - } - TouchConsumer.prototype.touchReset = function() { - this._queueWrite(function() { - return this._write('r\n') - }) - } - TouchConsumer.prototype.tap = function(point) { - this.touchDown(point) - this.touchCommit() - this.touchUp(point) - this.touchCommit() - } - TouchConsumer.prototype._ensureState = function() { - if (this.desiredState.empty()) { - return - } - if (this.failed) { - log.warn('Will not apply desired state due to too many failures') - return - } - switch (this.runningState) { - case TouchConsumer.STATE_STARTING: - case TouchConsumer.STATE_STOPPING: - // Just wait. - break - case TouchConsumer.STATE_STOPPED: - if (this.desiredState.next() === TouchConsumer.STATE_STARTED) { - this.runningState = TouchConsumer.STATE_STARTING - this.starter = this._startService() - .then((out) => { - this.output = new RiskyStream(out) - .on('unexpectedEnd', this._outputEnded.bind(this)) - return this._readOutput(this.output.stream) - }) - .then(() => { - return this._connectService() - }) - .then((socket) => { - this.socket = new RiskyStream(socket) - .on('unexpectedEnd', this._socketEnded.bind(this)) - return this._readBanner(this.socket.stream) - }) - .then((banner) => { - this.banner = banner - return this._readUnexpected(this.socket.stream) - }) - .then(() => { - this._processWriteQueue() - }) - .then(() => { - this.runningState = TouchConsumer.STATE_STARTED - this.emit('start') - }) - .catch(Promise.CancellationError, () => { - return this._stop() - }) - .catch((err) => { - return this._stop().finally(() => { - this.failCounter.inc() - this.emit('error', err) - }) - }) - .finally(() => { - this._ensureState() - }) - } - else { - setImmediate(this._ensureState.bind(this)) - } - break - case TouchConsumer.STATE_STARTED: - if (this.desiredState.next() === TouchConsumer.STATE_STOPPED) { - this.runningState = TouchConsumer.STATE_STOPPING - this._stop().finally(function() { - this._ensureState() - }) - } - else { - setImmediate(this._ensureState.bind(this)) - } - break - } - } - TouchConsumer.prototype.start = function() { - this.desiredState.push(TouchConsumer.STATE_STARTED) - this._ensureState() - } - TouchConsumer.prototype.stop = function() { - this.desiredState.push(TouchConsumer.STATE_STOPPED) - this._ensureState() - } - TouchConsumer.prototype.restart = function() { - switch (this.runningState) { - case TouchConsumer.STATE_STARTED: - case TouchConsumer.STATE_STARTING: - this._stop() - this.desiredState.push(TouchConsumer.STATE_STOPPED) - this.desiredState.push(TouchConsumer.STATE_STARTED) - this._ensureState() - break - } - } - TouchConsumer.prototype._configChanged = function() { - this.restart() - } - TouchConsumer.prototype._socketEnded = function() { - this.failCounter.inc() - this.restart() - } - TouchConsumer.prototype._outputEnded = function() { - this.failCounter.inc() - this.restart() - } - TouchConsumer.prototype._failLimitExceeded = function(limit, time) { - this._stop() - this.failed = true - this.emit('error', new Error(util.format('Failed more than %d times in %dms', limit, time))) - } - TouchConsumer.prototype._startService = async function() { - return await minitouch.run() - } - TouchConsumer.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('minitouch error: "%s"', line) - return lifecycle.fatal() - } - log.info('minitouch says: "%s"', line) - }) - } - TouchConsumer.prototype._connectService = function() { - function tryConnect(times, delay) { - return adb.getDevice(options.serial).openLocal('localabstract:minitouch') - .then(function(out) { - return out - }) - .catch(function(err) { - if (/closed/.test(err.message) && times > 1) { - return Promise.delay(delay) - .then(function() { - return tryConnect(times - 1, delay * 2) - }) - } - return Promise.reject(err) - }) - } - log.info('Connecting to minitouch service') - // SH-03G can be very slow to start sometimes. Make sure we try long - // enough. - return tryConnect(7, 100) - } - TouchConsumer.prototype._stop = function() { - return this._disconnectService(this.socket).bind(this) - .timeout(2000) - .then(function() { - return this._stopService(this.output).timeout(10000) - }) - .then(function() { - this.runningState = TouchConsumer.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 = TouchConsumer.STATE_STOPPED - this.emit('error', err) - this.emit('stop') - }) - .finally(function() { - this.output = null - this.socket = null - this.banner = null - }) - } - TouchConsumer.prototype._disconnectService = function(socket) { - log.info('Disconnecting from minitouch service') - if (!socket || socket.ended) { - return Promise.resolve(true) - } - socket.stream.removeListener('readable', this.readableListener) - var endListener - return new Promise(function(resolve) { - socket.on('end', endListener = function() { - resolve(true) - }) - socket.stream.resume() - socket.end() - }) - .finally(function() { - socket.removeListener('end', endListener) - }) - } - TouchConsumer.prototype._stopService = function(output) { - log.info('Stopping minitouch service') - if (!output || output.ended) { - return Promise.resolve(true) - } - var pid = this.banner ? this.banner.pid : -1 - function kill(signal) { - if (pid <= 0) { - return Promise.reject(new Error('Minitouch service pid is unknown')) - } - var signum = { - SIGTERM: -15, - SIGKILL: -9 - }[signal] - log.info('Sending %s to minitouch', signal) - return Promise.all([ - output.waitForEnd(), - adb.getDevice(options.serial).shell(['kill', signum, pid]) - .then(Adb.util.readAll) - .return(true) - ]) - .timeout(2000) - } - function kindKill() { - return kill('SIGTERM') - } - function forceKill() { - return kill('SIGKILL') - } - function forceEnd() { - log.info('Ending minitouch I/O as a last resort') - output.end() - return Promise.resolve(true) - } - return kindKill() - .catch(Promise.TimeoutError, forceKill) - .catch(forceEnd) - } - TouchConsumer.prototype._readBanner = async function(socket) { - log.info('Reading minitouch banner') - var parser = new Parser(socket) - var banner = { - pid: -1, // @todo - version: 0, - maxContacts: 0, - maxX: 0, - maxY: 0, - maxPressure: 0 - } - function readVersion() { - return parser.readLine() - .then(function(chunk) { - var args = chunk.toString().split(/ /g) - switch (args[0]) { - case 'v': - banner.version = Number(args[1]) - break - default: - throw new Error(util.format('Unexpected output "%s", expecting version line', chunk)) - } - }) - } - function readLimits() { - return parser.readLine() - .then(function(chunk) { - var args = chunk.toString().split(/ /g) - switch (args[0]) { - case '^': - banner.maxContacts = args[1] - banner.maxX = args[2] - banner.maxY = args[3] - banner.maxPressure = args[4] - break - default: - throw new Error(util.format('Unknown output "%s", expecting limits line', chunk)) - } - }) - } - function readPid() { - return parser.readLine() - .then(function(chunk) { - var args = chunk.toString().split(/ /g) - switch (args[0]) { - case '$': - banner.pid = Number(args[1]) - break - default: - throw new Error(util.format('Unexpected output "%s", expecting pid line', chunk)) - } - }) - } - await readVersion() - await readLimits() - await readPid() - return banner - } - TouchConsumer.prototype._readUnexpected = function(socket) { - socket.on('readable', this.readableListener) - // We may already have data pending. - this.readableListener() - } - TouchConsumer.prototype._readableListener = function() { - var chunk - while ((chunk = this.socket.stream.read())) { - log.warn('Unexpected output from minitouch socket', chunk) - } - } - TouchConsumer.prototype._processWriteQueue = function() { - for (var i = 0, l = this.writeQueue.length; i < l; ++i) { - this.writeQueue[i].call(this) - } - this.writeQueue = [] - } - TouchConsumer.prototype._write = function(chunk) { - this.socket.stream.write(chunk) - } - function startConsumer() { - var touchConsumer = new TouchConsumer({ - // Usually the touch origin is the same as the display's origin, - // but sometimes it might not be. - origin: (function(origin) { - log.info('Touch origin is %s', origin) - return { - 'top left': { - x: function(point) { - return point.x - }, - y: function(point) { - return point.y - } - }, // So far the only device we've seen exhibiting this behavior - // is Yoga Tablet 8. - - 'bottom left': { - x: function(point) { - return 1 - point.y - }, - y: function(point) { - return point.x - } - } - }[origin] - })(flags.get('forceTouchOrigin', 'top left')) - }) - var startListener, errorListener - return new Promise(function(resolve, reject) { - touchConsumer.on('start', startListener = function() { - resolve(touchConsumer) - }) - touchConsumer.on('error', errorListener = reject) - touchConsumer.start() - }) - .finally(function() { - touchConsumer.removeListener('start', startListener) - touchConsumer.removeListener('error', errorListener) - }) - } - return startConsumer() - .then(function(touchConsumer) { - var queue = new SeqQueue(100, 4) - touchConsumer.on('error', function(err) { - log.fatal('Touch consumer had an error', err.stack) - lifecycle.fatal() - }) - router - .on(GestureStartMessage, function(channel, message) { - queue.start(message.seq) - }) - .on(GestureStopMessage, function(channel, message) { - queue.push(message.seq, function() { - queue.stop() - }) - }) - .on(TouchDownMessage, function(channel, message) { - queue.push(message.seq, function() { - touchConsumer.touchDown(message) - }) - }) - .on(TouchMoveMessage, function(channel, message) { - queue.push(message.seq, function() { - touchConsumer.touchMove(message) - }) - }) - .on(TouchUpMessage, function(channel, message) { - queue.push(message.seq, function() { - touchConsumer.touchUp(message) - }) - }) - .on(TouchCommitMessage, function(channel, message) { - queue.push(message.seq, function() { - touchConsumer.touchCommit() - }) - }) - .on(TouchResetMessage, function(channel, message) { - queue.push(message.seq, function() { - touchConsumer.touchReset() - }) - }) - return touchConsumer - }) - }) diff --git a/lib/units/device/plugins/touch/index.ts b/lib/units/device/plugins/touch/index.ts new file mode 100644 index 0000000000..13e961efa3 --- /dev/null +++ b/lib/units/device/plugins/touch/index.ts @@ -0,0 +1,642 @@ +import util from 'util' +import syrup from '@devicefarmer/stf-syrup' +import type {Client} from '@u4/adbkit' +import split from 'split' +import EventEmitter from 'events' +import {Parser, Adb} from '@u4/adbkit' +import logger from '../../../../util/logger.js' +import lifecycle from '../../../../util/lifecycle.js' +import SeqQueue from '../../../../wire/seqqueue.js' +import StateQueue from '../../../../util/statequeue.js' +import RiskyStream from '../../../../util/riskystream.js' +import FailCounter from '../../../../util/failcounter.js' +import adb from '../../support/adb.js' +import router from '../../../base-device/support/router.js' +import minitouch from '../../resources/minitouch.js' +import flags from '../util/flags.js' +import { + GestureStartMessage, + GestureStopMessage, + TouchCommitMessage, + TouchDownMessage, + TouchMoveMessage, + TouchResetMessage, + TouchUpMessage +} from '../../../../wire/wire.js' + +interface TouchPoint { + contact: number + x: number + y: number + pressure?: number +} + +interface TouchOrigin { + x: (point: TouchPoint) => number + y: (point: TouchPoint) => number +} + +interface TouchConfig { + origin: TouchOrigin +} + +interface Banner { + pid: number + version: number + maxContacts: number + maxX: number + maxY: number + maxPressure: number +} + +interface MinitouchService { + bin: string + run: (cmd?: string) => Promise +} + +interface TouchOptions { + serial: string +} + +const log = logger.createLogger('device:plugins:touch') + +const STATE_STOPPED = 1 +const STATE_STARTING = 2 +const STATE_STARTED = 3 +const STATE_STOPPING = 4 + +type TouchState = typeof STATE_STOPPED | typeof STATE_STARTING | typeof STATE_STARTED | typeof STATE_STOPPING + +class TouchConsumer extends EventEmitter { + private actionQueue: any[] = [] + private runningState: TouchState = STATE_STOPPED + private desiredState: StateQueue + private output: RiskyStream | null = null + private socket: RiskyStream | null = null + private banner: Banner | null = null + private touchConfig: TouchConfig + private starter: Promise = Promise.resolve(true) + private failCounter: FailCounter + private failed: boolean = false + private readableListener: () => void + private writeQueue: Array<() => void> = [] + private options: TouchOptions + private adb: Client + private minitouch: MinitouchService + private ensureStateLock: boolean = false + private splitStream: any = null + + constructor(config: TouchConfig, options: TouchOptions, adb: Client, minitouch: MinitouchService) { + super() + this.options = options + this.adb = adb + this.minitouch = minitouch + this.desiredState = new StateQueue() + this.touchConfig = config + this.failCounter = new FailCounter(3, 10000) + this.failCounter.on('exceedLimit', this._failLimitExceeded.bind(this)) + this.readableListener = this._readableListener.bind(this) + } + + private _queueWrite(writer: () => void): void { + switch (this.runningState) { + case STATE_STARTED: + writer.call(this) + break + default: + this.writeQueue.push(writer) + break + } + } + + touchDown(point: TouchPoint): void { + this._queueWrite(() => { + const x = Math.ceil(this.touchConfig.origin.x(point) * this.banner!.maxX) + const y = Math.ceil(this.touchConfig.origin.y(point) * this.banner!.maxY) + const p = Math.ceil((point.pressure || 0.5) * this.banner!.maxPressure) + return this._write(`d ${point.contact} ${x} ${y} ${p}\n`) + }) + } + + touchMove(point: TouchPoint): void { + this._queueWrite(() => { + const x = Math.ceil(this.touchConfig.origin.x(point) * this.banner!.maxX) + const y = Math.ceil(this.touchConfig.origin.y(point) * this.banner!.maxY) + const p = Math.ceil((point.pressure || 0.5) * this.banner!.maxPressure) + return this._write(`m ${point.contact} ${x} ${y} ${p}\n`) + }) + } + + touchUp(point: TouchPoint): void { + this._queueWrite(() => { + return this._write(`u ${point.contact}\n`) + }) + } + + touchCommit(): void { + this._queueWrite(() => { + return this._write('c\n') + }) + } + + touchReset(): void { + this._queueWrite(() => { + return this._write('r\n') + }) + } + + tap(point: TouchPoint): void { + this.touchDown(point) + this.touchCommit() + this.touchUp(point) + this.touchCommit() + } + + private async startState(): Promise { + if (this.desiredState.next() !== STATE_STARTED) { + this.ensureStateLock = false + setImmediate(() => this._ensureState()) + return + } + + this.runningState = STATE_STARTING + try { + const out = await this._startService() + this.output = new RiskyStream(out) + .on('unexpectedEnd', this._outputEnded.bind(this)) + + this._readOutput(this.output.stream) + + const socket = await this._connectService() + this.socket = new RiskyStream(socket) + .on('unexpectedEnd', this._socketEnded.bind(this)) + + const banner = await this._readBanner(this.socket.stream) + this.banner = banner + + this._readUnexpected(this.socket.stream) + this._processWriteQueue() + + this.runningState = STATE_STARTED + this.emit('start') + } catch (err: any) { + try { + await this._stop() + } finally { + if (err.name !== 'CancellationError') { + this.failCounter.inc() + this.emit('error', err) + } + } + } finally { + this.ensureStateLock = false + this._ensureState() + } + } + + private async stopState(): Promise { + if (this.desiredState.next() !== STATE_STOPPED) { + this.ensureStateLock = false + setImmediate(() => this._ensureState()) + return + } + + this.runningState = STATE_STOPPING + await this._stop() + .finally(() => { + this.ensureStateLock = false + this._ensureState() + }) + } + + private async _ensureState(): Promise { + if (this.desiredState.empty()) { + return + } + if (this.failed) { + log.warn('Will not apply desired state due to too many failures') + return + } + + // Prevent concurrent execution + if (this.ensureStateLock) { + return + } + + this.ensureStateLock = true + try { + switch (this.runningState) { + case STATE_STARTING: + case STATE_STOPPING: + // Just wait. + break + case STATE_STOPPED: + await this.startState() + break + case STATE_STARTED: + await this.stopState() + break + } + } catch (err) { + this.ensureStateLock = false + throw err + } + } + + start(): void { + this.desiredState.push(STATE_STARTED) + this._ensureState() + } + + stop(): void { + this.desiredState.push(STATE_STOPPED) + this._ensureState() + } + + async restart(): Promise { + switch (this.runningState) { + case STATE_STARTED: + case STATE_STARTING: + await this._stop() + this.desiredState.push(STATE_STOPPED) + this.desiredState.push(STATE_STARTED) + this._ensureState() + break + } + } + + private _configChanged(): void { + this.restart() + } + + private _socketEnded(): void { + this.failCounter.inc() + this.restart() + } + + private _outputEnded(): void { + this.failCounter.inc() + this.restart() + } + + private _failLimitExceeded(limit: number, time: number): void { + this._stop() + this.failed = true + this.emit('error', new Error(util.format('Failed more than %d times in %dms', limit, time))) + } + + private async _startService(): Promise { + return await this.minitouch.run() + } + + private _readOutput(out: NodeJS.ReadableStream): void { + // Clean up previous split stream if exists + if (this.splitStream) { + this.splitStream.removeAllListeners('data') + this.splitStream.destroy() + } + + this.splitStream = out.pipe(split()).on('data', (line: any) => { + const trimmed = line.toString().trim() + if (trimmed === '') { + return + } + if (line.includes('ERROR')) { + log.fatal('minitouch error: "%s"', line) + return lifecycle.fatal() + } + log.info('minitouch says: "%s"', line) + }) + } + + private async _connectService(): Promise { + const tryConnect = async (times: number, delay: number): Promise => { + try { + const out = await this.adb.getDevice(this.options.serial).openLocal('localabstract:minitouch') + return out + } catch (err: any) { + if (err.message?.includes('closed') && times > 1) { + await new Promise(resolve => setTimeout(resolve, delay)) + return tryConnect(times - 1, delay * 2) + } + throw err + } + } + + log.info('Connecting to minitouch service') + // SH-03G can be very slow to start sometimes. Make sure we try long + // enough. + return tryConnect(7, 100) + } + + private async _stop(): Promise { + try { + await this._disconnectService(this.socket) + await Promise.race([ + this._stopService(this.output), + new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 10000)) + ]) + this.runningState = STATE_STOPPED + this.emit('stop') + } catch (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 = STATE_STOPPED + this.emit('error', err) + this.emit('stop') + } finally { + // Clean up split stream + if (this.splitStream) { + this.splitStream.removeAllListeners('data') + this.splitStream.destroy() + this.splitStream = null + } + + this.output = null + this.socket = null + this.banner = null + } + } + + private async _disconnectService(socket: RiskyStream | null): Promise { + log.info('Disconnecting from minitouch service') + + if (!socket || socket.ended) { + return true + } + + socket.stream.removeListener('readable', this.readableListener) + + return new Promise((resolve) => { + const endListener = () => { + socket.removeListener('end', endListener) + resolve(true) + } + socket.on('end', endListener) + socket.stream.resume() + socket.end() + + // Add timeout + setTimeout(() => { + socket.removeListener('end', endListener) + resolve(true) + }, 2000) + }) + } + + private async _stopService(output: RiskyStream | null): Promise { + log.info('Stopping minitouch service') + + if (!output || output.ended) { + return true + } + + const pid = this.banner ? this.banner.pid : -1 + + const kill = async (signal: 'SIGTERM' | 'SIGKILL'): Promise => { + if (pid <= 0) { + throw new Error('Minitouch service pid is unknown') + } + const signum = { + SIGTERM: -15, + SIGKILL: -9 + }[signal] + + log.info('Sending %s to minitouch', signal) + + await Promise.race([ + Promise.all([ + output.waitForEnd(), + this.adb.getDevice(this.options.serial).shell(['kill', signum.toString(), pid.toString()]) + .then(Adb.util.readAll) + ]), + new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)) + ]) + + return true + } + + const kindKill = () => kill('SIGTERM') + const forceKill = () => kill('SIGKILL') + const forceEnd = () => { + log.info('Ending minitouch I/O as a last resort') + output.end() + return true + } + + try { + return await kindKill() + } catch (err: any) { + if (err.message === 'Timeout') { + try { + return await forceKill() + } catch { + return forceEnd() + } + } + return forceEnd() + } + } + + private async _readBanner(socket: any): Promise { + log.info('Reading minitouch banner') + + const parser = new Parser(socket) + const banner: Banner = { + pid: -1, + version: 0, + maxContacts: 0, + maxX: 0, + maxY: 0, + maxPressure: 0 + } + + const readVersion = async (): Promise => { + const chunk = await parser.readLine() + const args = chunk.toString().split(/ /g) + switch (args[0]) { + case 'v': + banner.version = Number(args[1]) + break + default: + throw new Error(util.format('Unexpected output "%s", expecting version line', chunk)) + } + } + + const readLimits = async (): Promise => { + const chunk = await parser.readLine() + const args = chunk.toString().split(/ /g) + switch (args[0]) { + case '^': + banner.maxContacts = Number(args[1]) + banner.maxX = Number(args[2]) + banner.maxY = Number(args[3]) + banner.maxPressure = Number(args[4]) + break + default: + throw new Error(util.format('Unknown output "%s", expecting limits line', chunk)) + } + } + + const readPid = async (): Promise => { + const chunk = await parser.readLine() + const args = chunk.toString().split(/ /g) + switch (args[0]) { + case '$': + banner.pid = Number(args[1]) + break + default: + throw new Error(util.format('Unexpected output "%s", expecting pid line', chunk)) + } + } + + await readVersion() + await readLimits() + await readPid() + return banner + } + + private _readUnexpected(socket: any): void { + socket.on('readable', this.readableListener) + // We may already have data pending. + this.readableListener() + } + + private _readableListener(): void { + let chunk + while ((chunk = this.socket?.stream.read())) { + log.warn('Unexpected output from minitouch socket: %s', chunk) + } + } + + private _processWriteQueue(): void { + while (this.writeQueue.length > 0) { + const writer = this.writeQueue.shift() + writer?.call(this) + } + } + + private _write(chunk: string): void { + if (!this.socket?.stream) { + return + } + + // Handle backpressure + const canWrite = this.socket.stream.write(chunk) + if (!canWrite) { + log.warn('Socket buffer is full, experiencing backpressure') + } + } + + destroy(): void { + // Clean up all resources + if (this.splitStream) { + this.splitStream.removeAllListeners('data') + this.splitStream.destroy() + this.splitStream = null + } + + if (this.socket) { + this.socket.stream.removeListener('readable', this.readableListener) + this.socket.removeAllListeners() + } + + if (this.output) { + this.output.removeAllListeners() + } + + this.failCounter.removeAllListeners() + this.removeAllListeners() + this.writeQueue = [] + } +} + +export default syrup.serial() + .dependency(adb) + .dependency(router) + .dependency(minitouch) + .dependency(flags) + .define(async (options: TouchOptions, adb: Client, router: any, minitouch: MinitouchService, flags: any) => { + const startConsumer = async (): Promise => { + const origin = flags.get('forceTouchOrigin', 'top left') + log.info('Touch origin is %s', origin) + + const touchOrigins: Record = { + 'top left': { + x: (point: TouchPoint) => point.x, + y: (point: TouchPoint) => point.y + }, + // So far the only device we've seen exhibiting this behavior + // is Yoga Tablet 8. + 'bottom left': { + x: (point: TouchPoint) => 1 - point.y, + y: (point: TouchPoint) => point.x + } + } + + const touchConsumer = new TouchConsumer({ + // Usually the touch origin is the same as the display's origin, + // but sometimes it might not be. + origin: touchOrigins[origin] + }, options, adb, minitouch) + + // Use Promise.race with once() for cleaner event handling + touchConsumer.start() + + return Promise.race([ + new Promise((resolve) => { + touchConsumer.once('start', () => resolve(touchConsumer)) + }), + new Promise((_, reject) => { + touchConsumer.once('error', reject) + }) + ]) + } + + const touchConsumer = await startConsumer() + const queue = new SeqQueue(100, 4) + + touchConsumer.on('error', (err: Error) => { + log.fatal('Touch consumer had an error %s: %s', err?.message, err?.stack) + lifecycle.fatal() + }) + + router + .on(GestureStartMessage, (channel: any, message: any) => { + queue.start(message.seq) + }) + .on(GestureStopMessage, (channel: any, message: any) => { + queue.push(message.seq, () => { + queue.stop() + }) + }) + .on(TouchDownMessage, (channel: any, message: any) => { + queue.push(message.seq, () => { + touchConsumer.touchDown(message) + }) + }) + .on(TouchMoveMessage, (channel: any, message: any) => { + queue.push(message.seq, () => { + touchConsumer.touchMove(message) + }) + }) + .on(TouchUpMessage, (channel: any, message: any) => { + queue.push(message.seq, () => { + touchConsumer.touchUp(message) + }) + }) + .on(TouchCommitMessage, (channel: any, message: any) => { + queue.push(message.seq, () => { + touchConsumer.touchCommit() + }) + }) + .on(TouchResetMessage, (channel: any, message: any) => { + queue.push(message.seq, () => { + touchConsumer.touchReset() + }) + }) + + return touchConsumer + }) + diff --git a/lib/units/device/resources/minitouch.js b/lib/units/device/resources/minitouch.js deleted file mode 100644 index d9a0f8b26e..0000000000 --- a/lib/units/device/resources/minitouch.js +++ /dev/null @@ -1,75 +0,0 @@ -import util from 'util' -import fs from 'fs' -import Promise from 'bluebird' -import syrup from '@devicefarmer/stf-syrup' -import logger from '../../../util/logger.js' -import * as pathutil from '../../../util/pathutil.cjs' -import devutil from '../../../util/devutil.js' -import * as streamutil from '../../../util/streamutil.js' -import Resource from './util/resource.js' -import adb from '../support/adb.js' -import abi from '../support/abi.js' -export default syrup.serial() - .dependency(adb) - .dependency(abi) - .dependency(devutil) - .define(function(options, adb, abi, devutil) { - var log = logger.createLogger('device:resources:minitouch') - var resources = { - bin: new Resource({ - src: pathutil.requiredMatch(abi.all.map(function(supportedAbi) { - return pathutil.module(util.format('@devicefarmer/minitouch-prebuilt/prebuilt/%s/bin/minitouch%s', supportedAbi, abi.pie ? '' : '-nopie')) - })), - dest: [ - '/data/local/tmp/minitouch', - '/data/data/com.android.shell/minitouch' - ], - comm: 'minitouch', - mode: 0o755 - }) - } - async function removeResource(res) { - const out = await adb.getDevice(options.serial).shell(['rm', '-f', res.dest]) - await streamutil.readAll(out) - return res - } - async function pushResource(res) { - const transfer = await adb.getDevice(options.serial).push(res.src, res.dest, res.mode) - await transfer.waitForEnd() - return res - } - async function installResource(res) { - log.info('Installing "%s" as "%s"', res.src, res.dest) - async function checkExecutable(res) { - const stats = await adb.getDevice(options.serial).stat(res.dest) - return (stats.mode & fs.constants.S_IXUSR) === fs.constants.S_IXUSR - } - const removeResult = await removeResource(res) - const res2 = await pushResource(removeResult) - const ok = await checkExecutable(res2) - if (!ok) { - log.info('Pushed "%s" not executable, attempting fallback location', res.comm) - res.shift() - return installResource(res) - } - return res - } - function installAll() { - return Promise.all([ - installResource(resources.bin) - ]) - } - function stop() { - return devutil.killProcsByComm(resources.bin.comm, resources.bin.dest) - } - return stop() - .then(installAll) - .then(function() { - return { - bin: resources.bin.dest, - run: function(cmd) { - return adb.getDevice(options.serial).shell(util.format('exec %s%s', resources.bin.dest, cmd ? util.format(' %s', cmd) : '')) - } - } - }) - }) diff --git a/lib/units/device/resources/minitouch.ts b/lib/units/device/resources/minitouch.ts new file mode 100644 index 0000000000..fcb5cca49d --- /dev/null +++ b/lib/units/device/resources/minitouch.ts @@ -0,0 +1,96 @@ +import util from 'util' +import fs from 'fs' +import syrup from '@devicefarmer/stf-syrup' +import type {Client} from '@u4/adbkit' +import logger from '../../../util/logger.js' +import * as pathutil from '../../../util/pathutil.cjs' +import devutil from '../../../util/devutil.js' +import Resource from './util/resource.js' +import adb from '../support/adb.js' +import abi from '../support/abi.js' +import lifecycle from '../../../util/lifecycle.js' + +interface MinitouchOptions { + serial: string +} + +interface MinitouchResource { + bin: Resource +} + +interface MinitouchResult { + bin: string + run: (cmd?: string) => Promise +} + +export default syrup.serial() + .dependency(adb) + .dependency(abi) + .dependency(devutil) + .define(async (options: MinitouchOptions, adb: Client, abi: any, devutil: any): Promise => { + const log = logger.createLogger('device:resources:minitouch') + const resources: MinitouchResource = { + bin: new Resource({ + src: pathutil.requiredMatch(abi.all.map((supportedAbi: string) => + pathutil.module(util.format('@devicefarmer/minitouch-prebuilt/prebuilt/%s/bin/minitouch%s', supportedAbi, abi.pie ? '' : '-nopie')) + )), + dest: [ + '/data/local/tmp/minitouch', + '/data/data/com.android.shell/minitouch' + ], + comm: 'minitouch', + mode: 0o755 + }) + } + + const removeResource = async (res: Resource) => { + await adb.getDevice(options.serial).execOut(['rm', '-f', res.dest]) + } + + const pushResource = async (res: Resource) => { + const transfer = await adb.getDevice(options.serial).push(res.src, res.dest, res.mode) + await transfer.waitForEnd() + } + + const checkExecutable = async (res: Resource) => { + const stats = await adb.getDevice(options.serial).stat(res.dest) + return (stats.mode & fs.constants.S_IXUSR) === fs.constants.S_IXUSR + } + + const installResource = async (res: Resource): Promise => { + if (await checkExecutable(res)) return; + + log.info('Installing "%s" as "%s"', res.src, res.dest) + + await removeResource(res) + await pushResource(res) + const ok = await checkExecutable(res) + + if (!ok) { + log.error('Pushed "%s" not executable, attempting fallback location', res.comm) + res.shift() + return installResource(res) + } + } + + const plugin = { + bin: resources.bin.dest, + run: (cmd?: string) => + adb.getDevice(options.serial).shell(`exec ${resources.bin.dest} ${cmd || ''}`), + + stop: async () => { + const pid = (await adb.getDevice(options.serial).execOut('pidof minitouch')).toString().trim() + if (!pid?.length) return; + + log.info('Stopping minitouch process %s', pid) + return adb.getDevice(options.serial).execOut(['kill', '-9', pid]) + } + } + + lifecycle.observe(() => plugin.stop()) + + await plugin.stop() + await installResource(resources.bin) + + return plugin + }) \ No newline at end of file diff --git a/lib/units/ios-device/plugins/install.js b/lib/units/ios-device/plugins/install.js index 0d95efd686..a0f9e19338 100755 --- a/lib/units/ios-device/plugins/install.js +++ b/lib/units/ios-device/plugins/install.js @@ -1,7 +1,6 @@ import {v4 as uuidv4} from 'uuid' import syrup from '@devicefarmer/stf-syrup' import logger from '../../../util/logger.js' -import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import Promise from 'bluebird' import {exec} from 'child_process' @@ -10,7 +9,7 @@ import router from '../../base-device/support/router.js' import push from '../../base-device/support/push.js' import storage from '../../base-device/support/storage.js' import deviceutil from '../../../util/deviceutil.js' -import {InstallMessage} from '../../../wire/wire.js' +import {InstallMessage, UninstallIosMessage} from '../../../wire/wire.js' function execShellCommand(cmd) { return new Promise((resolve, reject) => { diff --git a/lib/util/failcounter.js b/lib/util/failcounter.js deleted file mode 100644 index f5d5a216d6..0000000000 --- a/lib/util/failcounter.js +++ /dev/null @@ -1,25 +0,0 @@ -import util from 'util' -import EventEmitter from 'eventemitter3' -function FailCounter(threshold, time) { - EventEmitter.call(this) - this.threshold = threshold - this.time = time - this.values = [] -} -util.inherits(FailCounter, EventEmitter) -FailCounter.prototype.inc = function() { - var now = Date.now() - while (this.values.length) { - if (now - this.values[0] >= this.time) { - this.values.shift() - } - else { - break - } - } - this.values.push(now) - if (this.values.length > this.threshold) { - this.emit('exceedLimit', this.threshold, this.time) - } -} -export default FailCounter diff --git a/lib/util/failcounter.ts b/lib/util/failcounter.ts new file mode 100644 index 0000000000..8ca6c20a97 --- /dev/null +++ b/lib/util/failcounter.ts @@ -0,0 +1,31 @@ +import EventEmitter from 'events' + +class FailCounter extends EventEmitter { + private threshold: number + private time: number + private values: number[] = [] + + constructor(threshold: number, time: number) { + super() + this.threshold = threshold + this.time = time + } + + inc(): void { + const now = Date.now() + while (this.values.length) { + if (now - this.values[0] >= this.time) { + this.values.shift() + } else { + break + } + } + this.values.push(now) + if (this.values.length > this.threshold) { + this.emit('exceedLimit', this.threshold, this.time) + } + } +} + +export default FailCounter + diff --git a/lib/util/riskystream.js b/lib/util/riskystream.js deleted file mode 100644 index 20b57a9001..0000000000 --- a/lib/util/riskystream.js +++ /dev/null @@ -1,46 +0,0 @@ -import util from 'util' -import Promise from 'bluebird' -import EventEmitter from 'eventemitter3' -function RiskyStream(stream) { - EventEmitter.call(this) - this.endListener = function() { - this.ended = true - this.stream.removeListener('end', this.endListener) - if (!this.expectingEnd) { - this.emit('unexpectedEnd') - } - this.emit('end') - }.bind(this) - this.stream = stream - .on('end', this.endListener) - this.expectingEnd = false - this.ended = false -} -util.inherits(RiskyStream, EventEmitter) -RiskyStream.prototype.end = function() { - this.expectEnd() - return this.stream.end() -} -RiskyStream.prototype.expectEnd = function() { - this.expectingEnd = true - return this -} -RiskyStream.prototype.waitForEnd = function() { - var stream = this.stream - var endListener - this.expectEnd() - return new Promise(function(resolve) { - if (stream.ended) { - return resolve(true) - } - stream.on('end', endListener = function() { - resolve(true) - }) - // Make sure we actually have a chance to get the 'end' event. - stream.resume() - }) - .finally(function() { - stream.removeListener('end', endListener) - }) -} -export default RiskyStream diff --git a/lib/util/riskystream.ts b/lib/util/riskystream.ts new file mode 100644 index 0000000000..b7f765b779 --- /dev/null +++ b/lib/util/riskystream.ts @@ -0,0 +1,56 @@ +import EventEmitter from 'events' + +class RiskyStream extends EventEmitter { + stream: NodeJS.ReadableStream | NodeJS.WritableStream | any + expectingEnd: boolean = false + ended: boolean = false + private endListener: () => void + + constructor(stream: NodeJS.ReadableStream | NodeJS.WritableStream | any) { + super() + + this.endListener = () => { + this.ended = true + this.stream.removeListener('end', this.endListener) + if (!this.expectingEnd) { + this.emit('unexpectedEnd') + } + this.emit('end') + } + + this.stream = stream.on('end', this.endListener) + } + + end(): any { + this.expectEnd() + return this.stream.end() + } + + expectEnd(): this { + this.expectingEnd = true + return this + } + + async waitForEnd(): Promise { + const stream = this.stream + this.expectEnd() + + return new Promise((resolve) => { + if (stream.ended) { + return resolve(true) + } + + const endListener = () => { + stream.removeListener('end', endListener) + resolve(true) + } + + stream.on('end', endListener) + // Make sure we actually have a chance to get the 'end' event. + stream.resume() + }) + } +} + +export default RiskyStream +