From 818855fcf705239d0c251aa720afb96591367fa4 Mon Sep 17 00:00:00 2001 From: cryptotavares Date: Thu, 29 Oct 2026 19:58:00 +0000 Subject: [PATCH] fix: iOS key presses, back and text clearing idb ui key takes integer HID codes only, so RETURN/HOME/LOCK/DELETE and key-sequence cmd+a all failed. pressButton and dismissKeyboard always threw on iOS, and typeText's clear failed silently (non-strict exec) so retyping appended to the old value. - enter -> ui key 40; home/lock -> ui button HOME|LOCK - back -> throw UNSUPPORTED_ON_IOS (was HOME, which backgrounded the app) - dismissKeyboard presses Return only when a Keyboard element is shown - typeText clears with ui key 4 --command + ui key 42, strict - drop the now-unused non-strict idb path from #idb BREAKING CHANGE: iOS pressButton('back') throws UNSUPPORTED_ON_IOS. --- CHANGELOG.md | 8 + README.md | 8 +- src/backends/idb-backend.test.ts | 272 ++++++++++++++++--------------- src/backends/idb-backend.ts | 165 +++++++++++-------- src/tools/dismiss-keyboard.ts | 4 +- src/tools/press-button.ts | 5 +- 6 files changed, 260 insertions(+), 202 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15f8256..5f71f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** fix: iOS key presses, `back` and text clearing ([#48](https://github.com/MetaMask/device-mcp/pull/48)) + - iOS `pressButton('enter')` and `dismissKeyboard()` send the integer HID key code (`idb ui key 40`); `home` and `lock` use `idb ui button`. Both failed before because `idb ui key` takes only integer codes. + - iOS `pressButton('back')` now throws `UNSUPPORTED_ON_IOS` instead of pressing Home (which put the app in the background). + - iOS `dismissKeyboard()` does nothing when no keyboard is shown. + - iOS `typeText` clears the field with Command+A and Backspace through strict execution. Before, the clear failed silently and the text was appended to the old value. + ## [0.7.0] ### Added diff --git a/README.md b/README.md index c3a5e9d..10ffa0e 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ The `.device-session` file is typically written by the test runner when it creat | `device_swipe` | Swipe in a direction with optional start coordinates and distance. | | `device_long_press` | Long press an element for context menus or drag initiation. | | `device_wait_for` | Poll until an element matching a query appears. | -| `device_press_button` | Press a device button (home/back/enter/lock). | +| `device_press_button` | Press a device button (home/back/enter/lock; no back on iOS). | ### App & Device Control @@ -228,11 +228,13 @@ Elements are identified by accessibility attributes — not internal refs. Match | `device_app_state` | `idb list-apps` / `simctl listapps` | `dumpsys activity` | `mobile: queryAppState` | | `device_open_app` | `idb launch` / `simctl launch` | `monkey -p` | `mobile: activateApp` | | `device_close_app` | `idb terminate` / `simctl terminate` | `am force-stop` | `mobile: terminateApp` | -| `device_press_button` | `idb ui key` | `input keyevent` | `mobile: pressButton/Key` | -| `device_dismiss_keyboard` | `idb ui key RETURN` | `input keyevent 111` | `mobile: hideKeyboard` | +| `device_press_button` | `idb ui key 40` / `idb ui button`¹ | `input keyevent` | `mobile: pressButton/Key` | +| `device_dismiss_keyboard` | `idb ui key 40` if keyboard shown | `input keyevent 111` | `mobile: hideKeyboard` | | `device_dismiss_alert` | find button + tap | find button + tap | `mobile: accept/dismissAlert` | | `device_logs` | `idb log` | `logcat` | `mobile: getLog` | +¹ `enter` sends HID key 40 (Return); `home` and `lock` use `idb ui button HOME|LOCK`. iOS has no back button, so `back` fails with `UNSUPPORTED_ON_IOS`. `device_type` clears the focused field first with Command+A (`idb ui key 4 --command`) and Backspace (`idb ui key 42`). + #### Android snapshot on continuously-redrawing screens `uiautomator dump` calls `UiAutomation.waitForIdle` internally, which never diff --git a/src/backends/idb-backend.test.ts b/src/backends/idb-backend.test.ts index 3c08c5a..b1c6ef0 100644 --- a/src/backends/idb-backend.test.ts +++ b/src/backends/idb-backend.test.ts @@ -1187,6 +1187,100 @@ describe('IdbBackend.snapshot accessibility backend selection', () => { }); }); +describe('IdbBackend keys and buttons', () => { + const udid = 'AAAA1111-BBBB-CCCC-DDDD-EEEE2222FFFF'; + const idb = '/usr/local/bin/idb'; + let backend: IdbBackend; + + const idbCalls = (): unknown[][] => + mockExecStrict.mock.calls + .filter(([cmd, args]) => cmd === idb && args?.[0] === 'ui') + .filter(([, args]) => args?.[1] !== 'describe-all') + .map(([, args]) => args as unknown[]); + + const hierarchyWith = (types: string[]): string => + JSON.stringify([ + { + type: 'Application', + AXLabel: 'MetaMask', + frame: { x: 0, y: 0, width: 402, height: 874 }, + children: types.map((type) => ({ + type, + frame: { x: 0, y: 590, width: 402, height: 284 }, + })), + }, + ]); + + beforeEach(() => { + vi.clearAllMocks(); + connectionState.alive = true; + mockSupportsApi.mockResolvedValue(false); + mockSupportsNested.mockResolvedValue(true); + mockExec.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + mockExecStrict.mockResolvedValue(''); + backend = new IdbBackend(udid); + }); + + it.each([ + ['enter', ['ui', 'key', '40', '--udid', udid]], + ['home', ['ui', 'button', 'HOME', '--udid', udid]], + ['lock', ['ui', 'button', 'LOCK', '--udid', udid]], + ] as const)('pressButton(%s) sends %j', async (button, args) => { + await backend.pressButton(button); + + expect(mockExecStrict).toHaveBeenCalledWith(idb, args, connectionOptions); + expect(idbCalls()).toStrictEqual([args]); + }); + + it('rejects pressButton(back) without touching the device', async () => { + await expect(backend.pressButton('back')).rejects.toMatchObject({ + code: 'UNSUPPORTED_ON_IOS', + message: expect.stringContaining('iOS has no back button'), + }); + expect(mockExecStrict).not.toHaveBeenCalled(); + expect(connectionMocks.connect).not.toHaveBeenCalled(); + }); + + it('clears with Command+A and Backspace before typing, all strict', async () => { + await backend.typeText('hello world'); + + expect(idbCalls()).toStrictEqual([ + ['ui', 'key', '4', '--command', '--udid', udid], + ['ui', 'key', '42', '--udid', udid], + ['ui', 'text', 'hello world', '--udid', udid], + ]); + expect(mockExec).not.toHaveBeenCalledWith( + idb, + expect.anything(), + expect.anything(), + ); + }); + + it('presses Return to dismiss a visible keyboard', async () => { + mockExecStrict.mockImplementation(async (_cmd, args) => + args?.[1] === 'describe-all' ? hierarchyWith(['Keyboard']) : '', + ); + + await backend.dismissKeyboard(); + + expect(idbCalls()).toStrictEqual([['ui', 'key', '40', '--udid', udid]]); + }); + + it('does nothing when no keyboard is shown', async () => { + // React Native's full-screen KeyboardControllerView is always present + // and must not count as a keyboard. + mockExecStrict.mockImplementation(async (_cmd, args) => + args?.[1] === 'describe-all' + ? hierarchyWith(['KeyboardControllerView']) + : '', + ); + + await backend.dismissKeyboard(); + + expect(idbCalls()).toStrictEqual([]); + }); +}); + describe('IdbBackend companion recovery', () => { const udid = 'AAAA1111-BBBB-CCCC-DDDD-EEEE2222FFFF'; const hidInvalidated = @@ -1402,152 +1496,74 @@ describe('IdbBackend companion recovery', () => { expect(connectionMocks.connect).toHaveBeenCalledTimes(1); }); - it('returns the first non-strict failure when disposed during a command', async () => { - await backend.ensureConnected(); - let finish!: (result: execModule.ExecResult) => void; - mockExec.mockImplementation(async (cmd, args) => - cmd !== 'xcrun' && args?.[1] === 'key-sequence' - ? new Promise((resolve) => { - finish = resolve; - }) - : { exitCode: 0, stdout: '', stderr: '' }, + const keyCalls = (key: string): unknown[][] => + mockExecStrict.mock.calls.filter( + ([, args]) => args?.[1] === 'key' && args?.[2] === key, ); - const typing = backend.typeText('abc').catch((e: unknown) => e); - await vi.waitFor(() => expect(finish).toBeDefined()); - - await backend.dispose(); - finish({ exitCode: 1, stdout: '', stderr: hidInvalidated }); - // The failed key-sequence is returned, not resent; the next idb command - // then fails fast because the backend is disposed. - expect(await typing).toMatchObject({ - message: 'IDB backend has been disposed', - }); - expect( - mockExec.mock.calls.filter(([, args]) => args?.[1] === 'key-sequence'), - ).toHaveLength(1); - expect(connectionMocks.connect).toHaveBeenCalledTimes(1); - }); - - it('does not resend when disposed after joining a newer companion', async () => { + it('resends the typeText clear when the HID connection was invalidated', async () => { await backend.ensureConnected(); - let fail!: (error: Error) => void; - mockExecStrict.mockReturnValueOnce( - new Promise((_resolve, reject) => { - fail = reject; - }), - ); - const tapping = backend.tapCoordinates(1, 2).catch((e: unknown) => e); - await vi.waitFor(() => expect(tapCalls()).toHaveLength(1)); - // Another caller replaces the companion while the tap is in flight. - await backend.reconnect(); - // Dispose exactly when the failed tap checks the new companion, so - // ensureConnected() itself succeeds. - let disposing: Promise | undefined; - Object.defineProperty(connectionState, 'alive', { - configurable: true, - get: (): boolean => { - disposing ??= backend.dispose(); - return true; - }, + let failures = 1; + mockExecStrict.mockImplementation(async (_cmd, args) => { + if (args?.[1] === 'key' && args?.[2] === '4' && failures > 0) { + failures -= 1; + throw new Error(hidInvalidated); + } + return ''; }); - try { - fail(new Error(hidInvalidated)); - expect(await tapping).toMatchObject({ - message: - 'IDB command interrupted: the backend was disposed during the call', - }); - await disposing; - } finally { - Object.defineProperty(connectionState, 'alive', { - configurable: true, - writable: true, - value: true, - }); - } - expect(disposing).toBeDefined(); - expect(tapCalls()).toHaveLength(1); - }); - it('keeps the connect error when cleanup after a failed connect also fails', async () => { - connectionMocks.connect.mockRejectedValueOnce(new Error('startup failed')); - connectionMocks.dispose.mockRejectedValueOnce(new Error('stop failed')); + await backend.typeText('abc'); - await expect(backend.ensureConnected()).rejects.toThrow('startup failed'); - expect(connectionMocks.dispose).toHaveBeenCalledTimes(1); + // Command+A failed undelivered, was resent once on a fresh companion, + // and the rest of the sequence ran. + expect(keyCalls('4')).toHaveLength(2); + expect(keyCalls('42')).toHaveLength(1); + expect(connectionMocks.connect).toHaveBeenCalledTimes(2); }); - it('reports a failed reconnect as IDB_COMPANION_UNAVAILABLE', async () => { + it('rejects typeText instead of appending when the clear cannot be delivered', async () => { await backend.ensureConnected(); - mockExecStrict.mockRejectedValue(new Error(hidInvalidated)); - connectionMocks.connect.mockRejectedValueOnce( - new Error('Private IDB companion exited before readiness (1)'), - ); - - await expect(backend.tapCoordinates(1, 1)).rejects.toMatchObject({ - code: 'IDB_COMPANION_UNAVAILABLE', - message: expect.stringContaining('exited before readiness'), - }); - expect(tapCalls()).toHaveLength(1); - }); - - it('passes other failures through unchanged', async () => { - mockExecStrict.mockRejectedValue(new Error('No such app io.metamask')); - - await expect(backend.tapCoordinates(1, 1)).rejects.toThrow( - 'No such app io.metamask', - ); - expect(connectionMocks.connect).toHaveBeenCalledTimes(1); - }); - - it('keeps non-strict commands non-throwing and retries them once', async () => { - mockExecStrict.mockResolvedValue(''); - mockExec.mockImplementation(async (cmd, args) => { - if (cmd !== 'xcrun' && args?.[1] === 'key-sequence') { - return { exitCode: 1, stdout: '', stderr: hidInvalidated }; + mockExecStrict.mockImplementation(async (_cmd, args) => { + if (args?.[1] === 'key' && args?.[2] === '42') { + throw new Error('StatusCode.UNAVAILABLE'); } - return { exitCode: 0, stdout: '', stderr: '' }; + return ''; }); - expect(await backend.typeText('abc')).toBeUndefined(); - const keySequence = mockExec.mock.calls.filter( - ([, args]) => args?.[1] === 'key-sequence', - ); - expect(keySequence).toHaveLength(2); - }); - - it('returns the first non-strict failure when the reconnect fails', async () => { - await backend.ensureConnected(); - mockExecStrict.mockResolvedValue(''); - mockExec.mockImplementation(async (cmd, args) => - cmd !== 'xcrun' && args?.[1] === 'key-sequence' - ? { exitCode: 1, stdout: '', stderr: hidInvalidated } - : { exitCode: 0, stdout: '', stderr: '' }, - ); - connectionMocks.connect.mockRejectedValueOnce(new Error('no companion')); - - // The failed key-sequence result is returned (not resent); the next - // command starts a fresh companion first, and the strict `ui text` succeeds. - expect(await backend.typeText('abc')).toBeUndefined(); + await expect(backend.typeText('abc')).rejects.toMatchObject({ + code: 'IDB_COMPANION_UNAVAILABLE', + message: expect.stringContaining('may have run'), + }); + // Backspace is mutating with uncertain delivery: not resent, and the + // text is never typed onto the old value. + expect(keyCalls('42')).toHaveLength(1); expect( - mockExec.mock.calls.filter(([, args]) => args?.[1] === 'key-sequence'), - ).toHaveLength(1); - expect(connectionMocks.connect).toHaveBeenCalledTimes(3); + mockExecStrict.mock.calls.filter(([, args]) => args?.[1] === 'text'), + ).toHaveLength(0); }); - it('returns a non-strict failure without resending when delivery is uncertain', async () => { - mockExecStrict.mockResolvedValue(''); - mockExec.mockImplementation(async (cmd, args) => - cmd !== 'xcrun' && args?.[1] === 'key' - ? { exitCode: 1, stdout: '', stderr: 'StatusCode.UNAVAILABLE' } - : { exitCode: 0, stdout: '', stderr: '' }, + it('reports disposal during the typeText clear as an interruption', async () => { + await backend.ensureConnected(); + let fail!: (error: Error) => void; + mockExecStrict.mockImplementation(async (_cmd, args) => + args?.[1] === 'key' + ? new Promise((_resolve, reject) => { + fail = reject; + }) + : '', ); + const typing = backend.typeText('abc').catch((e: unknown) => e); + await vi.waitFor(() => expect(fail).toBeDefined()); - await backend.typeText('abc'); + await backend.dispose(); + fail(new Error(hidInvalidated)); - expect( - mockExec.mock.calls.filter(([, args]) => args?.[1] === 'key'), - ).toHaveLength(1); + expect(await typing).toMatchObject({ + message: + 'IDB command interrupted: the backend was disposed during the call', + }); + expect(keyCalls('4')).toHaveLength(1); + expect(connectionMocks.connect).toHaveBeenCalledTimes(1); }); it('shares one reconnect between concurrent failing calls', async () => { diff --git a/src/backends/idb-backend.ts b/src/backends/idb-backend.ts index 3cbb9e6..6fefc7d 100644 --- a/src/backends/idb-backend.ts +++ b/src/backends/idb-backend.ts @@ -39,6 +39,33 @@ import { } from '../utils/platform.js'; import { stopChildProcess } from '../utils/stop-child-process.js'; +/** + * USB HID keyboard usage IDs. `idb ui key` takes these integers, not key + * names (idb 1.7: "argument key: invalid int value: 'RETURN'"). + */ +const HID_KEY = { + a: 4, + return: 40, + backspace: 42, +} as const; + +/** + * Whether the software keyboard is on screen: an element whose type is + * exactly `Keyboard`. (React Native's `KeyboardControllerView` is a + * full-screen container that is always present, so a substring match on the + * type is wrong.) + * + * @param elements - The snapshot hierarchy. + * @returns True when a keyboard element is present. + */ +function hasKeyboard(elements: UIElement[]): boolean { + return elements.some( + (element) => + element.type === 'Keyboard' || + (element.children !== undefined && hasKeyboard(element.children)), + ); +} + export class IdbBackend implements DeviceBackend { readonly kind = 'idb' as const; @@ -181,26 +208,21 @@ export class IdbBackend implements DeviceBackend { if (command !== this.#idbPath) { return execStrict(command, args); } - return this.#idb( - args, - async () => execStrict(command, args, { env: this.#connection.env }), - () => undefined, - true, + return this.#idb(args, async () => + execStrict(command, args, { env: this.#connection.env }), ); } + /** + * Run a non-idb command (`xcrun simctl ...`) and return its result without + * throwing. Every idb command goes through `#execStrict`. + * + * @param command - The command to run. + * @param args - Its arguments. + * @returns The exit code and output. + */ async #exec(command: string, args: string[]): Promise { - if (command !== this.#idbPath) { - return exec(command, args); - } - // Non-strict callers keep their semantics: a failure that survives the - // retry is returned, not thrown. - return this.#idb( - args, - async () => exec(command, args, { env: this.#connection.env }), - (result) => (result.exitCode === 0 ? undefined : result.stderr), - false, - ); + return exec(command, args); } /** @@ -219,24 +241,17 @@ export class IdbBackend implements DeviceBackend { * is never resent after the backend is disposed. * * @param args - The idb arguments (used to decide whether a resend is safe). - * @param run - Sends the command once. - * @param failureOf - Failure text of a resolved run, if it failed. - * @param strict - Throw a typed error when recovery does not help. - * @returns The result of the first successful (or unrecoverable) run. + * @param run - Sends the command once; rejects when it fails. + * @returns The output of the first successful run. */ - async #idb( - args: string[], - run: () => Promise, - failureOf: (value: Result) => string | undefined, - strict: boolean, - ): Promise { + async #idb(args: string[], run: () => Promise): Promise { if (!this.#connected || !this.#connection.alive) { // The companion died (or a reconnect failed) since this method's own // ensureConnected(); start a fresh one before sending. await this.ensureConnected(); } const generation = this.#generation; - const first = await attempt(run, failureOf); + const first = await attempt(run); const failure = first.text === undefined ? undefined : classifyIdbFailure(first.text); if (!failure) { @@ -252,10 +267,7 @@ export class IdbBackend implements DeviceBackend { } } catch (error) { if (this.#disposed) { - return interrupted(first, strict); - } - if (!strict) { - return first.settle(); + throw interrupted(first); } throw new IdbUnavailableError( 'IDB_COMPANION_UNAVAILABLE', @@ -266,12 +278,9 @@ export class IdbBackend implements DeviceBackend { } if (this.#disposed) { // Disposal started while another call's reconnect was finishing. - return interrupted(first, strict); + throw interrupted(first); } if (!resend) { - if (!strict) { - return first.settle(); - } throw new IdbUnavailableError( failure.code, this.#udid, @@ -282,10 +291,10 @@ export class IdbBackend implements DeviceBackend { }, ); } - const second = await attempt(run, failureOf); + const second = await attempt(run); const again = second.text === undefined ? undefined : classifyIdbFailure(second.text); - if (!again || !strict) { + if (!again) { return second.settle(); } throw new IdbUnavailableError(again.code, this.#udid, second.text ?? '', { @@ -489,20 +498,29 @@ export class IdbBackend implements DeviceBackend { ]); } + /** + * Replaces the content of the focused text field with `text`. + * + * Clears first with select-all (Command+A) and Backspace, both through + * strict execution so a failed clear rejects instead of appending to the + * old value. On an empty field the two keys do nothing. + * + * @param text - The text to type. + */ async typeText(text: string): Promise { await this.ensureConnected(); - await this.#exec(this.#idbPath, [ + await this.#execStrict(this.#idbPath, [ 'ui', - 'key-sequence', - '--', - 'cmd+a', + 'key', + String(HID_KEY.a), + '--command', '--udid', this.#udid, ]); - await this.#exec(this.#idbPath, [ + await this.#execStrict(this.#idbPath, [ 'ui', 'key', - 'DELETE', + String(HID_KEY.backspace), '--udid', this.#udid, ]); @@ -711,29 +729,45 @@ export class IdbBackend implements DeviceBackend { } } + /** + * `home` and `lock` are hardware buttons (`idb ui button`); `enter` is the + * Return key (`idb ui key 40`). iOS has no back button. + * + * @param button - The button to press. + * @throws `UNSUPPORTED_ON_IOS` for `back`, which iOS does not have. + */ async pressButton(button: DeviceButton): Promise { + if (button === 'back') { + throw Object.assign( + new Error( + "UNSUPPORTED_ON_IOS: iOS has no back button. Tap the screen's own " + + 'back control (usually top left) instead.', + ), + { code: 'UNSUPPORTED_ON_IOS' }, + ); + } await this.ensureConnected(); - const keyMap: Record = { - home: 'HOME', - lock: 'LOCK', - back: 'HOME', - enter: 'RETURN', - }; - await this.#execStrict(this.#idbPath, [ - 'ui', - 'key', - keyMap[button], - '--udid', - this.#udid, - ]); + const args = + button === 'enter' + ? ['ui', 'key', String(HID_KEY.return)] + : ['ui', 'button', button === 'home' ? 'HOME' : 'LOCK']; + await this.#execStrict(this.#idbPath, [...args, '--udid', this.#udid]); } + /** + * Hides the software keyboard by pressing Return, and does nothing when no + * keyboard is shown. Return can also submit a single-field form. + */ async dismissKeyboard(): Promise { await this.ensureConnected(); + const { hierarchy } = await this.snapshot(); + if (!hasKeyboard(hierarchy)) { + return; + } await this.#execStrict(this.#idbPath, [ 'ui', 'key', - 'RETURN', + String(HID_KEY.return), '--udid', this.#udid, ]); @@ -975,11 +1009,10 @@ type Attempt = { async function attempt( run: () => Promise, - failureOf: (value: Result) => string | undefined, ): Promise> { try { const value = await run(); - return { text: failureOf(value), error: undefined, settle: () => value }; + return { text: undefined, error: undefined, settle: () => value }; } catch (error) { return { text: error instanceof Error ? error.message : String(error), @@ -992,19 +1025,15 @@ async function attempt( } /** - * Settle a failed first attempt when the backend was disposed before the - * recovery could run. Disposal is a teardown, not a device fault, so the + * The error for a failed first attempt when the backend was disposed before + * the recovery could run. Disposal is a teardown, not a device fault, so the * command is not resent and no device remediation is given. * * @param first - The failed first attempt. - * @param strict - Throw instead of returning the failed result. - * @returns The first result, for non-strict callers. + * @returns The error to throw. */ -function interrupted(first: Attempt, strict: boolean): Result { - if (!strict) { - return first.settle(); - } - throw new Error( +function interrupted(first: Attempt): Error { + return new Error( 'IDB command interrupted: the backend was disposed during the call', { cause: first.error }, ); diff --git a/src/tools/dismiss-keyboard.ts b/src/tools/dismiss-keyboard.ts index 25adbae..a39fe10 100644 --- a/src/tools/dismiss-keyboard.ts +++ b/src/tools/dismiss-keyboard.ts @@ -13,7 +13,9 @@ export function registerDismissKeyboardTool( title: 'Dismiss Keyboard', description: 'Hide the on-screen keyboard. Call after typing to reveal elements ' + - 'that may be obscured by the keyboard.', + 'that may be obscured by the keyboard. On iOS this presses Return ' + + 'when a keyboard is shown (which can submit a single-field form) ' + + 'and does nothing otherwise.', inputSchema: {}, }, async () => { diff --git a/src/tools/press-button.ts b/src/tools/press-button.ts index 077462a..2d67a7f 100644 --- a/src/tools/press-button.ts +++ b/src/tools/press-button.ts @@ -13,8 +13,9 @@ export function registerPressButtonTool( { title: 'Press Button', description: - 'Press a physical device button. ' + - 'Note: iOS has no back button — "back" maps to home.', + 'Press a device button. "enter" sends the Return key and can ' + + 'submit a form. iOS has no back button: "back" fails with ' + + "UNSUPPORTED_ON_IOS; tap the screen's back control instead.", inputSchema: { button: z .enum(['home', 'back', 'enter', 'lock'])