From 2fd345f84e5b07598375ae3b62f39761730c880c Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 10 Jun 2026 17:29:40 +0100 Subject: [PATCH 1/2] feat: Support DeviceHub for Xcode 27+ Xcode 27 replaces the iOS Simulator.app with DeviceHub.app and relocates it from /Contents/Developer/Applications to /Contents/Applications, which made `run-ios` fail when launching the simulator UI. Inspired by https://github.com/expo/expo/pull/46757. --- .../__tests__/runOnSimulator.test.ts | 109 ++++++++++++++++++ .../src/commands/runCommand/runOnSimulator.ts | 36 +++++- 2 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 packages/cli-platform-apple/src/commands/runCommand/__tests__/runOnSimulator.test.ts diff --git a/packages/cli-platform-apple/src/commands/runCommand/__tests__/runOnSimulator.test.ts b/packages/cli-platform-apple/src/commands/runCommand/__tests__/runOnSimulator.test.ts new file mode 100644 index 000000000..95ebad757 --- /dev/null +++ b/packages/cli-platform-apple/src/commands/runCommand/__tests__/runOnSimulator.test.ts @@ -0,0 +1,109 @@ +import child_process from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import {IOSProjectInfo} from '@react-native-community/cli-types'; +import {Device} from '../../../types'; +import {runOnSimulator} from '../runOnSimulator'; +import {buildProject} from '../../buildCommand/buildProject'; +import installApp from '../installApp'; +import {FlagsT} from '../createRun'; + +jest.mock('child_process'); +jest.mock('fs', () => ({existsSync: jest.fn()})); +jest.mock('../../buildCommand/buildProject'); +jest.mock('../installApp'); + +const xcodeProject: IOSProjectInfo = { + name: 'TestApp.xcworkspace', + path: '/path/to/TestApp.xcworkspace', + isWorkspace: true, +}; + +const simulator: Device = { + name: 'iPhone 15', + udid: 'AAAA-BBBB-CCCC', + state: 'Booted', + type: 'simulator', +}; + +const args = {} as FlagsT; +const developerDir = '/Applications/Xcode.app/Contents/Developer'; + +beforeEach(() => { + jest.clearAllMocks(); + (buildProject as jest.Mock).mockResolvedValue(''); + (installApp as jest.Mock).mockResolvedValue(undefined); + (child_process.execFileSync as jest.Mock).mockReturnValue( + `${developerDir}\n`, + ); +}); + +test('opens Simulator.app with the device UDID when it exists', async () => { + (fs.existsSync as jest.Mock).mockImplementation((target) => + String(target).endsWith('Simulator.app'), + ); + + await runOnSimulator( + xcodeProject, + 'ios', + 'Debug', + 'TestApp', + args, + simulator, + ); + + expect(child_process.execFileSync).toHaveBeenCalledWith('open', [ + `${developerDir}/Applications/Simulator.app`, + '--args', + '-CurrentDeviceUDID', + simulator.udid, + ]); +}); + +test('falls back to DeviceHub.app without the UDID when Simulator.app is absent', async () => { + (fs.existsSync as jest.Mock).mockImplementation((target) => + String(target).endsWith('DeviceHub.app'), + ); + + await runOnSimulator( + xcodeProject, + 'ios', + 'Debug', + 'TestApp', + args, + simulator, + ); + + const deviceHubPath = path.join( + developerDir, + '..', + 'Applications', + 'DeviceHub.app', + ); + expect(child_process.execFileSync).toHaveBeenCalledWith('open', [ + deviceHubPath, + ]); + // DeviceHub cannot focus a specific device, so the UDID must not be passed. + expect(child_process.execFileSync).not.toHaveBeenCalledWith( + 'open', + expect.arrayContaining(['-CurrentDeviceUDID']), + ); +}); + +test('does not boot the simulator when it is already booted', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + + await runOnSimulator( + xcodeProject, + 'ios', + 'Debug', + 'TestApp', + args, + simulator, + ); + + expect(child_process.spawnSync).not.toHaveBeenCalledWith( + 'xcrun', + expect.arrayContaining(['boot']), + ); +}); diff --git a/packages/cli-platform-apple/src/commands/runCommand/runOnSimulator.ts b/packages/cli-platform-apple/src/commands/runCommand/runOnSimulator.ts index fd0484108..9e6840684 100644 --- a/packages/cli-platform-apple/src/commands/runCommand/runOnSimulator.ts +++ b/packages/cli-platform-apple/src/commands/runCommand/runOnSimulator.ts @@ -1,4 +1,6 @@ import child_process from 'child_process'; +import fs from 'fs'; +import path from 'path'; import {IOSProjectInfo} from '@react-native-community/cli-types'; import {logger} from '@react-native-community/cli-tools'; import {ApplePlatform, Device} from '../../types'; @@ -32,12 +34,34 @@ export async function runOnSimulator( .execFileSync('xcode-select', ['-p'], {encoding: 'utf8'}) .trim(); - child_process.execFileSync('open', [ - `${activeDeveloperDir}/Applications/Simulator.app`, - '--args', - '-CurrentDeviceUDID', - simulator.udid, - ]); + // Xcode 27 replaces Simulator.app with DeviceHub.app and relocates it from + // /Contents/Developer/Applications to /Contents/Applications. + // Prefer Simulator.app while it exists (stable Xcode); fall back to DeviceHub + // for Xcode 27+. See https://developer.apple.com/documentation/xcode/device-hub + const simulatorApp = path.join( + activeDeveloperDir, + 'Applications', + 'Simulator.app', + ); + const deviceHubApp = path.join( + activeDeveloperDir, + '..', + 'Applications', + 'DeviceHub.app', + ); + + if (fs.existsSync(simulatorApp)) { + child_process.execFileSync('open', [ + simulatorApp, + '--args', + '-CurrentDeviceUDID', + simulator.udid, + ]); + } else if (fs.existsSync(deviceHubApp)) { + // DeviceHub gives us no way to focus a specific device, so we open it + // without the -CurrentDeviceUDID argument. + child_process.execFileSync('open', [deviceHubApp]); + } if (simulator.state !== 'Booted') { bootSimulator(simulator); From bbdedafdfc4f6dc068fb893096479303896fc273 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Thu, 11 Jun 2026 15:46:50 +0100 Subject: [PATCH 2/2] Update to use devices:// URL scheme --- .../runCommand/__tests__/runOnSimulator.test.ts | 14 ++++---------- .../src/commands/runCommand/runOnSimulator.ts | 6 +++--- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/cli-platform-apple/src/commands/runCommand/__tests__/runOnSimulator.test.ts b/packages/cli-platform-apple/src/commands/runCommand/__tests__/runOnSimulator.test.ts index 95ebad757..596de693b 100644 --- a/packages/cli-platform-apple/src/commands/runCommand/__tests__/runOnSimulator.test.ts +++ b/packages/cli-platform-apple/src/commands/runCommand/__tests__/runOnSimulator.test.ts @@ -1,6 +1,5 @@ import child_process from 'child_process'; import fs from 'fs'; -import path from 'path'; import {IOSProjectInfo} from '@react-native-community/cli-types'; import {Device} from '../../../types'; import {runOnSimulator} from '../runOnSimulator'; @@ -60,7 +59,7 @@ test('opens Simulator.app with the device UDID when it exists', async () => { ]); }); -test('falls back to DeviceHub.app without the UDID when Simulator.app is absent', async () => { +test('falls back to DeviceHub via devices:// deep link when Simulator.app is absent', async () => { (fs.existsSync as jest.Mock).mockImplementation((target) => String(target).endsWith('DeviceHub.app'), ); @@ -74,16 +73,11 @@ test('falls back to DeviceHub.app without the UDID when Simulator.app is absent' simulator, ); - const deviceHubPath = path.join( - developerDir, - '..', - 'Applications', - 'DeviceHub.app', - ); + // DeviceHub registers the `devices://` URL scheme, which focuses the device by UDID. expect(child_process.execFileSync).toHaveBeenCalledWith('open', [ - deviceHubPath, + `devices://device/open?id=${simulator.udid}`, ]); - // DeviceHub cannot focus a specific device, so the UDID must not be passed. + // The deep link replaces the Simulator.app `-CurrentDeviceUDID` argument. expect(child_process.execFileSync).not.toHaveBeenCalledWith( 'open', expect.arrayContaining(['-CurrentDeviceUDID']), diff --git a/packages/cli-platform-apple/src/commands/runCommand/runOnSimulator.ts b/packages/cli-platform-apple/src/commands/runCommand/runOnSimulator.ts index 9e6840684..093ed6a4c 100644 --- a/packages/cli-platform-apple/src/commands/runCommand/runOnSimulator.ts +++ b/packages/cli-platform-apple/src/commands/runCommand/runOnSimulator.ts @@ -58,9 +58,9 @@ export async function runOnSimulator( simulator.udid, ]); } else if (fs.existsSync(deviceHubApp)) { - // DeviceHub gives us no way to focus a specific device, so we open it - // without the -CurrentDeviceUDID argument. - child_process.execFileSync('open', [deviceHubApp]); + child_process.execFileSync('open', [ + `devices://device/open?id=${simulator.udid}`, + ]); } if (simulator.state !== 'Booted') {