diff --git a/packages/fdb-debugger/src/index.ts b/packages/fdb-debugger/src/index.ts index ee576676..ccf40525 100644 --- a/packages/fdb-debugger/src/index.ts +++ b/packages/fdb-debugger/src/index.ts @@ -449,16 +449,55 @@ export class RemoteHost extends EventEmitter { FDBTypes.AppDebugEvalResult, ); + private sendButtonInput = this.bindMethod( + 'input.button', + FDBTypes.ButtonInput, + t.null, + ); + + private sendTouchInput = this.bindMethod( + 'input.touch', + FDBTypes.TouchInput, + t.null, + ); + hasEvalSupport() { return this.hasCapability('appHost.debug.app.evalToString.supported') && this.info.capabilities.appHost!.debug!.app!.evalToString!.supported && !FBOS3_EVAL_QUIRK.test(this.info.device); } + hasTouchInputSupport() { + return this.hasCapability('appHost.input.touch') && + this.info.capabilities.appHost!.input!.touch!; + } + + hasButtonInputSupport() { + return this.hasCapability('appHost.input.buttons') && + this.info.capabilities.appHost!.input!.buttons! && + this.info.capabilities.appHost!.input!.buttons!.length > 0; + } + + buttons() { + if (!this.hasButtonInputSupport) return []; + return this.info.capabilities.appHost!.input!.buttons!; + } + eval(cmd: string) { return this.sendEvalCmd({ cmd }); } + simulateButtonPress(button: FDBTypes.Button) { + return this.sendButtonInput({ button }); + } + + simulateTouch(location: FDBTypes.Point, state: FDBTypes.TouchState) { + return this.sendTouchInput({ + location, + state, + }); + } + supportsPartialAppInstall() { return this.hasCapability('appHost.install.partialBundle') && this.info.capabilities.appHost!.install!.partialBundle!; diff --git a/packages/fdb-protocol/src/FDBTypes/Initialize.ts b/packages/fdb-protocol/src/FDBTypes/Initialize.ts index 5c644b25..4b3f17b9 100644 --- a/packages/fdb-protocol/src/FDBTypes/Initialize.ts +++ b/packages/fdb-protocol/src/FDBTypes/Initialize.ts @@ -5,6 +5,7 @@ import { IOCapabilities } from './BulkData'; import { ConsoleDebuggerCapabilities } from './Console'; import { EvalToStringCapability } from './Eval'; import { HeapSnapshotCapability } from './HeapSnapshot'; +import { InputCapabilities } from './Input'; import { LaunchCapabilities } from './Launch'; import { ProtocolCapabilities } from './Meta'; import { ScreenshotCapabilities } from './Screenshot'; @@ -84,6 +85,7 @@ export const ApplicationHostCapabilities = t.partial( launch: LaunchCapabilities, screenshot: ScreenshotCapabilities, debug: DebugCapabilities, + input: InputCapabilities, }, 'ApplicationHostCapabilities', ); diff --git a/packages/fdb-protocol/src/FDBTypes/Input.ts b/packages/fdb-protocol/src/FDBTypes/Input.ts new file mode 100644 index 00000000..96619057 --- /dev/null +++ b/packages/fdb-protocol/src/FDBTypes/Input.ts @@ -0,0 +1,66 @@ +import * as t from 'io-ts'; + +import { Point } from './Structures'; + +// Runtime types are variables which are used like types, which is +// reflected in their PascalCase naming scheme. +/* tslint:disable:variable-name */ + +export const Button = t.union( + [t.literal('up'), t.literal('down'), t.literal('back')], + 'Button', +); +export type Button = t.TypeOf<typeof Button>; + +export const ButtonInput = t.interface( + { + /** + * Which button is being pressed. + */ + button: Button, + }, + 'ButtonInput', +); +export type ButtonInput = t.TypeOf<typeof ButtonInput>; + +export const TouchState = t.union( + [t.literal('up'), t.literal('down'), t.literal('move')], + 'TouchState', +); +export type TouchState = t.TypeOf<typeof TouchState>; + +export const TouchInput = t.interface( + { + /** + * Status of simulated touch. + * 'move' must only be sent in the period between a 'down' input and its corresponding 'up'. + */ + state: TouchState, + + /** + * Location of touch event. + */ + location: Point, + }, + 'TouchInput', +); +export type TouchInput = t.TypeOf<typeof TouchInput>; + +/** + * Capabilities specific to inputs. + */ +export const InputCapabilities = t.partial( + { + /** + * The Host supports sending simulated button presses. + */ + buttons: t.array(Button), + + /** + * The Host supports sending simulated touch screen presses. + */ + touch: t.boolean, + }, + 'InputCapabilities', +); +export type InputCapabilities = t.TypeOf<typeof InputCapabilities>; diff --git a/packages/fdb-protocol/src/FDBTypes/Structures.ts b/packages/fdb-protocol/src/FDBTypes/Structures.ts index 41711b69..cde7336e 100644 --- a/packages/fdb-protocol/src/FDBTypes/Structures.ts +++ b/packages/fdb-protocol/src/FDBTypes/Structures.ts @@ -182,3 +182,15 @@ export const ComponentBundleKind = t.union([ t.literal('companion'), ]); export type ComponentBundleKind = t.TypeOf<typeof ComponentBundleKind>; + +/** + * Describes a point on the simulated device's screen, relative to the top-left corner at (0,0). + */ +export const Point = t.interface( + { + x: NonNegativeInteger, + y: NonNegativeInteger, + }, + 'Point', +); +export type Point = t.TypeOf<typeof Point>; diff --git a/packages/fdb-protocol/src/FDBTypes/index.ts b/packages/fdb-protocol/src/FDBTypes/index.ts index bc3d998d..2d5391b7 100644 --- a/packages/fdb-protocol/src/FDBTypes/index.ts +++ b/packages/fdb-protocol/src/FDBTypes/index.ts @@ -6,6 +6,7 @@ export * from './ContentsList'; export * from './Eval'; export * from './HeapSnapshot'; export * from './Initialize'; +export * from './Input'; export * from './Launch'; export * from './Meta'; export * from './Screenshot'; diff --git a/packages/sdk-cli/src/cli.ts b/packages/sdk-cli/src/cli.ts index 0d00ee7f..43fcba93 100644 --- a/packages/sdk-cli/src/cli.ts +++ b/packages/sdk-cli/src/cli.ts @@ -20,6 +20,7 @@ import buildAndInstall from './commands/buildAndInstall'; import connect from './commands/connect'; import heapSnapshot from './commands/heapSnapshot'; import hosts from './commands/hosts'; +import input from './commands/input'; import install from './commands/install'; import logout from './commands/logout'; import mockHost from './commands/mockHost'; @@ -38,6 +39,7 @@ cli.use(build); cli.use(buildAndInstall({ hostConnections, appContext })); cli.use(connect({ hostConnections })); cli.use(heapSnapshot({ hostConnections })); +cli.use(input({ hostConnections })); cli.use(install({ hostConnections, appContext })); cli.use(screenshot({ hostConnections })); cli.use(setAppPackage({ appContext })); diff --git a/packages/sdk-cli/src/commands/input.ts b/packages/sdk-cli/src/commands/input.ts new file mode 100644 index 00000000..70e6448a --- /dev/null +++ b/packages/sdk-cli/src/commands/input.ts @@ -0,0 +1,94 @@ +import { FDBTypes } from '@fitbit/fdb-protocol'; +import { isRight } from 'fp-ts/lib/Either'; +import vorpal from 'vorpal'; + +import HostConnections from '../models/HostConnections'; + +function isSupportedButton( + supportedButtons: FDBTypes.Button[], + button: string, +): button is FDBTypes.Button { + return supportedButtons.includes(button as FDBTypes.Button); +} + +function isValidTouchState(state: string): state is FDBTypes.TouchState { + return isRight(FDBTypes.TouchState.decode(state)); +} + +const wait = (durationMs: number) => + new Promise(resolve => setTimeout(resolve, durationMs)); + +export default function input(stores: { hostConnections: HostConnections }) { + return (cli: vorpal) => { + cli + .command('input button <button>', 'Simulate a button press on device') + .hidden() + .action(async (args: vorpal.Args & { button?: string }) => { + const { appHost } = stores.hostConnections; + if (!appHost) { + cli.activeCommand.log('Not connected to a device'); + return false; + } + + if (!appHost.host.hasButtonInputSupport()) { + cli.activeCommand.log( + 'Connected device does not support simulated button presses', + ); + return false; + } + + cli.activeCommand.log(args.button); + if (!isSupportedButton(appHost.host.buttons(), args.button!)) { + cli.activeCommand.log( + `Connected device does not support requested button type. Supported buttons: ${appHost.host + .buttons() + .join(', ')}`, + ); + return false; + } + + return appHost.host.simulateButtonPress(args.button); + }); + + cli + .command( + 'input touch <state> <x> <y>', + 'Simualate a touch event on device', + ) + .hidden() + .action( + async ( + args: vorpal.Args & { state?: string; x?: number; y?: number }, + ) => { + const { appHost } = stores.hostConnections; + if (!appHost) { + cli.activeCommand.log('Not connected to a device'); + return false; + } + + if (!appHost.host.hasTouchInputSupport()) { + cli.activeCommand.log( + 'Connected device does not support simulated touch events', + ); + return false; + } + + if (args.state === 'tap') { + await appHost.host.simulateTouch({ x: args.x!, y: args.y! }, 'down'); + await wait(250); + await appHost.host.simulateTouch({ x: args.x!, y: args.y! }, 'up'); + } else { + if (!isValidTouchState(args.state!)) { + cli.activeCommand.log('Touch state provided was not valid'); + return false; + } + + return appHost.host.simulateTouch( + { x: args.x!, y: args.y! }, + args.state, + ); + } + }, + ); + }; +} diff --git a/yarn.lock b/yarn.lock index 87242dbc..f96677ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1198,6 +1198,13 @@ resolved "https://registry.yarnpkg.com/@types/base64-js/-/base64-js-1.2.5.tgz#582b2476169a6cba460a214d476c744441d873d5" integrity sha1-WCskdhaabLpGCiFNR2x0REHYc9U= +"@types/cbor@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/cbor/-/cbor-2.0.0.tgz#c627afc2ee22f23f2337fecb34628a4f97c6afbb" + integrity sha1-xievwu4i8j8jN/7LNGKKT5fGr7s= + dependencies: + "@types/node" "*" + "@types/cbor@^5.0.0": version "5.0.0" resolved "https://registry.yarnpkg.com/@types/cbor/-/cbor-5.0.0.tgz#b48dcc5f75191399ed6ea7cef862ef38a0945fa0" @@ -1399,6 +1406,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yargs@^13.0.3": + version "13.0.5" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-13.0.5.tgz#18121bfd39dc12f280cee58f92c5b21d32041908" + integrity sha512-CF/+sxTO7FOwbIRL4wMv0ZYLCRfMid2HQpzDRyViH7kSpfoAFiMdGqKIxb1PxWfjtQXQhnQuD33lvRHNwr809Q== + dependencies: + "@types/yargs-parser" "*" + "@zkochan/cmd-shim@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@zkochan/cmd-shim/-/cmd-shim-3.1.0.tgz#2ab8ed81f5bb5452a85f25758eb9b8681982fd2e" @@ -1529,6 +1543,14 @@ ansi-styles@^3.2.0, ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" +ansi-styles@^4.0.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" + integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== + dependencies: + "@types/color-name" "^1.1.1" + color-convert "^2.0.1" + ansi-styles@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.0.tgz#5681f0dcf7ae5880a7841d8831c4724ed9cc0172" @@ -2187,6 +2209,15 @@ cliui@^5.0.0: strip-ansi "^5.2.0" wrap-ansi "^5.1.0" +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + clone-deep@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" @@ -3130,6 +3161,14 @@ find-up@^3.0.0: dependencies: locate-path "^3.0.0" +find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + flush-write-stream@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" @@ -4745,6 +4784,13 @@ locate-path@^3.0.0: p-locate "^3.0.0" path-exists "^3.0.0" +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + lodash._reinterpolate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" @@ -5641,6 +5687,13 @@ p-limit@^2.0.0: dependencies: p-try "^2.0.0" +p-limit@^2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.2.tgz#61279b67721f5287aa1c13a9a7fbbc48c9291b1e" + integrity sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ== + dependencies: + p-try "^2.0.0" + p-locate@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" @@ -5655,6 +5708,13 @@ p-locate@^3.0.0: dependencies: p-limit "^2.0.0" +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + p-map-series@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-map-series/-/p-map-series-1.0.0.tgz#bf98fe575705658a9e1351befb85ae4c1f07bdca" @@ -5790,6 +5850,11 @@ path-exists@^3.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -6819,7 +6884,7 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" -string-width@^4.0.0, string-width@^4.1.0: +string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== @@ -7640,6 +7705,15 @@ wrap-ansi@^5.1.0: string-width "^3.0.0" strip-ansi "^5.0.0" +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -7769,6 +7843,14 @@ yargs-parser@^15.0.0: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^16.1.0: + version "16.1.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-16.1.0.tgz#73747d53ae187e7b8dbe333f95714c76ea00ecf1" + integrity sha512-H/V41UNZQPkUMIT5h5hiwg4QKIY1RPvoBV4XcjUbRM8Bk2oKqqyZ0DIEbTFZB0XjbtSPG8SAa/0DxCQmiRgzKg== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + yargs@^13.3.0: version "13.3.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83" @@ -7801,3 +7883,20 @@ yargs@^14.2.2: which-module "^2.0.0" y18n "^4.0.0" yargs-parser "^15.0.0" + +yargs@^15.0.2: + version "15.1.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.1.0.tgz#e111381f5830e863a89550bd4b136bb6a5f37219" + integrity sha512-T39FNN1b6hCW4SOIk1XyTOWxtXdcen0t+XYrysQmChzSipvhBO8Bj0nK1ozAasdk24dNWuMZvr4k24nz+8HHLg== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^16.1.0"