Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .axe-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.7.1
1.8.0
Comment thread
cameroncooke marked this conversation as resolved.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
2 changes: 1 addition & 1 deletion manifests/tools/build_run_sim.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
cameroncooke marked this conversation as resolved.
outputSchema:
schema: xcodebuildmcp.output.build-run-result
version: "2"
Expand Down
2 changes: 1 addition & 1 deletion manifests/tools/open_sim.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion manifests/tools/toggle_connect_hardware_keyboard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion manifests/tools/toggle_software_keyboard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 5 additions & 5 deletions src/benchmarks/claude-ui/__tests__/preflight-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});
Expand All @@ -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",
]);
});

Expand All @@ -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'];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);

Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 8 additions & 4 deletions src/benchmarks/claude-ui/preflight-commands.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -34,10 +34,14 @@
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],
);
Expand All @@ -60,13 +64,13 @@
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let timedOut = false;
let timeoutTimer: NodeJS.Timeout | undefined;

Check warning on line 67 in src/benchmarks/claude-ui/preflight-commands.ts

View workflow job for this annotation

GitHub Actions / build-and-test (24.x)

'timeoutTimer' is never reassigned. Use 'const' instead
let forceKillTimer: NodeJS.Timeout | undefined;
const clearTimers = () => {

Check warning on line 69 in src/benchmarks/claude-ui/preflight-commands.ts

View workflow job for this annotation

GitHub Actions / build-and-test (24.x)

Missing return type on function
if (timeoutTimer) clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
};
const signalChild = (signal: NodeJS.Signals) => {

Check warning on line 73 in src/benchmarks/claude-ui/preflight-commands.ts

View workflow job for this annotation

GitHub Actions / build-and-test (24.x)

Missing return type on function
if (child.exitCode !== null || child.killed || child.pid === undefined) return;
try {
process.kill(-child.pid, signal);
Expand Down
89 changes: 89 additions & 0 deletions src/benchmarks/claude-ui/simulator-frontend.ts
Original file line number Diff line number Diff line change
@@ -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<FrontendCommandResult>;
appendLog: (message: string) => Promise<void>;
onEvent?: (message: string) => void;
}): Promise<void> {
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<void>((resolve) => {
setTimeout(resolve, delayMs);
});
}
}
64 changes: 10 additions & 54 deletions src/benchmarks/claude-ui/simulator-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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<BenchmarkConfig['sessionDefaults']>;

Expand Down Expand Up @@ -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<void>((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,
Expand Down
Loading
Loading