diff --git a/.axe-version b/.axe-version index 943f9cbc4..27f9cd322 100644 --- a/.axe-version +++ b/.axe-version @@ -1 +1 @@ -1.7.1 +1.8.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b47e26f4..dae332aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Fixed malformed simulator discovery responses, project discovery path-boundary checks, and compiler diagnostic filenames containing glob metacharacters ([#424](https://github.com/getsentry/XcodeBuildMCP/issues/424)). - Fixed `suppressWarnings` being ignored in settled build, build-run, and test output. The flag was honored only while streaming, so warnings still reached the final MCP tool response ([#447](https://github.com/getsentry/XcodeBuildMCP/issues/447)). - Fixed iOS scaffold orientation and device-family settings, LLDB command isolation and argument escaping, run-destination parsing without an active scheme, concurrent working-directory mutations, blocking physical-device name lookup, and unverified `xcodemake` downloads ([#459](https://github.com/getsentry/XcodeBuildMCP/issues/459)). +- Fixed simulator UI launching and keyboard controls to prefer Xcode 27's Device Hub when available, with Simulator.app as the legacy fallback. ## [2.6.2] diff --git a/manifests/tools/build_run_sim.yaml b/manifests/tools/build_run_sim.yaml index c4c2e0a39..4505856a1 100644 --- a/manifests/tools/build_run_sim.yaml +++ b/manifests/tools/build_run_sim.yaml @@ -3,7 +3,7 @@ module: mcp/tools/simulator/build_run_sim names: mcp: build_run_sim cli: build-and-run -description: Build, install, and launch on iOS Simulator; boots simulator and attempts to open Simulator.app as needed. Runtime logs are captured automatically and the log file path is included in the response. Preferred single-step run tool when defaults are set. +description: Build, install, and launch on iOS Simulator, booting it when needed. Runtime logs are captured automatically and the log file path is included in the response. Preferred single-step run tool when defaults are set. outputSchema: schema: xcodebuildmcp.output.build-run-result version: "2" diff --git a/manifests/tools/open_sim.yaml b/manifests/tools/open_sim.yaml index d3560b109..793920faa 100644 --- a/manifests/tools/open_sim.yaml +++ b/manifests/tools/open_sim.yaml @@ -3,7 +3,7 @@ module: mcp/tools/simulator/open_sim names: mcp: open_sim cli: open -description: Open Simulator.app for visibility/manual workflows. Not required before simulator build-and-run (build_run_sim). +description: Open the simulator frontend for visibility and manual workflows. Not required before simulator build-and-run (build_run_sim). outputSchema: schema: xcodebuildmcp.output.simulator-action-result version: "2" diff --git a/manifests/tools/toggle_connect_hardware_keyboard.yaml b/manifests/tools/toggle_connect_hardware_keyboard.yaml index 26be0031d..bbd1f6784 100644 --- a/manifests/tools/toggle_connect_hardware_keyboard.yaml +++ b/manifests/tools/toggle_connect_hardware_keyboard.yaml @@ -3,7 +3,7 @@ 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. +description: Toggle whether the iOS Simulator simulates a hardware keyboard connection. 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: "2" diff --git a/manifests/tools/toggle_software_keyboard.yaml b/manifests/tools/toggle_software_keyboard.yaml index 680e6eeee..20b0d5517 100644 --- a/manifests/tools/toggle_software_keyboard.yaml +++ b/manifests/tools/toggle_software_keyboard.yaml @@ -3,7 +3,7 @@ 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. +description: Toggle the iOS Simulator software keyboard. 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: "2" diff --git a/src/benchmarks/claude-ui/__tests__/preflight-commands.test.ts b/src/benchmarks/claude-ui/__tests__/preflight-commands.test.ts index 021bb905a..4e96ec1ac 100644 --- a/src/benchmarks/claude-ui/__tests__/preflight-commands.test.ts +++ b/src/benchmarks/claude-ui/__tests__/preflight-commands.test.ts @@ -27,7 +27,7 @@ describe('Claude UI benchmark preflight commands', () => { 'killall -9 RocketSim || true', 'sleep 2', 'open -gja RocketSim', - 'open -a Simulator --args -CurrentDeviceUDID SIM-123', + "open 'devices:///manage/select?id=SIM-123' || open -a Simulator --args -CurrentDeviceUDID SIM-123", 'sleep 10', ]); }); @@ -46,9 +46,9 @@ describe('Claude UI benchmark preflight commands', () => { }), ).toEqual([ 'open RocketSim', - 'open -a Simulator --args -CurrentDeviceUDID SIM-123', + "open 'devices:///manage/select?id=SIM-123' || open -a Simulator --args -CurrentDeviceUDID SIM-123", 'open /Applications/RocketSim.app', - 'open -a Simulator --args -CurrentDeviceUDID SIM-123', + "open 'devices:///manage/select?id=SIM-123' || open -a Simulator --args -CurrentDeviceUDID SIM-123", ]); }); @@ -60,11 +60,11 @@ describe('Claude UI benchmark preflight commands', () => { }), ).toEqual([ 'open -a RocketSim.app', - "open -a Simulator --args -CurrentDeviceUDID 'SIM'\"'\"'123'", + "open 'devices:///manage/select?id=SIM%27123' || open -a Simulator --args -CurrentDeviceUDID 'SIM'\"'\"'123'", ]); }); - it('does not inject Simulator.app focus commands in headless launch mode', () => { + it('does not inject simulator frontend focus commands in headless launch mode', () => { process.env[HEADLESS_ENV_VAR] = '1'; const commands = ['open -gja RocketSim']; diff --git a/src/benchmarks/claude-ui/__tests__/simulator-existing-lifecycle.test.ts b/src/benchmarks/claude-ui/__tests__/simulator-existing-lifecycle.test.ts index b4e0b8058..0df06fcc3 100644 --- a/src/benchmarks/claude-ui/__tests__/simulator-existing-lifecycle.test.ts +++ b/src/benchmarks/claude-ui/__tests__/simulator-existing-lifecycle.test.ts @@ -74,16 +74,62 @@ describe('Claude UI existing simulator lifecycle', () => { ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], ['xcrun', 'simctl', 'boot', 'EXISTING-SIM-123'], ['xcrun', 'simctl', 'bootstatus', 'EXISTING-SIM-123', '-b'], - ['open', '-a', 'Simulator', '--args', '-CurrentDeviceUDID', 'EXISTING-SIM-123'], + ['open', 'devices:///manage/select?id=EXISTING-SIM-123'], ]); expect(events).toEqual([ 'resolving simulator iPhone 17 Pro Max', 'using simulator EXISTING-SIM-123', 'booting simulator EXISTING-SIM-123', 'waiting for simulator EXISTING-SIM-123 bootstatus', - 'opening Simulator.app for EXISTING-SIM-123', + 'opening Device Hub for EXISTING-SIM-123', 'simulator ready EXISTING-SIM-123', ]); expect(log.messages.join('\n')).toContain('Existing simulator ready: EXISTING-SIM-123'); }); + + it('falls back to Simulator.app when Device Hub is unavailable', async () => { + const commands: LifecycleCommandOptions[] = []; + const executor: LifecycleCommandExecutor = async (opts) => { + commands.push(opts); + if (opts.args[1] === 'list') { + return { + exitCode: 0, + stdout: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [ + { name: 'iPhone 17 Pro Max', udid: 'EXISTING-SIM-123', isAvailable: true }, + ], + }, + }), + stderr: '', + durationSeconds: 0.01, + }; + } + if (opts.command === 'open' && opts.args[0]?.startsWith('devices:')) { + return { + exitCode: 1, + stdout: '', + stderr: 'Device Hub unavailable', + durationSeconds: 0.01, + }; + } + return { exitCode: 0, stdout: '', stderr: '', durationSeconds: 0.01 }; + }; + + await prepareTemporarySimulator({ + config: config({ temporarySimulator: false }), + suiteSlug: 'weather', + timestamp: '20260522T120000Z', + cwd: '/repo', + logPath: '/tmp/simulator-lifecycle.log', + executor, + logWriter: async () => undefined, + readinessDelayMs: 0, + }); + + expect(commands.slice(-2).map((item) => [item.command, ...item.args])).toEqual([ + ['open', 'devices:///manage/select?id=EXISTING-SIM-123'], + ['open', '-a', 'Simulator', '--args', '-CurrentDeviceUDID', 'EXISTING-SIM-123'], + ]); + }); }); diff --git a/src/benchmarks/claude-ui/__tests__/simulator-lifecycle.test.ts b/src/benchmarks/claude-ui/__tests__/simulator-lifecycle.test.ts index 70912a7f6..af38b72aa 100644 --- a/src/benchmarks/claude-ui/__tests__/simulator-lifecycle.test.ts +++ b/src/benchmarks/claude-ui/__tests__/simulator-lifecycle.test.ts @@ -184,13 +184,13 @@ describe('Claude UI temporary simulator lifecycle', () => { ['xcrun', 'simctl', 'create', 'Claude UI weather 20260522T120000Z', 'iPhone 17 Pro Max'], ['xcrun', 'simctl', 'boot', 'TEMP-SIM-123'], ['xcrun', 'simctl', 'bootstatus', 'TEMP-SIM-123', '-b'], - ['open', '-a', 'Simulator', '--args', '-CurrentDeviceUDID', 'TEMP-SIM-123'], + ['open', 'devices:///manage/select?id=TEMP-SIM-123'], ]); expect(events).toEqual([ 'creating simulator Claude UI weather 20260522T120000Z', 'booting simulator TEMP-SIM-123', 'waiting for simulator TEMP-SIM-123 bootstatus', - 'opening Simulator.app for TEMP-SIM-123', + 'opening Device Hub for TEMP-SIM-123', 'simulator ready TEMP-SIM-123', ]); @@ -206,7 +206,7 @@ describe('Claude UI temporary simulator lifecycle', () => { expect(log.messages.join('\n')).toContain('Temporary simulator ready: TEMP-SIM-123'); }); - it('does not open Simulator.app when headless launch mode is enabled', async () => { + it('does not open a simulator frontend when headless launch mode is enabled', async () => { const previousHeadlessValue = process.env[HEADLESS_ENV_VAR]; process.env[HEADLESS_ENV_VAR] = '1'; try { @@ -249,7 +249,7 @@ describe('Claude UI temporary simulator lifecycle', () => { 'simulator ready TEMP-SIM-123', ]); expect(log.messages.join('\n')).toContain( - 'Simulator.app launch skipped by headless launch policy', + 'Simulator frontend launch skipped by headless launch policy', ); } finally { if (previousHeadlessValue === undefined) { diff --git a/src/benchmarks/claude-ui/preflight-commands.ts b/src/benchmarks/claude-ui/preflight-commands.ts index 611e2af91..e75558553 100644 --- a/src/benchmarks/claude-ui/preflight-commands.ts +++ b/src/benchmarks/claude-ui/preflight-commands.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { writeFile } from 'node:fs/promises'; -import { buildOpenSimulatorAppCommand } from '../../utils/focus-policy.ts'; +import { buildOpenSimulatorFrontendCommands } from '../../utils/focus-policy.ts'; interface CapturedCommandResult { exitCode: number | null; @@ -34,10 +34,14 @@ export function preflightCommandsWithFocusResign(opts: { const commands = opts.commands ?? []; if (!opts.simulatorId) return commands; - const focusSimulatorCommand = buildOpenSimulatorAppCommand({ simulatorId: opts.simulatorId }); - if (focusSimulatorCommand === null) return commands; + const focusSimulatorCommands = buildOpenSimulatorFrontendCommands({ + simulatorId: opts.simulatorId, + }); + if (focusSimulatorCommands === null) return commands; - const focusSimulatorShellCommand = shellCommandFromArgs(focusSimulatorCommand); + const focusSimulatorShellCommand = focusSimulatorCommands + .map(({ command }) => shellCommandFromArgs(command)) + .join(' || '); return commands.flatMap((command) => isRocketSimAppLaunchCommand(command) ? [command, focusSimulatorShellCommand] : [command], ); diff --git a/src/benchmarks/claude-ui/simulator-frontend.ts b/src/benchmarks/claude-ui/simulator-frontend.ts new file mode 100644 index 000000000..9528a411d --- /dev/null +++ b/src/benchmarks/claude-ui/simulator-frontend.ts @@ -0,0 +1,89 @@ +import { buildOpenSimulatorFrontendCommands } from '../../utils/focus-policy.ts'; + +interface FrontendCommandResult { + exitCode: number | null; + stdout: string; + stderr: string; +} + +interface FrontendCommandOptions { + command: string; + args: string[]; + cwd: string; + logPath: string; +} + +function commandText(command: string, args: string[]): string { + return [command, ...args].join(' '); +} + +function commandOutput(result: FrontendCommandResult): string { + return `${result.stdout}\n${result.stderr}`; +} + +export async function openBenchmarkSimulatorFrontend(opts: { + simulatorId: string; + configName: string; + cwd: string; + logPath: string; + executor: (opts: FrontendCommandOptions) => Promise; + appendLog: (message: string) => Promise; + onEvent?: (message: string) => void; +}): Promise { + const candidates = buildOpenSimulatorFrontendCommands({ simulatorId: opts.simulatorId }); + if (candidates === null) { + await opts.appendLog('Simulator frontend launch skipped by headless launch policy'); + return; + } + + for (let attempt = 1; attempt <= 3; attempt += 1) { + const failures: string[] = []; + for (const candidate of candidates) { + const [openExecutable, ...openArgs] = candidate.command; + if (openExecutable === undefined) { + throw new Error(`${opts.configName}: simulator frontend launch command was empty`); + } + const label = candidate.frontend === 'device-hub' ? 'Device Hub' : 'Simulator.app'; + opts.onEvent?.(`opening ${label} for ${opts.simulatorId}`); + const openResult = await opts.executor({ + command: openExecutable, + args: openArgs, + cwd: opts.cwd, + logPath: opts.logPath, + }); + if (openResult.exitCode === 0) return; + + failures.push( + `${label}: ${commandText(openExecutable, openArgs)} exited ${openResult.exitCode}`, + ); + await opts.appendLog( + `Open ${label} attempt ${attempt} failed with exit ${openResult.exitCode}`, + ); + if (candidate.frontend === 'simulator' && /error -1712/i.test(commandOutput(openResult))) { + await opts.appendLog( + 'Simulator.app did not respond to LaunchServices; terminating the UI process before retry', + ); + await opts.executor({ + command: 'killall', + args: ['-9', 'Simulator'], + cwd: opts.cwd, + logPath: opts.logPath, + }); + } + } + + if (attempt === 3) { + throw new Error( + `${opts.configName}: failed to open a simulator frontend (${failures.join('; ')}); see ${opts.logPath}`, + ); + } + const delayMs = attempt * 2_000; + await opts.appendLog( + `Simulator frontend open attempt ${attempt} failed; retrying in ${(delayMs / 1000).toFixed(1)}s`, + ); + opts.onEvent?.(`Simulator frontend open attempt ${attempt} failed; retrying`); + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + } +} diff --git a/src/benchmarks/claude-ui/simulator-lifecycle.ts b/src/benchmarks/claude-ui/simulator-lifecycle.ts index 0a509269e..47a9f4abe 100644 --- a/src/benchmarks/claude-ui/simulator-lifecycle.ts +++ b/src/benchmarks/claude-ui/simulator-lifecycle.ts @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process'; import { appendFile } from 'node:fs/promises'; -import { buildOpenSimulatorAppCommand } from '../../utils/focus-policy.ts'; import type { BenchmarkConfig } from './types.ts'; +import { openBenchmarkSimulatorFrontend } from './simulator-frontend.ts'; type SessionDefaultKey = keyof NonNullable; @@ -212,59 +212,15 @@ async function bootAndOpenSimulator(opts: { ); } - const openCommand = buildOpenSimulatorAppCommand({ simulatorId: opts.simulatorId }); - if (openCommand === null) { - await appendLifecycleLog( - opts.logPath, - 'Simulator.app launch skipped by headless launch policy', - opts.logWriter, - ); - } else { - const [openExecutable, ...openArgs] = openCommand; - if (openExecutable === undefined) { - throw new Error(`${opts.configName}: Simulator.app launch command was empty`); - } - - opts.onEvent?.(`opening Simulator.app for ${opts.simulatorId}`); - let openResult: LoggedCommandResult | undefined; - for (let attempt = 1; attempt <= 3; attempt += 1) { - openResult = await opts.executor({ - command: openExecutable, - args: openArgs, - cwd: opts.cwd, - logPath: opts.logPath, - }); - if (openResult.exitCode === 0) break; - if (attempt === 3) { - throw new Error( - `${opts.configName}: failed to open Simulator.app with ${commandText(openExecutable, openArgs)} (exit ${openResult.exitCode}); see ${opts.logPath}`, - ); - } - const delayMs = attempt * 2_000; - await appendLifecycleLog( - opts.logPath, - `Open Simulator.app attempt ${attempt} failed with exit ${openResult.exitCode}; retrying in ${(delayMs / 1000).toFixed(1)}s`, - opts.logWriter, - ); - if (/error -1712/i.test(commandOutput(openResult))) { - await appendLifecycleLog( - opts.logPath, - 'Simulator.app did not respond to LaunchServices; terminating the UI process before retry', - opts.logWriter, - ); - await opts.executor({ - command: 'killall', - args: ['-9', 'Simulator'], - cwd: opts.cwd, - logPath: opts.logPath, - }); - } - opts.onEvent?.(`Simulator.app open attempt ${attempt} failed; retrying`); - await new Promise((resolve) => { - setTimeout(resolve, delayMs); - }); - } - } + await openBenchmarkSimulatorFrontend({ + simulatorId: opts.simulatorId, + configName: opts.configName, + cwd: opts.cwd, + logPath: opts.logPath, + executor: opts.executor, + appendLog: (message) => appendLifecycleLog(opts.logPath, message, opts.logWriter), + onEvent: opts.onEvent, + }); await waitForReadinessDelay({ logPath: opts.logPath, 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 da14ead72..08a27c7bd 100644 --- a/src/mcp/tools/simulator-management/__tests__/_keyboard_shortcut.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/_keyboard_shortcut.test.ts @@ -73,11 +73,10 @@ describe('sendKeyboardShortcut', () => { } }); - it('sends Cmd+K for software-keyboard when simulator is booted and window exists', async () => { + it('uses Device Hub to toggle the software keyboard when available', async () => { const { executor, calls } = makeFifoExecutor([ { success: true, output: BOOTED_JSON }, { success: true, output: '' }, - { success: true, output: 'OK' }, { success: true, output: '' }, ]); @@ -85,21 +84,18 @@ describe('sendKeyboardShortcut', () => { 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[1].command).toEqual(['open', 'devices:///manage/select?id=test-uuid-123']); 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'); + const keystrokeScript = calls[2].command.join(' '); + expect(keystrokeScript).toContain('com.apple.dt.Devices'); + expect(keystrokeScript).toContain('keystroke "k" using {command down}'); + expect(keystrokeScript).not.toContain('Toggle Software Keyboard'); }); - it('sends Cmd+Shift+K for connect-hardware-keyboard', async () => { + it('uses Device Hub to simulate a hardware keyboard connection', async () => { const { executor, calls } = makeFifoExecutor([ { success: true, output: BOOTED_JSON }, { success: true, output: '' }, - { success: true, output: 'OK' }, { success: true, output: '' }, ]); @@ -110,15 +106,15 @@ describe('sendKeyboardShortcut', () => { ); 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'); + const keystrokeScript = calls[2].command.join(' '); + expect(keystrokeScript).toContain('keystroke "k" using {command down, shift down}'); + expect(keystrokeScript).not.toContain('Simulate Hardware Keyboard'); }); it('escapes backslashes before embedding simulator names in the focus AppleScript', async () => { const { executor, calls } = makeFifoExecutor([ { success: true, output: ESCAPED_NAME_JSON }, + { success: false, error: 'Device Hub unavailable' }, { success: true, output: '' }, { success: true, output: 'OK' }, { success: true, output: '' }, @@ -127,12 +123,13 @@ describe('sendKeyboardShortcut', () => { const result = await sendKeyboardShortcut('escaped-uuid', 'software-keyboard', executor); expect(result.success).toBe(true); - expect(calls[2].command[2]).toContain('Test\\\\Device\\"'); + expect(calls[3].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: false, error: 'Device Hub unavailable' }, { success: true, output: '' }, { success: true, output: 'OK' }, { success: true, output: '' }, @@ -141,9 +138,9 @@ describe('sendKeyboardShortcut', () => { 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'); + expect(calls[3].command[2]).toContain('title is "iPhone 15"'); + expect(calls[3].command[2]).toContain('title starts with "iPhone 15 –"'); + expect(calls[3].command[2]).not.toContain('title contains'); }); it('errors when simulator UUID is not found', async () => { @@ -200,24 +197,28 @@ describe('sendKeyboardShortcut', () => { expect(calls).toHaveLength(1); }); - it('errors when `open -a Simulator` fails', async () => { + it('errors when neither simulator frontend can be opened', async () => { const { executor, calls } = makeFifoExecutor([ { success: true, output: BOOTED_JSON }, - { success: false, error: 'could not open' }, + { success: false, error: 'Device Hub unavailable' }, + { success: false, error: 'Simulator unavailable' }, ]); 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(result.error).toContain('simulator frontend'); + expect(result.error).toContain('Device Hub unavailable'); + expect(result.error).toContain('Simulator unavailable'); } - expect(calls).toHaveLength(2); + expect(calls).toHaveLength(3); }); it('errors and does not send keystroke when window lookup returns NO_WINDOW', async () => { const { executor, calls } = makeFifoExecutor([ { success: true, output: BOOTED_JSON }, + { success: false, error: 'Device Hub unavailable' }, { success: true, output: '' }, { success: true, output: 'NO_WINDOW' }, ]); @@ -230,7 +231,7 @@ describe('sendKeyboardShortcut', () => { expect(result.error).toContain('without a device window'); expect(result.error).toContain('retry the keyboard shortcut'); } - expect(calls).toHaveLength(3); + expect(calls).toHaveLength(4); }); it('errors when simctl list fails', async () => { @@ -244,11 +245,10 @@ describe('sendKeyboardShortcut', () => { } }); - it('errors when keystroke osascript fails', async () => { + it('errors when the Device Hub keyboard shortcut fails', async () => { const { executor } = makeFifoExecutor([ { success: true, output: BOOTED_JSON }, { success: true, output: '' }, - { success: true, output: 'OK' }, { success: false, error: '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 index 385d6b2ab..4c68edf85 100644 --- 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 @@ -53,7 +53,6 @@ describe('toggle_connect_hardware_keyboard tool', () => { const { executor } = fifo([ { success: true, output: BOOTED_JSON }, { success: true, output: '' }, - { success: true, output: 'OK' }, { success: true, output: '' }, ]); @@ -64,11 +63,10 @@ describe('toggle_connect_hardware_keyboard tool', () => { expect(result.isError).toBeFalsy(); }); - it('sends Cmd+Shift+K keystroke', async () => { + it('uses the Device Hub hardware keyboard shortcut', async () => { const { executor, commands } = fifo([ { success: true, output: BOOTED_JSON }, { success: true, output: '' }, - { success: true, output: 'OK' }, { success: true, output: '' }, ]); @@ -76,10 +74,9 @@ describe('toggle_connect_hardware_keyboard tool', () => { 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'); + const keyboardAction = commands[2].join(' '); + expect(keyboardAction).toContain('com.apple.dt.Devices'); + expect(keyboardAction).toContain('keystroke "k" using {command down, shift down}'); }); it('returns error when simulator not found', async () => { 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 index 42d8ded29..1d2b71e1c 100644 --- a/src/mcp/tools/simulator-management/__tests__/toggle_software_keyboard.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/toggle_software_keyboard.test.ts @@ -50,7 +50,6 @@ describe('toggle_software_keyboard tool', () => { const { executor } = fifo([ { success: true, output: BOOTED_JSON }, { success: true, output: '' }, - { success: true, output: 'OK' }, { success: true, output: '' }, ]); @@ -82,11 +81,10 @@ describe('toggle_software_keyboard tool', () => { expect(result.isError).toBe(true); }); - it('sends Cmd+K keystroke without shift modifier', async () => { + it('uses the Device Hub software keyboard shortcut', async () => { const { executor, commands } = fifo([ { success: true, output: BOOTED_JSON }, { success: true, output: '' }, - { success: true, output: 'OK' }, { success: true, output: '' }, ]); @@ -94,10 +92,9 @@ describe('toggle_software_keyboard tool', () => { 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'); + const keyboardAction = commands[2].join(' '); + expect(keyboardAction).toContain('com.apple.dt.Devices'); + expect(keyboardAction).toContain('keystroke "k" using {command down}'); }); it('returns error when executor throws', async () => { diff --git a/src/mcp/tools/simulator-management/_keyboard_shortcut.ts b/src/mcp/tools/simulator-management/_keyboard_shortcut.ts index 62ed9e7fd..8bec3ad5f 100644 --- a/src/mcp/tools/simulator-management/_keyboard_shortcut.ts +++ b/src/mcp/tools/simulator-management/_keyboard_shortcut.ts @@ -1,7 +1,7 @@ import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { log } from '../../../utils/logging/index.ts'; import { toErrorMessage } from '../../../utils/errors.ts'; -import { buildOpenSimulatorAppCommand, isHeadlessLaunchMode } from '../../../utils/focus-policy.ts'; +import { isHeadlessLaunchMode, openSimulatorFrontend } from '../../../utils/focus-policy.ts'; export type KeyboardShortcut = 'software-keyboard' | 'connect-hardware-keyboard'; @@ -57,6 +57,21 @@ function buildKeystrokeScript(shortcut: KeyboardShortcut): string { ].join('\n'); } +function buildDeviceHubKeystrokeScript(shortcut: KeyboardShortcut): string { + const modifiers = + shortcut === 'connect-hardware-keyboard' ? '{command down, shift down}' : '{command down}'; + return [ + 'tell application "System Events"', + ' set deviceHubProcess to first application process whose bundle identifier is "com.apple.dt.Devices"', + ' set frontmost of deviceHubProcess to true', + ' delay 0.5', + ' tell deviceHubProcess', + ` keystroke "k" using ${modifiers}`, + ' end tell', + 'end tell', + ].join('\n'); +} + export async function sendKeyboardShortcut( simulatorId: string, shortcut: KeyboardShortcut, @@ -105,24 +120,38 @@ export async function sendKeyboardShortcut( return { success: false, error: - 'Keyboard shortcuts require Simulator.app to be in the foreground, which is incompatible with XCODEBUILDMCP_HEADLESS_LAUNCH mode.', + 'Keyboard controls require a simulator frontend in the foreground, which is incompatible with XCODEBUILDMCP_HEADLESS_LAUNCH mode.', }; } - const openCommand = buildOpenSimulatorAppCommand(); - if (openCommand === null) { + const openResult = await openSimulatorFrontend(executor, { simulatorId }); + if (!openResult.success) { return { success: false, - error: - 'Keyboard shortcuts require Simulator.app to be in the foreground, which is incompatible with XCODEBUILDMCP_HEADLESS_LAUNCH mode.', + error: openResult.error, }; } - const openResult = await executor(openCommand, 'Open Simulator App', false); - if (!openResult.success) { + if (openResult.frontend === 'device-hub') { + const keystrokeResult = await executor( + ['osascript', '-e', buildDeviceHubKeystrokeScript(shortcut)], + 'Send Device Hub Keyboard Shortcut', + false, + ); + if (!keystrokeResult.success) { + return { + success: false, + error: `Failed to send Device Hub keyboard shortcut: ${keystrokeResult.error ?? 'unknown error'}`, + }; + } + return { success: true }; + } + + if (openResult.frontend === null) { return { success: false, - error: `Failed to open Simulator app: ${openResult.error ?? 'unknown error'}`, + error: + 'Keyboard controls require a simulator frontend in the foreground, which is incompatible with XCODEBUILDMCP_HEADLESS_LAUNCH mode.', }; } diff --git a/src/mcp/tools/simulator/__tests__/open_sim.test.ts b/src/mcp/tools/simulator/__tests__/open_sim.test.ts index 68d6a40fd..8e5397c0f 100644 --- a/src/mcp/tools/simulator/__tests__/open_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/open_sim.test.ts @@ -119,8 +119,8 @@ describe('open_sim tool', () => { expect(calls).toHaveLength(1); expect(calls[0]).toEqual({ - command: ['open', '-a', 'Simulator'], - description: 'Open Simulator', + command: ['open', '-a', 'DeviceHub'], + description: 'Open Device Hub', hideOutput: false, opts: undefined, }); diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index 4ef01b3f0..f53a03b25 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -49,7 +49,7 @@ import { } from '../../../utils/xcodebuild-domain-results.ts'; import { resolveEffectiveDerivedDataPath } from '../../../utils/derived-data-path.ts'; import { createBuildInvocationFragment } from '../../../utils/xcodebuild-pipeline.ts'; -import { buildOpenSimulatorAppCommand } from '../../../utils/focus-policy.ts'; +import { openSimulatorFrontend } from '../../../utils/focus-policy.ts'; const baseOptions = { scheme: z.string().describe('The scheme to use (Required)'), @@ -375,19 +375,16 @@ export function createBuildRunSimExecutor( status: 'succeeded', }); - const openSimulatorCommand = buildOpenSimulatorAppCommand(); - if (openSimulatorCommand !== null) { - try { - const openResult = await executor(openSimulatorCommand, 'Open Simulator App'); - if (!openResult.success) { - throw new Error(openResult.error ?? 'Failed to open Simulator app'); - } - } catch (error) { - log( - 'warn', - `Warning: Could not open Simulator app: ${error instanceof Error ? error.message : String(error)}`, - ); + try { + const openResult = await openSimulatorFrontend(executor, { simulatorId }); + if (!openResult.success) { + throw new Error(openResult.error); } + } catch (error) { + log( + 'warn', + `Warning: Could not open simulator frontend: ${error instanceof Error ? error.message : String(error)}`, + ); } ctx.emitFragment({ diff --git a/src/mcp/tools/simulator/open_sim.ts b/src/mcp/tools/simulator/open_sim.ts index b705fbe51..2acf0ea8c 100644 --- a/src/mcp/tools/simulator/open_sim.ts +++ b/src/mcp/tools/simulator/open_sim.ts @@ -8,7 +8,7 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; import { toErrorMessage } from '../../../utils/errors.ts'; import { createBasicDiagnostics } from '../../../utils/diagnostics.ts'; -import { buildOpenSimulatorAppCommand } from '../../../utils/focus-policy.ts'; +import { openSimulatorFrontend } from '../../../utils/focus-policy.ts'; const openSimSchema = z.object({}); @@ -49,19 +49,13 @@ export function createOpenSimExecutor( ): NonStreamingExecutor { return async (_params) => { try { - const command = buildOpenSimulatorAppCommand(); - if (command === null) { - return createOpenSimResult({ didError: false }); - } - - const result = await executor(command, 'Open Simulator', false); + const result = await openSimulatorFrontend(executor); if (!result.success) { - const diagnosticMessage = result.error ?? 'Unknown error'; return createOpenSimResult({ didError: true, error: 'Open simulator operation failed.', - diagnosticMessage, + diagnosticMessage: result.error, }); } diff --git a/src/server/server.ts b/src/server/server.ts index 1de931b7d..d07426ffa 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -53,7 +53,7 @@ Simulator run flow: - If session_show_defaults confirms project/workspace + scheme + simulator are set, call build_run_sim immediately (often with empty arguments). - Use discover_projs only when session_show_defaults shows project/workspace is missing or wrong. - Never call discover_projs speculatively or in parallel with session_show_defaults. -- Do not call boot_sim or open_sim as prerequisites for build_run_sim; build_run_sim boots and opens Simulator as needed.`, +- Do not call boot_sim or open_sim as prerequisites for build_run_sim; build_run_sim boots and opens the simulator frontend as needed.`, capabilities: { tools: { listChanged: true, diff --git a/src/utils/__tests__/focus-policy.test.ts b/src/utils/__tests__/focus-policy.test.ts index 7134de9fc..ce13e95cc 100644 --- a/src/utils/__tests__/focus-policy.test.ts +++ b/src/utils/__tests__/focus-policy.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { buildOpenAppCommand, + buildOpenSimulatorFrontendCommands, buildOpenSimulatorAppCommand, isHeadlessLaunchMode, + openSimulatorFrontend, } from '../focus-policy.ts'; +import { createMockCommandResponse } from '../../test-utils/mock-executors.ts'; const ENV_VAR = 'XCODEBUILDMCP_HEADLESS_LAUNCH'; @@ -102,4 +105,85 @@ describe('focus-policy', () => { expect(buildOpenSimulatorAppCommand()).toBeNull(); }); }); + + describe('buildOpenSimulatorFrontendCommands', () => { + it('prefers Device Hub and falls back to Simulator.app', () => { + expect(buildOpenSimulatorFrontendCommands()).toEqual([ + { frontend: 'device-hub', command: ['open', '-a', 'DeviceHub'] }, + { frontend: 'simulator', command: ['open', '-a', 'Simulator'] }, + ]); + }); + + it('targets the requested UDID in both frontends', () => { + expect(buildOpenSimulatorFrontendCommands({ simulatorId: 'SIM 123' })).toEqual([ + { + frontend: 'device-hub', + command: ['open', 'devices:///manage/select?id=SIM%20123'], + }, + { + frontend: 'simulator', + command: ['open', '-a', 'Simulator', '--args', '-CurrentDeviceUDID', 'SIM 123'], + }, + ]); + }); + + it('returns null in headless mode', () => { + process.env[ENV_VAR] = '1'; + expect(buildOpenSimulatorFrontendCommands()).toBeNull(); + }); + }); + + describe('openSimulatorFrontend', () => { + it('uses Device Hub when it is available', async () => { + const commands: string[][] = []; + const result = await openSimulatorFrontend(async (command) => { + commands.push(command); + return createMockCommandResponse({ success: true }); + }); + + expect(result).toEqual({ success: true, frontend: 'device-hub' }); + expect(commands).toEqual([['open', '-a', 'DeviceHub']]); + }); + + it('falls back to Simulator.app when Device Hub is unavailable', async () => { + const commands: string[][] = []; + const result = await openSimulatorFrontend(async (command) => { + commands.push(command); + return createMockCommandResponse({ + success: command.includes('Simulator'), + error: command.includes('Simulator') ? undefined : 'Device Hub not found', + }); + }); + + expect(result).toEqual({ success: true, frontend: 'simulator' }); + expect(commands).toEqual([ + ['open', '-a', 'DeviceHub'], + ['open', '-a', 'Simulator'], + ]); + }); + + it('reports both launch failures', async () => { + const result = await openSimulatorFrontend(async (command) => + createMockCommandResponse({ success: false, error: `${command.at(-1)} not found` }), + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('Device Hub: DeviceHub not found'); + expect(result.error).toContain('Simulator.app: Simulator not found'); + } + }); + + it('skips both frontends in headless mode', async () => { + process.env[ENV_VAR] = '1'; + let called = false; + const result = await openSimulatorFrontend(async () => { + called = true; + return createMockCommandResponse({ success: true }); + }); + + expect(result).toEqual({ success: true, frontend: null }); + expect(called).toBe(false); + }); + }); }); diff --git a/src/utils/focus-policy.ts b/src/utils/focus-policy.ts index 299a09edf..2a5af5341 100644 --- a/src/utils/focus-policy.ts +++ b/src/utils/focus-policy.ts @@ -1,3 +1,5 @@ +import type { CommandExecutor } from './execution/index.ts'; + /** * Headless launch policy. * @@ -5,8 +7,8 @@ * otherwise steal window focus on macOS are suppressed: * * - macOS app launches use `open -g` (run in background, no foreground steal). - * - Simulator.app launches are skipped entirely; `simctl boot` alone keeps - * the simulator runtime available for `simctl` UI automation without + * - Simulator frontend launches are skipped entirely; `simctl boot` alone + * keeps the simulator runtime available for `simctl` UI automation without * surfacing a window. * * This is intended for snapshot/smoke tests and other CI-style runs. It is @@ -53,3 +55,68 @@ export function buildOpenSimulatorAppCommand(opts?: { simulatorId?: string }): s } return command; } + +export type SimulatorFrontend = 'device-hub' | 'simulator'; + +export interface SimulatorFrontendCommand { + frontend: SimulatorFrontend; + command: string[]; +} + +/** + * Build launch candidates in preference order. Device Hub is the primary + * frontend on Xcode 27 and can display simulators from legacy runtimes. + */ +export function buildOpenSimulatorFrontendCommands(opts?: { + simulatorId?: string; +}): SimulatorFrontendCommand[] | null { + if (isHeadlessLaunchMode()) { + return null; + } + + const encodedSimulatorId = opts?.simulatorId + ? encodeURIComponent(opts.simulatorId).replace(/'/g, '%27') + : undefined; + const deviceHubCommand = encodedSimulatorId + ? ['open', `devices:///manage/select?id=${encodedSimulatorId}`] + : ['open', '-a', 'DeviceHub']; + const simulatorCommand = buildOpenSimulatorAppCommand(opts); + + return [ + { frontend: 'device-hub', command: deviceHubCommand }, + { frontend: 'simulator', command: simulatorCommand ?? ['open', '-a', 'Simulator'] }, + ]; +} + +export type OpenSimulatorFrontendResult = + | { success: true; frontend: SimulatorFrontend | null } + | { success: false; error: string }; + +/** + * Open Device Hub when available, falling back to Simulator.app for hosts that + * do not have Device Hub installed. + */ +export async function openSimulatorFrontend( + executor: CommandExecutor, + opts?: { simulatorId?: string }, +): Promise { + const candidates = buildOpenSimulatorFrontendCommands(opts); + if (candidates === null) { + return { success: true, frontend: null }; + } + + const errors: string[] = []; + for (const candidate of candidates) { + const label = candidate.frontend === 'device-hub' ? 'Device Hub' : 'Simulator.app'; + const result = await executor(candidate.command, `Open ${label}`, false); + if (result.success) { + return { success: true, frontend: candidate.frontend }; + } + errors.push(`${label}: ${result.error ?? 'unknown error'}`); + } + + return { + success: false, + error: `Failed to open a simulator frontend. ${errors.join('; ')}`, + }; +}