diff --git a/.gitignore b/.gitignore index 193c75f6c..2930bdbcb 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,9 @@ xcuserdata/ .claude/ **/.claude/settings.local.json +# Worktrees +.worktrees/ + # incremental builds Makefile buildServer.json diff --git a/CHANGELOG.md b/CHANGELOG.md index f657851aa..26f45f79a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Added `toggle_software_keyboard` tool to show or hide the iOS Simulator software keyboard ([#346](https://github.com/getsentry/XcodeBuildMCP/issues/346)). +- Added `toggle_connect_hardware_keyboard` tool to toggle the iOS Simulator hardware keyboard connection ([#346](https://github.com/getsentry/XcodeBuildMCP/issues/346)). - Added `xcodebuildmcp upgrade` command to check for updates and upgrade in place. Supports `--check` (report-only) and `--yes`/`-y` (skip confirmation). Detects install method (Homebrew, npm-global, npx) and queries the appropriate channel source (`brew info`, `npm view`, or GitHub Releases) for the latest version. Non-interactive environments exit 1 when an auto-upgrade is possible but `--yes` was not supplied. ## [2.3.2] @@ -433,4 +435,3 @@ Please note that the UI automation features are an early preview and currently i - Initial release of XcodeBuildMCP - Basic support for building iOS and macOS applications - diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index 87aa254b7..ab508ef79 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -2,7 +2,7 @@ This document lists CLI tool names as exposed by `xcodebuildmcp `. -XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. +XcodeBuildMCP provides 73 canonical tools organized into 13 workflow groups. ## Workflow Groups @@ -128,7 +128,7 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. ### Simulator Management (`simulator-management`) -**Purpose**: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. (8 tools) +**Purpose**: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. (10 tools) - `boot` - Boot iOS simulator for manual/non-build flows. Not required before simulator build-and-run (build_run_sim). - `erase` - Erase simulator. @@ -138,6 +138,8 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. - `set-appearance` - Set sim appearance. - `set-location` - Set sim location. - `statusbar` - Set sim status bar network. +- `toggle-connect-hardware-keyboard` - Toggle whether the iOS Simulator receives Mac hardware keyboard input (Cmd+Shift+K). Disconnecting makes the on-screen keyboard appear for tap-based input. Requires the simulator to be booted and Accessibility permission for the MCP host. +- `toggle-software-keyboard` - Toggle the iOS Simulator software keyboard (Cmd+K). Shows or hides the on-screen keyboard. Requires the simulator to be booted and Accessibility permission for the MCP host. @@ -185,10 +187,10 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. ## Summary Statistics -- **Canonical Tools**: 71 -- **Total Tools**: 99 +- **Canonical Tools**: 73 +- **Total Tools**: 101 - **Workflow Groups**: 13 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-04-07T11:23:03.868Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-04-19T09:35:05.443Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index bf9c1b5a1..2d1e088b1 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,6 +1,6 @@ # XcodeBuildMCP MCP Tools Reference -This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 77 canonical tools organized into 15 workflow groups for comprehensive Apple development workflows. +This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 79 canonical tools organized into 15 workflow groups for comprehensive Apple development workflows. ## Workflow Groups @@ -137,7 +137,7 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov ### Simulator Management (`simulator-management`) -**Purpose**: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. (8 tools) +**Purpose**: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. (10 tools) - `boot_sim` - Boot iOS simulator for manual/non-build flows. Not required before simulator build-and-run (build_run_sim). - `erase_sims` - Erase simulator. @@ -147,6 +147,8 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov - `set_sim_appearance` - Set sim appearance. - `set_sim_location` - Set sim location. - `sim_statusbar` - Set sim status bar network. +- `toggle_connect_hardware_keyboard` - Toggle whether the iOS Simulator receives Mac hardware keyboard input (Cmd+Shift+K). Disconnecting makes the on-screen keyboard appear for tap-based input. Requires the simulator to be booted and Accessibility permission for the MCP host. +- `toggle_software_keyboard` - Toggle the iOS Simulator software keyboard (Cmd+K). Shows or hides the on-screen keyboard. Requires the simulator to be booted and Accessibility permission for the MCP host. @@ -201,10 +203,10 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov ## Summary Statistics -- **Canonical Tools**: 77 -- **Total Tools**: 105 +- **Canonical Tools**: 79 +- **Total Tools**: 107 - **Workflow Groups**: 15 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-04-07T11:23:03.868Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-04-19T09:35:05.443Z UTC* diff --git a/manifests/tools/toggle_connect_hardware_keyboard.yaml b/manifests/tools/toggle_connect_hardware_keyboard.yaml new file mode 100644 index 000000000..d29ed3c0c --- /dev/null +++ b/manifests/tools/toggle_connect_hardware_keyboard.yaml @@ -0,0 +1,12 @@ +id: toggle_connect_hardware_keyboard +module: mcp/tools/simulator-management/toggle_connect_hardware_keyboard +names: + mcp: toggle_connect_hardware_keyboard + cli: toggle-connect-hardware-keyboard +description: Toggle whether the iOS Simulator receives Mac hardware keyboard input (Cmd+Shift+K). Disconnecting makes the on-screen keyboard appear for tap-based input. Requires the simulator to be booted and Accessibility permission for the MCP host. +annotations: + title: Toggle Connect Hardware Keyboard + readOnlyHint: false + destructiveHint: false + openWorldHint: false + idempotentHint: false diff --git a/manifests/tools/toggle_software_keyboard.yaml b/manifests/tools/toggle_software_keyboard.yaml new file mode 100644 index 000000000..9be4af66d --- /dev/null +++ b/manifests/tools/toggle_software_keyboard.yaml @@ -0,0 +1,12 @@ +id: toggle_software_keyboard +module: mcp/tools/simulator-management/toggle_software_keyboard +names: + mcp: toggle_software_keyboard + cli: toggle-software-keyboard +description: Toggle the iOS Simulator software keyboard (Cmd+K). Shows or hides the on-screen keyboard. Requires the simulator to be booted and Accessibility permission for the MCP host. +annotations: + title: Toggle Software Keyboard + readOnlyHint: false + destructiveHint: false + openWorldHint: false + idempotentHint: false diff --git a/manifests/workflows/simulator-management.yaml b/manifests/workflows/simulator-management.yaml index ca55174e3..de0ffa0fd 100644 --- a/manifests/workflows/simulator-management.yaml +++ b/manifests/workflows/simulator-management.yaml @@ -10,3 +10,5 @@ tools: - reset_sim_location - set_sim_appearance - sim_statusbar + - toggle_software_keyboard + - toggle_connect_hardware_keyboard diff --git a/src/mcp/tools/simulator-management/__tests__/_keyboard_shortcut.test.ts b/src/mcp/tools/simulator-management/__tests__/_keyboard_shortcut.test.ts new file mode 100644 index 000000000..b2325eed9 --- /dev/null +++ b/src/mcp/tools/simulator-management/__tests__/_keyboard_shortcut.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect } from 'vitest'; +import { + createMockCommandResponse, + type CommandExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import { sendKeyboardShortcut } from '../_keyboard_shortcut.ts'; + +const BOOTED_JSON = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ + { udid: 'test-uuid-123', name: 'iPhone 15 Pro', state: 'Booted' }, + ], + }, +}); + +const SHUTDOWN_JSON = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ + { udid: 'test-uuid-123', name: 'iPhone 15 Pro', state: 'Shutdown' }, + ], + }, +}); + +const EMPTY_JSON = JSON.stringify({ devices: {} }); +const ESCAPED_NAME_JSON = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ + { udid: 'escaped-uuid', name: 'Test\\Device"', state: 'Booted' }, + ], + }, +}); +const PREFIX_NAME_JSON = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ + { udid: 'prefix-uuid', name: 'iPhone 15', state: 'Booted' }, + ], + }, +}); + +type Call = { command: string[] }; + +function makeFifoExecutor( + responses: Array<{ success: boolean; output?: string; error?: string }>, +): { executor: CommandExecutor; calls: Call[] } { + const calls: Call[] = []; + let i = 0; + const executor: CommandExecutor = async (command) => { + calls.push({ command }); + const r = responses[i] ?? { success: true, output: '' }; + i += 1; + return createMockCommandResponse({ + success: r.success, + output: r.output ?? '', + error: r.error, + }); + }; + return { executor, calls }; +} + +describe('sendKeyboardShortcut', () => { + it('sends Cmd+K for software-keyboard when simulator is booted and window exists', async () => { + const { executor, calls } = makeFifoExecutor([ + { success: true, output: BOOTED_JSON }, + { success: true, output: '' }, + { success: true, output: 'OK' }, + { success: true, output: '' }, + ]); + + const result = await sendKeyboardShortcut('test-uuid-123', 'software-keyboard', executor); + + expect(result.success).toBe(true); + expect(calls[0].command).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']); + expect(calls[1].command).toEqual(['open', '-a', 'Simulator']); + expect(calls[2].command[0]).toBe('osascript'); + expect(calls[2].command.join(' ')).toContain('iPhone 15 Pro'); + expect(calls[3].command[0]).toBe('osascript'); + const keystrokeScript = calls[3].command.join(' '); + expect(keystrokeScript).toContain('keystroke "k"'); + expect(keystrokeScript).toContain('command down'); + expect(keystrokeScript).not.toContain('shift down'); + }); + + it('sends Cmd+Shift+K for connect-hardware-keyboard', async () => { + const { executor, calls } = makeFifoExecutor([ + { success: true, output: BOOTED_JSON }, + { success: true, output: '' }, + { success: true, output: 'OK' }, + { success: true, output: '' }, + ]); + + const result = await sendKeyboardShortcut( + 'test-uuid-123', + 'connect-hardware-keyboard', + executor, + ); + + expect(result.success).toBe(true); + const keystrokeScript = calls[3].command.join(' '); + expect(keystrokeScript).toContain('keystroke "k"'); + expect(keystrokeScript).toContain('command down'); + expect(keystrokeScript).toContain('shift down'); + }); + + it('escapes backslashes before embedding simulator names in the focus AppleScript', async () => { + const { executor, calls } = makeFifoExecutor([ + { success: true, output: ESCAPED_NAME_JSON }, + { success: true, output: '' }, + { success: true, output: 'OK' }, + { success: true, output: '' }, + ]); + + const result = await sendKeyboardShortcut('escaped-uuid', 'software-keyboard', executor); + + expect(result.success).toBe(true); + expect(calls[2].command[2]).toContain('Test\\\\Device\\"'); + }); + + it('matches the simulator window by exact title or runtime suffix instead of substring contains', async () => { + const { executor, calls } = makeFifoExecutor([ + { success: true, output: PREFIX_NAME_JSON }, + { success: true, output: '' }, + { success: true, output: 'OK' }, + { success: true, output: '' }, + ]); + + const result = await sendKeyboardShortcut('prefix-uuid', 'software-keyboard', executor); + + expect(result.success).toBe(true); + expect(calls[2].command[2]).toContain('title is "iPhone 15"'); + expect(calls[2].command[2]).toContain('title starts with "iPhone 15 –"'); + expect(calls[2].command[2]).not.toContain('title contains'); + }); + + it('errors when simulator UUID is not found', async () => { + const { executor, calls } = makeFifoExecutor([{ success: true, output: EMPTY_JSON }]); + + const result = await sendKeyboardShortcut('missing-uuid', 'software-keyboard', executor); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('missing-uuid'); + expect(result.error).toContain('not found'); + } + expect(calls).toHaveLength(1); + }); + + it('errors when simulator is not booted', async () => { + const { executor, calls } = makeFifoExecutor([{ success: true, output: SHUTDOWN_JSON }]); + + const result = await sendKeyboardShortcut('test-uuid-123', 'software-keyboard', executor); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('not booted'); + } + expect(calls).toHaveLength(1); + }); + + it('errors when `open -a Simulator` fails', async () => { + const { executor, calls } = makeFifoExecutor([ + { success: true, output: BOOTED_JSON }, + { success: false, error: 'could not open' }, + ]); + + const result = await sendKeyboardShortcut('test-uuid-123', 'software-keyboard', executor); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('Simulator app'); + } + expect(calls).toHaveLength(2); + }); + + it('errors and does not send keystroke when window lookup returns NO_WINDOW', async () => { + const { executor, calls } = makeFifoExecutor([ + { success: true, output: BOOTED_JSON }, + { success: true, output: '' }, + { success: true, output: 'NO_WINDOW' }, + ]); + + const result = await sendKeyboardShortcut('test-uuid-123', 'software-keyboard', executor); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('iPhone 15 Pro'); + } + expect(calls).toHaveLength(3); + }); + + it('errors when simctl list fails', async () => { + const { executor } = makeFifoExecutor([{ success: false, error: 'simctl blew up' }]); + + const result = await sendKeyboardShortcut('test-uuid-123', 'software-keyboard', executor); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('simctl blew up'); + } + }); + + it('errors when keystroke osascript fails', async () => { + const { executor } = makeFifoExecutor([ + { success: true, output: BOOTED_JSON }, + { success: true, output: '' }, + { success: true, output: 'OK' }, + { success: false, error: 'accessibility denied' }, + ]); + + const result = await sendKeyboardShortcut('test-uuid-123', 'software-keyboard', executor); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('accessibility denied'); + } + }); +}); diff --git a/src/mcp/tools/simulator-management/__tests__/toggle_connect_hardware_keyboard.test.ts b/src/mcp/tools/simulator-management/__tests__/toggle_connect_hardware_keyboard.test.ts new file mode 100644 index 000000000..385d6b2ab --- /dev/null +++ b/src/mcp/tools/simulator-management/__tests__/toggle_connect_hardware_keyboard.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import * as z from 'zod'; +import { + createMockCommandResponse, + type CommandExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import { + schema, + toggle_connect_hardware_keyboardLogic, +} from '../toggle_connect_hardware_keyboard.ts'; +import { runLogic } from '../../../../test-utils/test-helpers.ts'; + +const BOOTED_JSON = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ + { udid: 'test-uuid-123', name: 'iPhone 15 Pro', state: 'Booted' }, + ], + }, +}); + +function fifo(responses: Array<{ success: boolean; output?: string; error?: string }>): { + executor: CommandExecutor; + commands: string[][]; +} { + const commands: string[][] = []; + let i = 0; + const executor: CommandExecutor = async (command) => { + commands.push(command); + const r = responses[i] ?? { success: true, output: '' }; + i += 1; + return createMockCommandResponse({ + success: r.success, + output: r.output ?? '', + error: r.error, + }); + }; + return { executor, commands }; +} + +describe('toggle_connect_hardware_keyboard tool', () => { + describe('Schema Validation', () => { + it('exposes public schema without simulatorId field', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({}).success).toBe(true); + const withSimId = schemaObj.safeParse({ simulatorId: 'test-uuid-123' }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as object)).toBe(false); + }); + }); + + describe('Handler Behavior', () => { + it('returns success for a booted simulator', async () => { + const { executor } = fifo([ + { success: true, output: BOOTED_JSON }, + { success: true, output: '' }, + { success: true, output: 'OK' }, + { success: true, output: '' }, + ]); + + const result = await runLogic(() => + toggle_connect_hardware_keyboardLogic({ simulatorId: 'test-uuid-123' }, executor), + ); + + expect(result.isError).toBeFalsy(); + }); + + it('sends Cmd+Shift+K keystroke', async () => { + const { executor, commands } = fifo([ + { success: true, output: BOOTED_JSON }, + { success: true, output: '' }, + { success: true, output: 'OK' }, + { success: true, output: '' }, + ]); + + await runLogic(() => + toggle_connect_hardware_keyboardLogic({ simulatorId: 'test-uuid-123' }, executor), + ); + + const keystroke = commands[3].join(' '); + expect(keystroke).toContain('keystroke "k"'); + expect(keystroke).toContain('command down'); + expect(keystroke).toContain('shift down'); + }); + + it('returns error when simulator not found', async () => { + const { executor } = fifo([{ success: true, output: JSON.stringify({ devices: {} }) }]); + + const result = await runLogic(() => + toggle_connect_hardware_keyboardLogic({ simulatorId: 'missing' }, executor), + ); + + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/src/mcp/tools/simulator-management/__tests__/toggle_software_keyboard.test.ts b/src/mcp/tools/simulator-management/__tests__/toggle_software_keyboard.test.ts new file mode 100644 index 000000000..42d8ded29 --- /dev/null +++ b/src/mcp/tools/simulator-management/__tests__/toggle_software_keyboard.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import * as z from 'zod'; +import { + createMockCommandResponse, + type CommandExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import { schema, toggle_software_keyboardLogic } from '../toggle_software_keyboard.ts'; +import { runLogic } from '../../../../test-utils/test-helpers.ts'; + +const BOOTED_JSON = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ + { udid: 'test-uuid-123', name: 'iPhone 15 Pro', state: 'Booted' }, + ], + }, +}); + +function fifo(responses: Array<{ success: boolean; output?: string; error?: string }>): { + executor: CommandExecutor; + commands: string[][]; +} { + const commands: string[][] = []; + let i = 0; + const executor: CommandExecutor = async (command) => { + commands.push(command); + const r = responses[i] ?? { success: true, output: '' }; + i += 1; + return createMockCommandResponse({ + success: r.success, + output: r.output ?? '', + error: r.error, + }); + }; + return { executor, commands }; +} + +describe('toggle_software_keyboard tool', () => { + describe('Schema Validation', () => { + it('exposes public schema without simulatorId field', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({}).success).toBe(true); + const withSimId = schemaObj.safeParse({ simulatorId: 'test-uuid-123' }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as object)).toBe(false); + }); + }); + + describe('Handler Behavior', () => { + it('returns success for a booted simulator', async () => { + const { executor } = fifo([ + { success: true, output: BOOTED_JSON }, + { success: true, output: '' }, + { success: true, output: 'OK' }, + { success: true, output: '' }, + ]); + + const result = await runLogic(() => + toggle_software_keyboardLogic({ simulatorId: 'test-uuid-123' }, executor), + ); + + expect(result.isError).toBeFalsy(); + }); + + it('returns an error when the simulator is not booted', async () => { + const { executor } = fifo([ + { + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ + { udid: 'test-uuid-123', name: 'iPhone 15 Pro', state: 'Shutdown' }, + ], + }, + }), + }, + ]); + + const result = await runLogic(() => + toggle_software_keyboardLogic({ simulatorId: 'test-uuid-123' }, executor), + ); + + expect(result.isError).toBe(true); + }); + + it('sends Cmd+K keystroke without shift modifier', async () => { + const { executor, commands } = fifo([ + { success: true, output: BOOTED_JSON }, + { success: true, output: '' }, + { success: true, output: 'OK' }, + { success: true, output: '' }, + ]); + + await runLogic(() => + toggle_software_keyboardLogic({ simulatorId: 'test-uuid-123' }, executor), + ); + + const keystroke = commands[3].join(' '); + expect(keystroke).toContain('keystroke "k"'); + expect(keystroke).toContain('command down'); + expect(keystroke).not.toContain('shift down'); + }); + + it('returns error when executor throws', async () => { + const executor: CommandExecutor = async () => { + throw new Error('boom'); + }; + + const result = await runLogic(() => + toggle_software_keyboardLogic({ simulatorId: 'test-uuid-123' }, executor), + ); + + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/src/mcp/tools/simulator-management/_keyboard_shortcut.ts b/src/mcp/tools/simulator-management/_keyboard_shortcut.ts new file mode 100644 index 000000000..130af9777 --- /dev/null +++ b/src/mcp/tools/simulator-management/_keyboard_shortcut.ts @@ -0,0 +1,147 @@ +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { log } from '../../../utils/logging/index.ts'; + +export type KeyboardShortcut = 'software-keyboard' | 'connect-hardware-keyboard'; + +export type KeyboardShortcutResult = { success: true } | { success: false; error: string }; + +type SimctlDevice = { udid: string; name: string; state: string }; +type SimctlList = { devices: Record }; + +function escapeAppleScriptStringLiteral(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + +function resolveDevice(list: SimctlList, simulatorId: string): SimctlDevice | undefined { + for (const runtime in list.devices) { + const found = list.devices[runtime]?.find((d) => d.udid === simulatorId); + if (found) return found; + } + return undefined; +} + +function buildFocusScript(deviceName: string): string { + const safeName = escapeAppleScriptStringLiteral(deviceName); + return [ + 'tell application "System Events"', + ' tell process "Simulator"', + ' set frontmost to true', + ' set matchingWindows to (every window whose (title is "' + + safeName + + '" or title starts with "' + + safeName + + ' –" or title starts with "' + + safeName + + ' -"))', + ' if (count of matchingWindows) is 0 then', + ' return "NO_WINDOW"', + ' end if', + ' perform action "AXRaise" of (item 1 of matchingWindows)', + ' return "OK"', + ' end tell', + 'end tell', + ].join('\n'); +} + +function buildKeystrokeScript(shortcut: KeyboardShortcut): string { + const modifiers = + shortcut === 'connect-hardware-keyboard' ? '{command down, shift down}' : '{command down}'; + return [ + 'tell application "System Events"', + ' tell process "Simulator"', + ' keystroke "k" using ' + modifiers, + ' end tell', + 'end tell', + ].join('\n'); +} + +export async function sendKeyboardShortcut( + simulatorId: string, + shortcut: KeyboardShortcut, + executor: CommandExecutor, +): Promise { + log('info', `Sending keyboard shortcut "${shortcut}" to simulator ${simulatorId}`); + + const listResult = await executor( + ['xcrun', 'simctl', 'list', 'devices', '--json'], + 'List Simulators', + false, + ); + if (!listResult.success) { + return { + success: false, + error: `Failed to list simulators: ${listResult.error ?? 'unknown error'}`, + }; + } + + let parsed: SimctlList; + try { + parsed = JSON.parse(listResult.output) as SimctlList; + } catch (e) { + return { + success: false, + error: `Failed to parse simulator list: ${(e as Error).message}`, + }; + } + + const device = resolveDevice(parsed, simulatorId); + if (!device) { + return { + success: false, + error: `Simulator ${simulatorId} not found. Use list_sims to see available simulators.`, + }; + } + + if (device.state !== 'Booted') { + return { + success: false, + error: `Simulator ${simulatorId} is not booted. Boot it first with boot_sim.`, + }; + } + + const openResult = await executor(['open', '-a', 'Simulator'], 'Open Simulator App', false); + if (!openResult.success) { + return { + success: false, + error: `Failed to open Simulator app: ${openResult.error ?? 'unknown error'}`, + }; + } + + const focusResult = await executor( + ['osascript', '-e', buildFocusScript(device.name)], + 'Focus Simulator Window', + false, + ); + if (!focusResult.success) { + return { + success: false, + error: `Failed to focus Simulator window: ${focusResult.error ?? 'unknown error'}`, + }; + } + + if (focusResult.output.trim() === 'NO_WINDOW') { + return { + success: false, + error: `No Simulator window found for "${device.name}". Is the simulator window visible?`, + }; + } + + const keystrokeResult = await executor( + ['osascript', '-e', buildKeystrokeScript(shortcut)], + 'Send Keyboard Shortcut', + false, + ); + if (!keystrokeResult.success) { + return { + success: false, + error: `Failed to send keyboard shortcut: ${keystrokeResult.error ?? 'unknown error'}`, + }; + } + + return { success: true }; +} diff --git a/src/mcp/tools/simulator-management/toggle_connect_hardware_keyboard.ts b/src/mcp/tools/simulator-management/toggle_connect_hardware_keyboard.ts new file mode 100644 index 000000000..814393a94 --- /dev/null +++ b/src/mcp/tools/simulator-management/toggle_connect_hardware_keyboard.ts @@ -0,0 +1,77 @@ +import * as z from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { sendKeyboardShortcut } from './_keyboard_shortcut.ts'; + +const toggleConnectHardwareKeyboardSchema = z.object({ + simulatorId: z.uuid().describe('UUID of the simulator to use (obtained from list_simulators)'), +}); + +type ToggleConnectHardwareKeyboardParams = z.infer; + +export async function toggle_connect_hardware_keyboardLogic( + params: ToggleConnectHardwareKeyboardParams, + executor: CommandExecutor, +): Promise { + log('info', `Toggling hardware keyboard connection on simulator ${params.simulatorId}`); + + const headerEvent = header('Toggle Connect Hardware Keyboard', [ + { label: 'Simulator', value: params.simulatorId }, + ]); + + const ctx = getHandlerContext(); + + return withErrorHandling( + ctx, + async () => { + const result = await sendKeyboardShortcut( + params.simulatorId, + 'connect-hardware-keyboard', + executor, + ); + + if (!result.success) { + log('error', `Failed to toggle hardware keyboard: ${result.error}`); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', result.error)); + return; + } + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Sent Connect Hardware Keyboard (Cmd+Shift+K)')); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to toggle hardware keyboard: ${message}`, + logMessage: ({ message }) => + `Error toggling hardware keyboard for simulator ${params.simulatorId}: ${message}`, + }, + ); +} + +const publicSchemaObject = z.strictObject( + toggleConnectHardwareKeyboardSchema.omit({ simulatorId: true } as const).shape, +); + +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: toggleConnectHardwareKeyboardSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: toggleConnectHardwareKeyboardSchema as unknown as z.ZodType< + ToggleConnectHardwareKeyboardParams, + unknown + >, + logicFunction: toggle_connect_hardware_keyboardLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); diff --git a/src/mcp/tools/simulator-management/toggle_software_keyboard.ts b/src/mcp/tools/simulator-management/toggle_software_keyboard.ts new file mode 100644 index 000000000..749437a41 --- /dev/null +++ b/src/mcp/tools/simulator-management/toggle_software_keyboard.ts @@ -0,0 +1,73 @@ +import * as z from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { sendKeyboardShortcut } from './_keyboard_shortcut.ts'; + +const toggleSoftwareKeyboardSchema = z.object({ + simulatorId: z.uuid().describe('UUID of the simulator to use (obtained from list_simulators)'), +}); + +type ToggleSoftwareKeyboardParams = z.infer; + +export async function toggle_software_keyboardLogic( + params: ToggleSoftwareKeyboardParams, + executor: CommandExecutor, +): Promise { + log('info', `Toggling software keyboard on simulator ${params.simulatorId}`); + + const headerEvent = header('Toggle Software Keyboard', [ + { label: 'Simulator', value: params.simulatorId }, + ]); + + const ctx = getHandlerContext(); + + return withErrorHandling( + ctx, + async () => { + const result = await sendKeyboardShortcut(params.simulatorId, 'software-keyboard', executor); + + if (!result.success) { + log('error', `Failed to toggle software keyboard: ${result.error}`); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', result.error)); + return; + } + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Sent Toggle Software Keyboard (Cmd+K)')); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to toggle software keyboard: ${message}`, + logMessage: ({ message }) => + `Error toggling software keyboard for simulator ${params.simulatorId}: ${message}`, + }, + ); +} + +const publicSchemaObject = z.strictObject( + toggleSoftwareKeyboardSchema.omit({ simulatorId: true } as const).shape, +); + +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: toggleSoftwareKeyboardSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: toggleSoftwareKeyboardSchema as unknown as z.ZodType< + ToggleSoftwareKeyboardParams, + unknown + >, + logicFunction: toggle_software_keyboardLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +});