Skip to content
Merged
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ xcuserdata/
.claude/
**/.claude/settings.local.json

# Worktrees
.worktrees/

# incremental builds
Makefile
buildServer.json
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@

### 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)).

### 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

Expand Down
15 changes: 15 additions & 0 deletions manifests/tools/toggle_connect_hardware_keyboard.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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.
outputSchema:
schema: xcodebuildmcp.output.simulator-action-result
version: "1"
annotations:
title: Toggle Connect Hardware Keyboard
readOnlyHint: false
destructiveHint: false
openWorldHint: false
idempotentHint: false
15 changes: 15 additions & 0 deletions manifests/tools/toggle_software_keyboard.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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.
outputSchema:
schema: xcodebuildmcp.output.simulator-action-result
version: "1"
annotations:
title: Toggle Software Keyboard
readOnlyHint: false
destructiveHint: false
openWorldHint: false
idempotentHint: false
2 changes: 2 additions & 0 deletions manifests/workflows/simulator-management.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ tools:
- reset_sim_location
- set_sim_appearance
- sim_statusbar
- toggle_software_keyboard
- toggle_connect_hardware_keyboard
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
]
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { describe, it, expect } from 'vitest';
import {
createMockCommandResponse,
type CommandExecutor,
} from '../../../../test-utils/mock-executors.ts';
import { sendKeyboardShortcut } from '../_keyboard_shortcut.ts';

const BOOTED_JSON = JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [
{ udid: 'test-uuid-123', name: 'iPhone 15 Pro', state: 'Booted' },
],
},
});

const SHUTDOWN_JSON = JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [
{ udid: 'test-uuid-123', name: 'iPhone 15 Pro', state: 'Shutdown' },
],
},
});

const EMPTY_JSON = JSON.stringify({ devices: {} });
const ESCAPED_NAME_JSON = JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [
{ udid: 'escaped-uuid', name: 'Test\\Device"', state: 'Booted' },
],
},
});
const PREFIX_NAME_JSON = JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [
{ udid: 'prefix-uuid', name: 'iPhone 15', state: 'Booted' },
],
},
});

type Call = { command: string[] };

function makeFifoExecutor(
responses: Array<{ success: boolean; output?: string; error?: string }>,
): { executor: CommandExecutor; calls: Call[] } {
const calls: Call[] = [];
let i = 0;
const executor: CommandExecutor = async (command) => {
calls.push({ command });
const r = responses[i] ?? { success: true, output: '' };
i += 1;
return createMockCommandResponse({
success: r.success,
output: r.output ?? '',
error: r.error,
});
};
return { executor, calls };
}

describe('sendKeyboardShortcut', () => {
it('sends Cmd+K for software-keyboard when simulator is booted and window exists', async () => {
const { executor, calls } = makeFifoExecutor([
{ success: true, output: BOOTED_JSON },
{ success: true, output: '' },
{ success: true, output: 'OK' },
{ success: true, output: '' },
]);

const result = await sendKeyboardShortcut('test-uuid-123', 'software-keyboard', executor);

expect(result.success).toBe(true);
expect(calls[0].command).toEqual(['xcrun', 'simctl', 'list', 'devices', '--json']);
expect(calls[1].command).toEqual(['open', '-a', 'Simulator']);
expect(calls[2].command[0]).toBe('osascript');
expect(calls[2].command.join(' ')).toContain('iPhone 15 Pro');
expect(calls[3].command[0]).toBe('osascript');
const keystrokeScript = calls[3].command.join(' ');
expect(keystrokeScript).toContain('keystroke "k"');
expect(keystrokeScript).toContain('command down');
expect(keystrokeScript).not.toContain('shift down');
});

it('sends Cmd+Shift+K for connect-hardware-keyboard', async () => {
const { executor, calls } = makeFifoExecutor([
{ success: true, output: BOOTED_JSON },
{ success: true, output: '' },
{ success: true, output: 'OK' },
{ success: true, output: '' },
]);

const result = await sendKeyboardShortcut(
'test-uuid-123',
'connect-hardware-keyboard',
executor,
);

expect(result.success).toBe(true);
const keystrokeScript = calls[3].command.join(' ');
expect(keystrokeScript).toContain('keystroke "k"');
expect(keystrokeScript).toContain('command down');
expect(keystrokeScript).toContain('shift down');
});

it('escapes backslashes before embedding simulator names in the focus AppleScript', async () => {
const { executor, calls } = makeFifoExecutor([
{ success: true, output: ESCAPED_NAME_JSON },
{ success: true, output: '' },
{ success: true, output: 'OK' },
{ success: true, output: '' },
]);

const result = await sendKeyboardShortcut('escaped-uuid', 'software-keyboard', executor);

expect(result.success).toBe(true);
expect(calls[2].command[2]).toContain('Test\\\\Device\\"');
});

it('matches the simulator window by exact title or runtime suffix instead of substring contains', async () => {
const { executor, calls } = makeFifoExecutor([
{ success: true, output: PREFIX_NAME_JSON },
{ success: true, output: '' },
{ success: true, output: 'OK' },
{ success: true, output: '' },
]);

const result = await sendKeyboardShortcut('prefix-uuid', 'software-keyboard', executor);

expect(result.success).toBe(true);
expect(calls[2].command[2]).toContain('title is "iPhone 15"');
expect(calls[2].command[2]).toContain('title starts with "iPhone 15 –"');
expect(calls[2].command[2]).not.toContain('title contains');
});

it('errors when simulator UUID is not found', async () => {
const { executor, calls } = makeFifoExecutor([{ success: true, output: EMPTY_JSON }]);

const result = await sendKeyboardShortcut('missing-uuid', 'software-keyboard', executor);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('missing-uuid');
expect(result.error).toContain('not found');
}
expect(calls).toHaveLength(1);
});

it('errors when simulator is not booted', async () => {
const { executor, calls } = makeFifoExecutor([{ success: true, output: SHUTDOWN_JSON }]);

const result = await sendKeyboardShortcut('test-uuid-123', 'software-keyboard', executor);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('not booted');
}
expect(calls).toHaveLength(1);
});

it('errors when `open -a Simulator` fails', async () => {
const { executor, calls } = makeFifoExecutor([
{ success: true, output: BOOTED_JSON },
{ success: false, error: 'could not open' },
]);

const result = await sendKeyboardShortcut('test-uuid-123', 'software-keyboard', executor);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('Simulator app');
}
expect(calls).toHaveLength(2);
});

it('errors and does not send keystroke when window lookup returns NO_WINDOW', async () => {
const { executor, calls } = makeFifoExecutor([
{ success: true, output: BOOTED_JSON },
{ success: true, output: '' },
{ success: true, output: 'NO_WINDOW' },
]);

const result = await sendKeyboardShortcut('test-uuid-123', 'software-keyboard', executor);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('iPhone 15 Pro');
}
expect(calls).toHaveLength(3);
});

it('errors when simctl list fails', async () => {
const { executor } = makeFifoExecutor([{ success: false, error: 'simctl blew up' }]);

const result = await sendKeyboardShortcut('test-uuid-123', 'software-keyboard', executor);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('simctl blew up');
}
});

it('errors when keystroke osascript fails', async () => {
const { executor } = makeFifoExecutor([
{ success: true, output: BOOTED_JSON },
{ success: true, output: '' },
{ success: true, output: 'OK' },
{ success: false, error: 'accessibility denied' },
]);

const result = await sendKeyboardShortcut('test-uuid-123', 'software-keyboard', executor);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('accessibility denied');
}
});
});
Loading
Loading