From 249251171eae78c4c0e97c32c503ebaf6a53f86c Mon Sep 17 00:00:00 2001 From: yi-jiang-applovin Date: Sun, 19 Apr 2026 17:09:50 +0800 Subject: [PATCH 1/9] chore: ignore .worktrees directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 193c75f6..2930bdbc 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,9 @@ xcuserdata/ .claude/ **/.claude/settings.local.json +# Worktrees +.worktrees/ + # incremental builds Makefile buildServer.json From 5d34df175c968c3352ca407753ff491583761c0d Mon Sep 17 00:00:00 2001 From: yjmeqt Date: Sun, 19 Apr 2026 17:32:30 +0800 Subject: [PATCH 2/9] feat(simulator-management): add shared keyboard shortcut helper Resolves a simulator UDID to its device name, verifies the simulator is booted, focuses its Simulator.app window via AppleScript, and sends a Cmd+K or Cmd+Shift+K keystroke. Consumed by the keyboard toggle tools added in subsequent commits. Refs #346 --- .../__tests__/_keyboard_shortcut.test.ts | 172 ++++++++++++++++++ .../_keyboard_shortcut.ts | 132 ++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 src/mcp/tools/simulator-management/__tests__/_keyboard_shortcut.test.ts create mode 100644 src/mcp/tools/simulator-management/_keyboard_shortcut.ts 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 00000000..142c11e9 --- /dev/null +++ b/src/mcp/tools/simulator-management/__tests__/_keyboard_shortcut.test.ts @@ -0,0 +1,172 @@ +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: {} }); + +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('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/_keyboard_shortcut.ts b/src/mcp/tools/simulator-management/_keyboard_shortcut.ts new file mode 100644 index 00000000..70c9f31b --- /dev/null +++ b/src/mcp/tools/simulator-management/_keyboard_shortcut.ts @@ -0,0 +1,132 @@ +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 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 = deviceName.replace(/"/g, '\\"'); + return [ + 'tell application "System Events"', + ' tell process "Simulator"', + ' set frontmost to true', + ' set matchingWindows to (every window whose title contains "' + 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 }; +} From 368e4338d9454969f08f029fba295cac127f10f7 Mon Sep 17 00:00:00 2001 From: yjmeqt Date: Sun, 19 Apr 2026 17:33:28 +0800 Subject: [PATCH 3/9] feat(simulator-management): add toggle_software_keyboard tool Exposes Simulator > I/O > Keyboard > Toggle Software Keyboard (Cmd+K) as an MCP tool. Delegates to the shared keyboard shortcut helper. Refs #346 --- manifests/tools/toggle_software_keyboard.yaml | 12 ++ .../toggle_software_keyboard.test.ts | 115 ++++++++++++++++++ .../toggle_software_keyboard.ts | 73 +++++++++++ 3 files changed, 200 insertions(+) create mode 100644 manifests/tools/toggle_software_keyboard.yaml create mode 100644 src/mcp/tools/simulator-management/__tests__/toggle_software_keyboard.test.ts create mode 100644 src/mcp/tools/simulator-management/toggle_software_keyboard.ts diff --git a/manifests/tools/toggle_software_keyboard.yaml b/manifests/tools/toggle_software_keyboard.yaml new file mode 100644 index 00000000..9be4af66 --- /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/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 00000000..42d8ded2 --- /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/toggle_software_keyboard.ts b/src/mcp/tools/simulator-management/toggle_software_keyboard.ts new file mode 100644 index 00000000..749437a4 --- /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' }], +}); From 92c479b6b9ef972dfc18548117b5ef0d1de1db28 Mon Sep 17 00:00:00 2001 From: yjmeqt Date: Sun, 19 Apr 2026 17:34:12 +0800 Subject: [PATCH 4/9] feat(simulator-management): add toggle_connect_hardware_keyboard tool Exposes Simulator > I/O > Keyboard > Connect Hardware Keyboard (Cmd+Shift+K) as an MCP tool. Disconnecting makes the on-screen keyboard appear for tap-based input during UI automation. Refs #346 --- .../toggle_connect_hardware_keyboard.yaml | 12 +++ .../toggle_connect_hardware_keyboard.test.ts | 95 +++++++++++++++++++ .../toggle_connect_hardware_keyboard.ts | 77 +++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 manifests/tools/toggle_connect_hardware_keyboard.yaml create mode 100644 src/mcp/tools/simulator-management/__tests__/toggle_connect_hardware_keyboard.test.ts create mode 100644 src/mcp/tools/simulator-management/toggle_connect_hardware_keyboard.ts diff --git a/manifests/tools/toggle_connect_hardware_keyboard.yaml b/manifests/tools/toggle_connect_hardware_keyboard.yaml new file mode 100644 index 00000000..d29ed3c0 --- /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/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 00000000..385d6b2a --- /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/toggle_connect_hardware_keyboard.ts b/src/mcp/tools/simulator-management/toggle_connect_hardware_keyboard.ts new file mode 100644 index 00000000..814393a9 --- /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' }], +}); From 24a5896572c4c9c270e658dc19eef64dd095585b Mon Sep 17 00:00:00 2001 From: yjmeqt Date: Sun, 19 Apr 2026 17:35:27 +0800 Subject: [PATCH 5/9] docs: document keyboard toggle tools Regenerate TOOLS.md and TOOLS-CLI.md, register the new tools in the simulator-management workflow, and add CHANGELOG entries for toggle_software_keyboard and toggle_connect_hardware_keyboard. Refs #346 --- CHANGELOG.md | 2 ++ manifests/workflows/simulator-management.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb04fbf0..4f8040c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Changed +- Added `toggle_software_keyboard` tool to show or hide the iOS Simulator software keyboard ([#346](https://github.com/getsentry/XcodeBuildMCP/issues/346)). - The `setup` wizard no longer prompts for a simulator or device when macOS is the only selected platform — macOS apps run natively and do not require a simulator or physical device ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). - When a single platform is selected, `xcodebuildmcp setup` now writes `platform` to `sessionDefaults` in `config.yaml` and includes `XCODEBUILDMCP_PLATFORM` in `--format mcp-json` output. For multi-platform projects the platform key is omitted so the agent can choose per-command ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). - The `setup` wizard remembers previous choices on re-run: existing `config.yaml` values (including the new `platform`) are pre-loaded as defaults for every prompt ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). @@ -18,6 +19,7 @@ ### Fixed - Expanded leading `~` and `~/` prefixes in configured `derivedDataPath`, `projectPath`, `workspacePath`, `axePath`, and the iOS/macOS template paths so values like `~/.foo/derivedData` resolve under the user's home directory instead of creating a literal `~` directory under the project root. As part of this change, configured absolute paths are now lexically normalized (e.g. `/a/b/../c` collapses to `/a/c`) before being passed to `xcodebuild` ([#283](https://github.com/getsentry/XcodeBuildMCP/issues/283), supersedes [#301](https://github.com/getsentry/XcodeBuildMCP/pull/301) by [@trmquang93](https://github.com/trmquang93)). +- Added `toggle_connect_hardware_keyboard` tool to toggle the iOS Simulator hardware keyboard connection ([#346](https://github.com/getsentry/XcodeBuildMCP/issues/346)). ### Changed diff --git a/manifests/workflows/simulator-management.yaml b/manifests/workflows/simulator-management.yaml index 0f781472..c1ce1ddc 100644 --- a/manifests/workflows/simulator-management.yaml +++ b/manifests/workflows/simulator-management.yaml @@ -11,3 +11,5 @@ tools: - reset_sim_location - set_sim_appearance - sim_statusbar + - toggle_software_keyboard + - toggle_connect_hardware_keyboard From 7e91100ebd5d4e17993326aad23a73279247ed67 Mon Sep 17 00:00:00 2001 From: yi-jiang-applovin Date: Mon, 20 Apr 2026 22:16:27 +0800 Subject: [PATCH 6/9] fix(simulator-management): tighten keyboard window matching --- .../__tests__/_keyboard_shortcut.test.ts | 44 +++++++++++++++++++ .../_keyboard_shortcut.ts | 19 +++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/mcp/tools/simulator-management/__tests__/_keyboard_shortcut.test.ts b/src/mcp/tools/simulator-management/__tests__/_keyboard_shortcut.test.ts index 142c11e9..b2325eed 100644 --- a/src/mcp/tools/simulator-management/__tests__/_keyboard_shortcut.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/_keyboard_shortcut.test.ts @@ -22,6 +22,20 @@ const SHUTDOWN_JSON = JSON.stringify({ }); 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[] }; @@ -87,6 +101,36 @@ describe('sendKeyboardShortcut', () => { 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 }]); diff --git a/src/mcp/tools/simulator-management/_keyboard_shortcut.ts b/src/mcp/tools/simulator-management/_keyboard_shortcut.ts index 70c9f31b..130af977 100644 --- a/src/mcp/tools/simulator-management/_keyboard_shortcut.ts +++ b/src/mcp/tools/simulator-management/_keyboard_shortcut.ts @@ -8,6 +8,15 @@ export type KeyboardShortcutResult = { success: true } | { success: false; error 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); @@ -17,12 +26,18 @@ function resolveDevice(list: SimctlList, simulatorId: string): SimctlDevice | un } function buildFocusScript(deviceName: string): string { - const safeName = deviceName.replace(/"/g, '\\"'); + const safeName = escapeAppleScriptStringLiteral(deviceName); return [ 'tell application "System Events"', ' tell process "Simulator"', ' set frontmost to true', - ' set matchingWindows to (every window whose title contains "' + safeName + '")', + ' 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', From 7bceed169e8ebbd5373e616f639ea2a93195dfa1 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 25 Apr 2026 23:24:51 +0100 Subject: [PATCH 7/9] feat(simulator-management): emit structured output for keyboard toggle tools Refactor toggle_software_keyboard and toggle_connect_hardware_keyboard to return SimulatorActionResultDomainResult, matching the xcodebuildmcp.output.simulator-action-result contract used by sibling simulator-management tools (set_sim_appearance, sim_statusbar, erase_sims, etc.). - Add toggle-software-keyboard and toggle-connect-hardware-keyboard variants to the SimulatorAction domain union and the structured output JSON schema. - Wire both tool implementations through a NonStreamingExecutor that builds a SimulatorActionResultDomainResult and publishes it via ctx.structuredOutput, replacing the prior status-line fragments. - Add outputSchema declarations to both tool manifests. - Extend the simulator-action text renderer with title and success messages for the two new action types. --- .../toggle_connect_hardware_keyboard.yaml | 3 + manifests/tools/toggle_software_keyboard.yaml | 3 + .../1.schema.json | 24 ++++ .../toggle_connect_hardware_keyboard.ts | 123 +++++++++++++----- .../toggle_software_keyboard.ts | 113 +++++++++++----- src/types/domain-results.ts | 10 +- src/utils/renderers/domain-result-text.ts | 4 + 7 files changed, 215 insertions(+), 65 deletions(-) diff --git a/manifests/tools/toggle_connect_hardware_keyboard.yaml b/manifests/tools/toggle_connect_hardware_keyboard.yaml index d29ed3c0..2ee4766e 100644 --- a/manifests/tools/toggle_connect_hardware_keyboard.yaml +++ b/manifests/tools/toggle_connect_hardware_keyboard.yaml @@ -4,6 +4,9 @@ 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. +outputSchema: + schema: xcodebuildmcp.output.simulator-action-result + version: "1" annotations: title: Toggle Connect Hardware Keyboard readOnlyHint: false diff --git a/manifests/tools/toggle_software_keyboard.yaml b/manifests/tools/toggle_software_keyboard.yaml index 9be4af66..94e99d9f 100644 --- a/manifests/tools/toggle_software_keyboard.yaml +++ b/manifests/tools/toggle_software_keyboard.yaml @@ -4,6 +4,9 @@ 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. +outputSchema: + schema: xcodebuildmcp.output.simulator-action-result + version: "1" annotations: title: Toggle Software Keyboard readOnlyHint: false diff --git a/schemas/structured-output/xcodebuildmcp.output.simulator-action-result/1.schema.json b/schemas/structured-output/xcodebuildmcp.output.simulator-action-result/1.schema.json index c0309c50..090998ff 100644 --- a/schemas/structured-output/xcodebuildmcp.output.simulator-action-result/1.schema.json +++ b/schemas/structured-output/xcodebuildmcp.output.simulator-action-result/1.schema.json @@ -144,6 +144,30 @@ "required": [ "type" ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "toggle-software-keyboard" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "const": "toggle-connect-hardware-keyboard" + } + }, + "required": [ + "type" + ] } ] }, diff --git a/src/mcp/tools/simulator-management/toggle_connect_hardware_keyboard.ts b/src/mcp/tools/simulator-management/toggle_connect_hardware_keyboard.ts index 814393a9..53058906 100644 --- a/src/mcp/tools/simulator-management/toggle_connect_hardware_keyboard.ts +++ b/src/mcp/tools/simulator-management/toggle_connect_hardware_keyboard.ts @@ -1,4 +1,7 @@ import * as z from 'zod'; +import type { ToolHandlerContext } from '../../../rendering/types.ts'; +import type { SimulatorActionResultDomainResult } from '../../../types/domain-results.ts'; +import type { NonStreamingExecutor } from '../../../types/tool-execution.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; @@ -6,9 +9,10 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, getHandlerContext, + toInternalSchema, } from '../../../utils/typed-tool-factory.ts'; -import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; -import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { toErrorMessage } from '../../../utils/errors.ts'; +import { createBasicDiagnostics } from '../../../utils/diagnostics.ts'; import { sendKeyboardShortcut } from './_keyboard_shortcut.ts'; const toggleConnectHardwareKeyboardSchema = z.object({ @@ -16,22 +20,49 @@ const toggleConnectHardwareKeyboardSchema = z.object({ }); type ToggleConnectHardwareKeyboardParams = z.infer; +type ToggleConnectHardwareKeyboardResult = SimulatorActionResultDomainResult; -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 }, - ]); +function createToggleConnectHardwareKeyboardResult(params: { + simulatorId: string; + didError: boolean; + error?: string; + diagnosticMessage?: string; +}): ToggleConnectHardwareKeyboardResult { + return { + kind: 'simulator-action-result', + didError: params.didError, + error: params.error ?? null, + summary: { + status: params.didError ? 'FAILED' : 'SUCCEEDED', + }, + action: { + type: 'toggle-connect-hardware-keyboard', + }, + ...(params.diagnosticMessage + ? { diagnostics: createBasicDiagnostics({ errors: [params.diagnosticMessage] }) } + : {}), + artifacts: { + simulatorId: params.simulatorId, + }, + }; +} - const ctx = getHandlerContext(); +function setStructuredOutput( + ctx: ToolHandlerContext, + result: ToggleConnectHardwareKeyboardResult, +): void { + ctx.structuredOutput = { + result, + schema: 'xcodebuildmcp.output.simulator-action-result', + schemaVersion: '1', + }; +} - return withErrorHandling( - ctx, - async () => { +export function createToggleConnectHardwareKeyboardExecutor( + executor: CommandExecutor, +): NonStreamingExecutor { + return async (params) => { + try { const result = await sendKeyboardShortcut( params.simulatorId, 'connect-hardware-keyboard', @@ -39,22 +70,49 @@ export async function toggle_connect_hardware_keyboardLogic( ); if (!result.success) { - log('error', `Failed to toggle hardware keyboard: ${result.error}`); - ctx.emit(headerEvent); - ctx.emit(statusLine('error', result.error)); - return; + return createToggleConnectHardwareKeyboardResult({ + simulatorId: params.simulatorId, + didError: true, + error: 'Failed to toggle hardware keyboard.', + diagnosticMessage: result.error, + }); } - 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}`, - }, - ); + return createToggleConnectHardwareKeyboardResult({ + simulatorId: params.simulatorId, + didError: false, + }); + } catch (error) { + const diagnosticMessage = toErrorMessage(error); + return createToggleConnectHardwareKeyboardResult({ + simulatorId: params.simulatorId, + didError: true, + error: 'Failed to toggle hardware keyboard.', + diagnosticMessage, + }); + } + }; +} + +export async function toggle_connect_hardware_keyboardLogic( + params: ToggleConnectHardwareKeyboardParams, + executor: CommandExecutor, +): Promise { + log('info', `Toggling hardware keyboard connection on simulator ${params.simulatorId}`); + + const ctx = getHandlerContext(); + const executeToggleConnectHardwareKeyboard = + createToggleConnectHardwareKeyboardExecutor(executor); + + const result = await executeToggleConnectHardwareKeyboard(params); + setStructuredOutput(ctx, result); + + if (result.didError) { + log( + 'error', + `Error toggling hardware keyboard for simulator ${params.simulatorId}: ${result.error ?? 'Unknown error'}`, + ); + } } const publicSchemaObject = z.strictObject( @@ -67,10 +125,9 @@ export const schema = getSessionAwareToolSchemaShape({ }); export const handler = createSessionAwareTool({ - internalSchema: toggleConnectHardwareKeyboardSchema as unknown as z.ZodType< - ToggleConnectHardwareKeyboardParams, - unknown - >, + internalSchema: toInternalSchema( + toggleConnectHardwareKeyboardSchema, + ), 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 index 749437a4..899d0ed6 100644 --- a/src/mcp/tools/simulator-management/toggle_software_keyboard.ts +++ b/src/mcp/tools/simulator-management/toggle_software_keyboard.ts @@ -1,4 +1,7 @@ import * as z from 'zod'; +import type { ToolHandlerContext } from '../../../rendering/types.ts'; +import type { SimulatorActionResultDomainResult } from '../../../types/domain-results.ts'; +import type { NonStreamingExecutor } from '../../../types/tool-execution.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; @@ -6,9 +9,10 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, getHandlerContext, + toInternalSchema, } from '../../../utils/typed-tool-factory.ts'; -import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; -import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { toErrorMessage } from '../../../utils/errors.ts'; +import { createBasicDiagnostics } from '../../../utils/diagnostics.ts'; import { sendKeyboardShortcut } from './_keyboard_shortcut.ts'; const toggleSoftwareKeyboardSchema = z.object({ @@ -16,6 +20,72 @@ const toggleSoftwareKeyboardSchema = z.object({ }); type ToggleSoftwareKeyboardParams = z.infer; +type ToggleSoftwareKeyboardResult = SimulatorActionResultDomainResult; + +function createToggleSoftwareKeyboardResult(params: { + simulatorId: string; + didError: boolean; + error?: string; + diagnosticMessage?: string; +}): ToggleSoftwareKeyboardResult { + return { + kind: 'simulator-action-result', + didError: params.didError, + error: params.error ?? null, + summary: { + status: params.didError ? 'FAILED' : 'SUCCEEDED', + }, + action: { + type: 'toggle-software-keyboard', + }, + ...(params.diagnosticMessage + ? { diagnostics: createBasicDiagnostics({ errors: [params.diagnosticMessage] }) } + : {}), + artifacts: { + simulatorId: params.simulatorId, + }, + }; +} + +function setStructuredOutput(ctx: ToolHandlerContext, result: ToggleSoftwareKeyboardResult): void { + ctx.structuredOutput = { + result, + schema: 'xcodebuildmcp.output.simulator-action-result', + schemaVersion: '1', + }; +} + +export function createToggleSoftwareKeyboardExecutor( + executor: CommandExecutor, +): NonStreamingExecutor { + return async (params) => { + try { + const result = await sendKeyboardShortcut(params.simulatorId, 'software-keyboard', executor); + + if (!result.success) { + return createToggleSoftwareKeyboardResult({ + simulatorId: params.simulatorId, + didError: true, + error: 'Failed to toggle software keyboard.', + diagnosticMessage: result.error, + }); + } + + return createToggleSoftwareKeyboardResult({ + simulatorId: params.simulatorId, + didError: false, + }); + } catch (error) { + const diagnosticMessage = toErrorMessage(error); + return createToggleSoftwareKeyboardResult({ + simulatorId: params.simulatorId, + didError: true, + error: 'Failed to toggle software keyboard.', + diagnosticMessage, + }); + } + }; +} export async function toggle_software_keyboardLogic( params: ToggleSoftwareKeyboardParams, @@ -23,34 +93,18 @@ export async function toggle_software_keyboardLogic( ): Promise { log('info', `Toggling software keyboard on simulator ${params.simulatorId}`); - const headerEvent = header('Toggle Software Keyboard', [ - { label: 'Simulator', value: params.simulatorId }, - ]); - const ctx = getHandlerContext(); + const executeToggleSoftwareKeyboard = createToggleSoftwareKeyboardExecutor(executor); - return withErrorHandling( - ctx, - async () => { - const result = await sendKeyboardShortcut(params.simulatorId, 'software-keyboard', executor); + const result = await executeToggleSoftwareKeyboard(params); + setStructuredOutput(ctx, result); - 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}`, - }, - ); + if (result.didError) { + log( + 'error', + `Error toggling software keyboard for simulator ${params.simulatorId}: ${result.error ?? 'Unknown error'}`, + ); + } } const publicSchemaObject = z.strictObject( @@ -63,10 +117,7 @@ export const schema = getSessionAwareToolSchemaShape({ }); export const handler = createSessionAwareTool({ - internalSchema: toggleSoftwareKeyboardSchema as unknown as z.ZodType< - ToggleSoftwareKeyboardParams, - unknown - >, + internalSchema: toInternalSchema(toggleSoftwareKeyboardSchema), logicFunction: toggle_software_keyboardLogic, getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], diff --git a/src/types/domain-results.ts b/src/types/domain-results.ts index 36ccb878..f810254c 100644 --- a/src/types/domain-results.ts +++ b/src/types/domain-results.ts @@ -409,6 +409,12 @@ export interface SimulatorActionStatusbar { type: 'statusbar'; dataNetwork?: string; } +export interface SimulatorActionToggleSoftwareKeyboard { + type: 'toggle-software-keyboard'; +} +export interface SimulatorActionToggleConnectHardwareKeyboard { + type: 'toggle-connect-hardware-keyboard'; +} export type SimulatorAction = | SimulatorActionBoot | SimulatorActionErase @@ -416,7 +422,9 @@ export type SimulatorAction = | SimulatorActionResetLocation | SimulatorActionSetLocation | SimulatorActionSetAppearance - | SimulatorActionStatusbar; + | SimulatorActionStatusbar + | SimulatorActionToggleSoftwareKeyboard + | SimulatorActionToggleConnectHardwareKeyboard; export interface AppPathRequest { scheme?: string; projectPath?: string; diff --git a/src/utils/renderers/domain-result-text.ts b/src/utils/renderers/domain-result-text.ts index 7a24a7e0..72f1f3c9 100644 --- a/src/utils/renderers/domain-result-text.ts +++ b/src/utils/renderers/domain-result-text.ts @@ -1165,6 +1165,8 @@ function createSimulatorActionItems( 'set-location': 'Set Location', 'set-appearance': 'Set Appearance', statusbar: 'Statusbar', + 'toggle-software-keyboard': 'Toggle Software Keyboard', + 'toggle-connect-hardware-keyboard': 'Toggle Connect Hardware Keyboard', }; const params: HeaderRenderItem['params'] = []; @@ -1196,6 +1198,8 @@ function createSimulatorActionItems( 'set-location': 'Location set successfully', 'set-appearance': `Appearance successfully set to ${result.action.type === 'set-appearance' ? result.action.appearance : 'requested'} mode`, statusbar: 'Status bar data network set successfully', + 'toggle-software-keyboard': 'Sent Toggle Software Keyboard (Cmd+K)', + 'toggle-connect-hardware-keyboard': 'Sent Connect Hardware Keyboard (Cmd+Shift+K)', }; items.push( ...createStandardDiagnosticSections(result.diagnostics), From 6c6fa69ff30939db171ece1663831d48eec87bad Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 26 Apr 2026 09:04:30 +0100 Subject: [PATCH 8/9] refactor(simulator-management): tidy keyboard shortcut helper Use template literals in the AppleScript builders so the title predicate reads as a single expression rather than a chain of string concatenations, and route the JSON.parse error through toErrorMessage to match the rest of the codebase and stay safe for non-Error throws. --- .../simulator-management/_keyboard_shortcut.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/mcp/tools/simulator-management/_keyboard_shortcut.ts b/src/mcp/tools/simulator-management/_keyboard_shortcut.ts index 130af977..a4ea377e 100644 --- a/src/mcp/tools/simulator-management/_keyboard_shortcut.ts +++ b/src/mcp/tools/simulator-management/_keyboard_shortcut.ts @@ -1,5 +1,6 @@ import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { log } from '../../../utils/logging/index.ts'; +import { toErrorMessage } from '../../../utils/errors.ts'; export type KeyboardShortcut = 'software-keyboard' | 'connect-hardware-keyboard'; @@ -27,17 +28,12 @@ function resolveDevice(list: SimctlList, simulatorId: string): SimctlDevice | un function buildFocusScript(deviceName: string): string { const safeName = escapeAppleScriptStringLiteral(deviceName); + const titlePredicate = `title is "${safeName}" or title starts with "${safeName} –" or title starts with "${safeName} -"`; 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 + - ' -"))', + ` set matchingWindows to (every window whose (${titlePredicate}))`, ' if (count of matchingWindows) is 0 then', ' return "NO_WINDOW"', ' end if', @@ -54,7 +50,7 @@ function buildKeystrokeScript(shortcut: KeyboardShortcut): string { return [ 'tell application "System Events"', ' tell process "Simulator"', - ' keystroke "k" using ' + modifiers, + ` keystroke "k" using ${modifiers}`, ' end tell', 'end tell', ].join('\n'); @@ -85,7 +81,7 @@ export async function sendKeyboardShortcut( } catch (e) { return { success: false, - error: `Failed to parse simulator list: ${(e as Error).message}`, + error: `Failed to parse simulator list: ${toErrorMessage(e)}`, }; } From af123f1c8a90dd2e30ec0b0b4df412193048963b Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 28 Apr 2026 15:15:32 +0100 Subject: [PATCH 9/9] test(snapshot): Add keyboard toggle snapshots Add simulator-management snapshot coverage for the new keyboard toggle tools across CLI, MCP, and JSON output. Cover both successful shortcut dispatch and invalid simulator errors so rendered output stays stable. Co-Authored-By: OpenAI Codex --- ...ware-keyboard--error-invalid-simulator.txt | 10 ++++ ...gle-connect-hardware-keyboard--success.txt | 6 +++ ...ware-keyboard--error-invalid-simulator.txt | 10 ++++ .../toggle-software-keyboard--success.txt | 6 +++ ...are-keyboard--error-invalid-simulator.json | 25 +++++++++ ...le-connect-hardware-keyboard--success.json | 17 ++++++ ...are-keyboard--error-invalid-simulator.json | 25 +++++++++ .../toggle-software-keyboard--success.json | 17 ++++++ ...ware-keyboard--error-invalid-simulator.txt | 10 ++++ ...gle-connect-hardware-keyboard--success.txt | 6 +++ ...ware-keyboard--error-invalid-simulator.txt | 10 ++++ .../toggle-software-keyboard--success.txt | 6 +++ .../suites/simulator-management-suite.ts | 52 +++++++++++++++++++ 13 files changed, 200 insertions(+) create mode 100644 src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-connect-hardware-keyboard--error-invalid-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-connect-hardware-keyboard--success.txt create mode 100644 src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-software-keyboard--error-invalid-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-software-keyboard--success.txt create mode 100644 src/snapshot-tests/__fixtures__/json/simulator-management/toggle-connect-hardware-keyboard--error-invalid-simulator.json create mode 100644 src/snapshot-tests/__fixtures__/json/simulator-management/toggle-connect-hardware-keyboard--success.json create mode 100644 src/snapshot-tests/__fixtures__/json/simulator-management/toggle-software-keyboard--error-invalid-simulator.json create mode 100644 src/snapshot-tests/__fixtures__/json/simulator-management/toggle-software-keyboard--success.json create mode 100644 src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-connect-hardware-keyboard--error-invalid-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-connect-hardware-keyboard--success.txt create mode 100644 src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-software-keyboard--error-invalid-simulator.txt create mode 100644 src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-software-keyboard--success.txt diff --git a/src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-connect-hardware-keyboard--error-invalid-simulator.txt b/src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-connect-hardware-keyboard--error-invalid-simulator.txt new file mode 100644 index 00000000..7f385d3f --- /dev/null +++ b/src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-connect-hardware-keyboard--error-invalid-simulator.txt @@ -0,0 +1,10 @@ + +⚙️ Toggle Connect Hardware Keyboard + + Simulator: + +Errors (1): + + ✗ Simulator not found. Use list_sims to see available simulators. + +❌ Failed to toggle hardware keyboard. diff --git a/src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-connect-hardware-keyboard--success.txt b/src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-connect-hardware-keyboard--success.txt new file mode 100644 index 00000000..90e09c78 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-connect-hardware-keyboard--success.txt @@ -0,0 +1,6 @@ + +⚙️ Toggle Connect Hardware Keyboard + + Simulator: + +✅ Sent Connect Hardware Keyboard (Cmd+Shift+K) diff --git a/src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-software-keyboard--error-invalid-simulator.txt b/src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-software-keyboard--error-invalid-simulator.txt new file mode 100644 index 00000000..f57b7d9b --- /dev/null +++ b/src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-software-keyboard--error-invalid-simulator.txt @@ -0,0 +1,10 @@ + +⚙️ Toggle Software Keyboard + + Simulator: + +Errors (1): + + ✗ Simulator not found. Use list_sims to see available simulators. + +❌ Failed to toggle software keyboard. diff --git a/src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-software-keyboard--success.txt b/src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-software-keyboard--success.txt new file mode 100644 index 00000000..4d511834 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/cli/simulator-management/toggle-software-keyboard--success.txt @@ -0,0 +1,6 @@ + +⚙️ Toggle Software Keyboard + + Simulator: + +✅ Sent Toggle Software Keyboard (Cmd+K) diff --git a/src/snapshot-tests/__fixtures__/json/simulator-management/toggle-connect-hardware-keyboard--error-invalid-simulator.json b/src/snapshot-tests/__fixtures__/json/simulator-management/toggle-connect-hardware-keyboard--error-invalid-simulator.json new file mode 100644 index 00000000..0c5c2176 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/json/simulator-management/toggle-connect-hardware-keyboard--error-invalid-simulator.json @@ -0,0 +1,25 @@ +{ + "schema": "xcodebuildmcp.output.simulator-action-result", + "schemaVersion": "1", + "didError": true, + "error": "Failed to toggle hardware keyboard.", + "data": { + "summary": { + "status": "FAILED" + }, + "action": { + "type": "toggle-connect-hardware-keyboard" + }, + "diagnostics": { + "warnings": [], + "errors": [ + { + "message": "Simulator not found. Use list_sims to see available simulators." + } + ] + }, + "artifacts": { + "simulatorId": "" + } + } +} diff --git a/src/snapshot-tests/__fixtures__/json/simulator-management/toggle-connect-hardware-keyboard--success.json b/src/snapshot-tests/__fixtures__/json/simulator-management/toggle-connect-hardware-keyboard--success.json new file mode 100644 index 00000000..1d77a088 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/json/simulator-management/toggle-connect-hardware-keyboard--success.json @@ -0,0 +1,17 @@ +{ + "schema": "xcodebuildmcp.output.simulator-action-result", + "schemaVersion": "1", + "didError": false, + "error": null, + "data": { + "summary": { + "status": "SUCCEEDED" + }, + "action": { + "type": "toggle-connect-hardware-keyboard" + }, + "artifacts": { + "simulatorId": "" + } + } +} diff --git a/src/snapshot-tests/__fixtures__/json/simulator-management/toggle-software-keyboard--error-invalid-simulator.json b/src/snapshot-tests/__fixtures__/json/simulator-management/toggle-software-keyboard--error-invalid-simulator.json new file mode 100644 index 00000000..28316ab0 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/json/simulator-management/toggle-software-keyboard--error-invalid-simulator.json @@ -0,0 +1,25 @@ +{ + "schema": "xcodebuildmcp.output.simulator-action-result", + "schemaVersion": "1", + "didError": true, + "error": "Failed to toggle software keyboard.", + "data": { + "summary": { + "status": "FAILED" + }, + "action": { + "type": "toggle-software-keyboard" + }, + "diagnostics": { + "warnings": [], + "errors": [ + { + "message": "Simulator not found. Use list_sims to see available simulators." + } + ] + }, + "artifacts": { + "simulatorId": "" + } + } +} diff --git a/src/snapshot-tests/__fixtures__/json/simulator-management/toggle-software-keyboard--success.json b/src/snapshot-tests/__fixtures__/json/simulator-management/toggle-software-keyboard--success.json new file mode 100644 index 00000000..ad964dcc --- /dev/null +++ b/src/snapshot-tests/__fixtures__/json/simulator-management/toggle-software-keyboard--success.json @@ -0,0 +1,17 @@ +{ + "schema": "xcodebuildmcp.output.simulator-action-result", + "schemaVersion": "1", + "didError": false, + "error": null, + "data": { + "summary": { + "status": "SUCCEEDED" + }, + "action": { + "type": "toggle-software-keyboard" + }, + "artifacts": { + "simulatorId": "" + } + } +} diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-connect-hardware-keyboard--error-invalid-simulator.txt b/src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-connect-hardware-keyboard--error-invalid-simulator.txt new file mode 100644 index 00000000..7f385d3f --- /dev/null +++ b/src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-connect-hardware-keyboard--error-invalid-simulator.txt @@ -0,0 +1,10 @@ + +⚙️ Toggle Connect Hardware Keyboard + + Simulator: + +Errors (1): + + ✗ Simulator not found. Use list_sims to see available simulators. + +❌ Failed to toggle hardware keyboard. diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-connect-hardware-keyboard--success.txt b/src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-connect-hardware-keyboard--success.txt new file mode 100644 index 00000000..90e09c78 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-connect-hardware-keyboard--success.txt @@ -0,0 +1,6 @@ + +⚙️ Toggle Connect Hardware Keyboard + + Simulator: + +✅ Sent Connect Hardware Keyboard (Cmd+Shift+K) diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-software-keyboard--error-invalid-simulator.txt b/src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-software-keyboard--error-invalid-simulator.txt new file mode 100644 index 00000000..f57b7d9b --- /dev/null +++ b/src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-software-keyboard--error-invalid-simulator.txt @@ -0,0 +1,10 @@ + +⚙️ Toggle Software Keyboard + + Simulator: + +Errors (1): + + ✗ Simulator not found. Use list_sims to see available simulators. + +❌ Failed to toggle software keyboard. diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-software-keyboard--success.txt b/src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-software-keyboard--success.txt new file mode 100644 index 00000000..4d511834 --- /dev/null +++ b/src/snapshot-tests/__fixtures__/mcp/simulator-management/toggle-software-keyboard--success.txt @@ -0,0 +1,6 @@ + +⚙️ Toggle Software Keyboard + + Simulator: + +✅ Sent Toggle Software Keyboard (Cmd+K) diff --git a/src/snapshot-tests/suites/simulator-management-suite.ts b/src/snapshot-tests/suites/simulator-management-suite.ts index 1c774816..b745383a 100644 --- a/src/snapshot-tests/suites/simulator-management-suite.ts +++ b/src/snapshot-tests/suites/simulator-management-suite.ts @@ -135,6 +135,58 @@ export function registerSimulatorManagementSnapshotSuite(runtime: SnapshotRuntim }); }); + describe('toggle-software-keyboard', () => { + it('success', async () => { + const { text, isError } = await harness.invoke( + 'simulator-management', + 'toggle-software-keyboard', + { + simulatorId: simulatorUdid, + }, + ); + expect(isError).toBe(false); + expectFixture(text, 'toggle-software-keyboard--success'); + }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke( + 'simulator-management', + 'toggle-software-keyboard', + { + simulatorId: '00000000-0000-0000-0000-000000000000', + }, + ); + expect(isError).toBe(true); + expectFixture(text, 'toggle-software-keyboard--error-invalid-simulator'); + }); + }); + + describe('toggle-connect-hardware-keyboard', () => { + it('success', async () => { + const { text, isError } = await harness.invoke( + 'simulator-management', + 'toggle-connect-hardware-keyboard', + { + simulatorId: simulatorUdid, + }, + ); + expect(isError).toBe(false); + expectFixture(text, 'toggle-connect-hardware-keyboard--success'); + }); + + it('error - invalid simulator', async () => { + const { text, isError } = await harness.invoke( + 'simulator-management', + 'toggle-connect-hardware-keyboard', + { + simulatorId: '00000000-0000-0000-0000-000000000000', + }, + ); + expect(isError).toBe(true); + expectFixture(text, 'toggle-connect-hardware-keyboard--error-invalid-simulator'); + }); + }); + describe('statusbar', () => { it('success', async () => { const { text, isError } = await harness.invoke('simulator-management', 'statusbar', {