diff --git a/packages/@expo/cli/CHANGELOG.md b/packages/@expo/cli/CHANGELOG.md index 25fbda691ffd8..049622b469231 100644 --- a/packages/@expo/cli/CHANGELOG.md +++ b/packages/@expo/cli/CHANGELOG.md @@ -6,6 +6,8 @@ ### 🎉 New features +- Support building for macOS devices and visionOS simulators with `npx expo run:ios -d`. ([#28430](https://github.com/expo/expo/pull/28430) by [@EvanBacon](https://github.com/EvanBacon)) + ### 🐛 Bug fixes - Fix issue with installing OTA on iOS devices. ([#28406](https://github.com/expo/expo/pull/28406) by [@EvanBacon](https://github.com/EvanBacon)) diff --git a/packages/@expo/cli/src/run/ios/XcodeBuild.types.ts b/packages/@expo/cli/src/run/ios/XcodeBuild.types.ts index 5d8c26d660863..063b5d768234b 100644 --- a/packages/@expo/cli/src/run/ios/XcodeBuild.types.ts +++ b/packages/@expo/cli/src/run/ios/XcodeBuild.types.ts @@ -1,3 +1,4 @@ +import { OSType } from '../../start/platforms/ios/simctl'; import { BundlerProps } from '../resolveBundlerProps'; export type XcodeConfiguration = 'Debug' | 'Release'; @@ -30,7 +31,7 @@ export type BuildProps = { /** Is the target a simulator. */ isSimulator: boolean; xcodeProject: ProjectInfo; - device: { name: string; udid: string }; + device: { name: string; udid: string; osType: OSType }; configuration: XcodeConfiguration; /** Disable the initial bundling from the native script. */ shouldSkipInitialBundling: boolean; diff --git a/packages/@expo/cli/src/run/ios/appleDevice/AppleDevice.ts b/packages/@expo/cli/src/run/ios/appleDevice/AppleDevice.ts index 701dd75a5337f..a32e635aa28b0 100644 --- a/packages/@expo/cli/src/run/ios/appleDevice/AppleDevice.ts +++ b/packages/@expo/cli/src/run/ios/appleDevice/AppleDevice.ts @@ -11,6 +11,7 @@ import { Log } from '../../../log'; import { XcodeDeveloperDiskImagePrerequisite } from '../../../start/doctor/apple/XcodeDeveloperDiskImagePrerequisite'; import * as devicectl from '../../../start/platforms/ios/devicectl'; import { launchAppWithDeviceCtl } from '../../../start/platforms/ios/devicectl'; +import { OSType } from '../../../start/platforms/ios/simctl'; import { uniqBy } from '../../../utils/array'; import { delayAsync } from '../../../utils/delay'; import { CommandError } from '../../../utils/errors'; @@ -33,6 +34,8 @@ export interface ConnectedDevice { connectionType: 'USB' | 'Network'; /** @example `15.4.1` */ osVersion: string; + + osType: OSType; } async function getConnectedDevicesUsingNativeToolsAsync(): Promise { @@ -50,11 +53,35 @@ async function getConnectedDevicesUsingNativeToolsAsync(): Promise { const devices = await Promise.all([ @@ -94,6 +121,7 @@ async function getConnectedDevicesUsingCustomToolingAsync(): Promise { + jest.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: + '\n\n\tAvailable destinations for the "apr23" scheme:\n\t\t{ platform:macOS, arch:arm64, variant:Designed for [iPad,iPhone], id:00006021-000C08810CF0C01E, name:My Mac }\n\t\t{ platform:visionOS, arch:arm64, variant:Designed for [iPad,iPhone], id:00008112-001A20EC1E78A01E, name:Apple Vision Pro }\n\t\t{ platform:iOS, arch:arm64, id:00008120-001638590230201E, name:Evan\'s phone }\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }\n\t\t{ platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }\n\t\t{ platform:visionOS Simulator, variant:Designed for [iPad,iPhone], id:1D9DBD6E-4C07-491A-A991-E05789006B21, OS:1.1, name:Apple Vision Pro }\n\t\t{ platform:iOS Simulator, id:2D411F42-76CF-4CAE-808E-6A48742FEA7A, OS:17.0.1, name:iPad (10th generation) }\n\t\t{ platform:iOS Simulator, id:A100A197-272D-4B06-8F90-ADFF1E6A4693, OS:17.2, name:iPad (10th generation) }\n\t\t{ platform:iOS Simulator, id:C3D864B2-7A49-45D1-A0D5-43224410E049, OS:17.4, name:iPad (10th generation) }\n\t\t{ platform:iOS Simulator, id:3FF83215-94B3-4D92-B738-9D609A272498, OS:17.0.1, name:iPad Air (5th generation) }\n\t\t{ platform:iOS Simulator, id:0222C6EC-1201-40F3-9A46-7437B7BB75FC, OS:17.2, name:iPad Air (5th generation) }\n\t\t{ platform:iOS Simulator, id:DF32B328-FBF8-4AFF-ACB5-051FDA3E87D8, OS:17.4, name:iPad Air (5th generation) }\n\t\t{ platform:iOS Simulator, id:FEC21FFF-197B-4AC9-8DA3-93E1B114C1A5, OS:17.0.1, name:iPad Pro (11-inch) (4th generation) }\n\t\t{ platform:iOS Simulator, id:6833E9A7-CB53-4680-838C-4C2AAE6B50FD, OS:17.2, name:iPad Pro (11-inch) (4th generation) }\n\t\t{ platform:iOS Simulator, id:BA970B35-4BBE-41D2-80A9-318EEAE5BCA2, OS:17.4, name:iPad Pro (11-inch) (4th generation) }\n\t\t{ platform:iOS Simulator, id:211D918A-9DA3-4A9F-BA65-A1B03FD495B3, OS:17.0.1, name:iPad Pro (12.9-inch) (6th generation) }\n\t\t{ platform:iOS Simulator, id:5103AD54-1E7A-447C-ABEB-DDF1F0A4DC81, OS:17.2, name:iPad Pro (12.9-inch) (6th generation) }\n\t\t{ platform:iOS Simulator, id:A4E14843-44F5-40F5-9335-1F256ECFC6F3, OS:17.4, name:iPad Pro (12.9-inch) (6th generation) }\n\t\t{ platform:iOS Simulator, id:2BE21987-D78C-4C56-97F3-16E9FFC0A056, OS:17.0.1, name:iPad mini (6th generation) }\n\t\t{ platform:iOS Simulator, id:CD2254A7-C6AA-43D5-A7B4-C50AE7811CC9, OS:17.2, name:iPad mini (6th generation) }\n\t\t{ platform:iOS Simulator, id:6F25787E-69BA-47BB-8F97-781A0FB011E6, OS:17.4, name:iPad mini (6th generation) }\n\t\t{ platform:iOS Simulator, id:995EC62D-F49E-4DDC-8497-020514AB9D96, OS:17.0.1, name:iPhone 14 }\n\t\t{ platform:iOS Simulator, id:DAC5FD64-4DB3-457D-82BA-3CF5F239757A, OS:17.0.1, name:iPhone 14 Plus }\n\t\t{ platform:iOS Simulator, id:99CBBCFB-309E-42DB-A9F1-5431C1C26257, OS:17.0.1, name:iPhone 14 Pro }\n\t\t{ platform:iOS Simulator, id:7EFB5EB9-D4C5-41DB-8C28-315C19D25B55, OS:17.0.1, name:iPhone 14 Pro Max }\n\t\t{ platform:iOS Simulator, id:B668BBCA-BD25-411E-B4DE-B6CEE1D1EBCC, OS:17.0.1, name:iPhone 15 }\n\t\t{ platform:iOS Simulator, id:8F543F95-68E8-446E-AB37-DFFB6C6AE2A0, OS:17.2, name:iPhone 15 }\n\t\t{ platform:iOS Simulator, id:9B01E470-2A96-4C4B-8E7C-8A2141EB54AB, OS:17.4, name:iPhone 15 }\n\t\t{ platform:iOS Simulator, id:C982CD02-DA1E-471C-B75D-5A9E60466026, OS:17.0.1, name:iPhone 15 Plus }\n\t\t{ platform:iOS Simulator, id:251FC869-1A35-46A1-B3B2-20AE88AB2384, OS:17.2, name:iPhone 15 Plus }\n\t\t{ platform:iOS Simulator, id:A8BC8907-1486-43A7-A8AA-D1EC8846A28B, OS:17.4, name:iPhone 15 Plus }\n\t\t{ platform:iOS Simulator, id:3EA62B23-FEA5-4D9A-BDA5-387FEF8C8D32, OS:17.0.1, name:iPhone 15 Pro }\n\t\t{ platform:iOS Simulator, id:C40569B3-F8D0-4A16-A6A8-102F1D6BA9A2, OS:17.2, name:iPhone 15 Pro }\n\t\t{ platform:iOS Simulator, id:8CD2EC35-A8E3-4696-892F-F6F8665F9208, OS:17.4, name:iPhone 15 Pro }\n\t\t{ platform:iOS Simulator, id:8A8B76C8-7CE9-47FC-A88F-69D0C010D22B, OS:17.0.1, name:iPhone 15 Pro Max }\n\t\t{ platform:iOS Simulator, id:612D1387-1C24-4F28-8A82-E23576552CA5, OS:17.2, name:iPhone 15 Pro Max }\n\t\t{ platform:iOS Simulator, id:58BEC952-8426-4FF8-9AA8-AF2AD3240693, OS:17.4, name:iPhone 15 Pro Max }\n\t\t{ platform:iOS Simulator, id:4234A59A-9840-45FF-AD9D-E4C5CB473881, OS:17.0.1, name:iPhone SE (3rd generation) }\n\t\t{ platform:iOS Simulator, id:B6B15903-338B-43A6-9081-436CE69C1CE2, OS:17.2, name:iPhone SE (3rd generation) }\n\t\t{ platform:iOS Simulator, id:D0B94DA0-D9AE-45C6-BE17-EA6A82805BB3, OS:17.4, name:iPhone SE (3rd generation) }\n', + } as any); + + const devices = await resolveDestinationsAsync({ + xcodeProject: { + isWorkspace: true, + name: 'bacon', + }, + configuration: 'Debug', + scheme: 'evan', + }); + + for (const device of devices) { + assert('name' in device && 'udid' in device); + } + expect(devices).toMatchSnapshot(); +}); diff --git a/packages/@expo/cli/src/run/ios/options/__tests__/resolveDevice-test.ts b/packages/@expo/cli/src/run/ios/options/__tests__/resolveDevice-test.ts index d604ea29405dd..b39eccb6e7d17 100644 --- a/packages/@expo/cli/src/run/ios/options/__tests__/resolveDevice-test.ts +++ b/packages/@expo/cli/src/run/ios/options/__tests__/resolveDevice-test.ts @@ -17,6 +17,10 @@ const simulator = { jest.mock('../../../../log'); +jest.mock('../appleDestinations', () => ({ + resolveDestinationsAsync: jest.fn(async () => []), +})); + jest.mock('../../appleDevice/AppleDevice', () => ({ getConnectedDevicesAsync: jest.fn(async () => [ { @@ -58,25 +62,46 @@ jest.mock('../../../../start/platforms/ios/AppleDeviceManager', () => ({ describe(resolveDeviceAsync, () => { it(`resolves a default device`, async () => { - expect((await resolveDeviceAsync(undefined, { osType: undefined })).name).toEqual('iPhone 8'); + expect( + ( + await resolveDeviceAsync(undefined, { + osType: undefined, + configuration: 'Debug', + scheme: '123', + xcodeProject: { isWorkspace: true, name: '123 ' }, + }) + ).name + ).toEqual('iPhone 8'); expect(AppleDeviceManager.assertSystemRequirementsAsync).toBeCalled(); }); it(`prompts the user to select a device`, async () => { - expect((await resolveDeviceAsync(true, { osType: undefined })).name).toEqual(`Evan's phone`); - - expect(promptDeviceAsync).toBeCalledWith([ - expect.anything(), - expect.anything(), - expect.anything(), - ]); + expect( + ( + await resolveDeviceAsync(true, { + osType: undefined, + configuration: 'Debug', + scheme: '123', + xcodeProject: { isWorkspace: true, name: '123 ' }, + }) + ).name + ).toEqual(`Evan's phone`); + + expect(promptDeviceAsync).toBeCalledWith([expect.anything(), expect.anything()]); expect(AppleDeviceManager.assertSystemRequirementsAsync).toBeCalled(); expect(sortDefaultDeviceToBeginningAsync).toBeCalled(); }); it(`searches for the provided device by name`, async () => { - expect((await resolveDeviceAsync(`Evan's phone`, { osType: undefined })).name).toEqual( - `Evan's phone` - ); + expect( + ( + await resolveDeviceAsync(`Evan's phone`, { + osType: undefined, + configuration: 'Debug', + scheme: '123', + xcodeProject: { isWorkspace: true, name: '123 ' }, + }) + ).name + ).toEqual(`Evan's phone`); expect(promptDeviceAsync).not.toBeCalled(); @@ -85,7 +110,14 @@ describe(resolveDeviceAsync, () => { }); it(`searches for the provided device by id`, async () => { expect( - (await resolveDeviceAsync(`00008101-001964A22629003A`, { osType: undefined })).udid + ( + await resolveDeviceAsync(`00008101-001964A22629003A`, { + osType: undefined, + configuration: 'Debug', + scheme: '123', + xcodeProject: { isWorkspace: true, name: '123 ' }, + }) + ).udid ).toEqual(`00008101-001964A22629003A`); expect(promptDeviceAsync).not.toBeCalled(); @@ -94,9 +126,14 @@ describe(resolveDeviceAsync, () => { expect(sortDefaultDeviceToBeginningAsync).toBeCalled(); }); it(`asserts the requested device could not be found`, async () => { - await expect(resolveDeviceAsync(`foobar`, { osType: undefined })).rejects.toThrowError( - /No device UDID or name matching "foobar"/ - ); + await expect( + resolveDeviceAsync(`foobar`, { + osType: undefined, + configuration: 'Debug', + scheme: '123', + xcodeProject: { isWorkspace: true, name: '123 ' }, + }) + ).rejects.toThrowError(/No device UDID or name matching "foobar"/); expect(promptDeviceAsync).not.toBeCalled(); diff --git a/packages/@expo/cli/src/run/ios/options/appleDestinations.ts b/packages/@expo/cli/src/run/ios/options/appleDestinations.ts new file mode 100644 index 0000000000000..ecd21e3196c53 --- /dev/null +++ b/packages/@expo/cli/src/run/ios/options/appleDestinations.ts @@ -0,0 +1,176 @@ +import spawnAsync from '@expo/spawn-async'; + +import { Log } from '../../../log'; +import { OSType } from '../../../start/platforms/ios/simctl'; +import * as SimControl from '../../../start/platforms/ios/simctl'; +import { BuildProps } from '../XcodeBuild.types'; +import * as AppleDevice from '../appleDevice/AppleDevice'; + +const debug = require('debug')('expo:apple-destination') as typeof console.log; + +interface Destination { + // 'visionOS' + platform: string; + // 'arm64' + arch?: string; + // 'Designed for [iPad,iPhone]' + variant?: string; + // '00008112-001A20EC1E78A01E' + id: string; + // 'Apple Vision Pro' + name: string; + // Available in simulators + OS?: string; +} + +function coerceDestinationPlatformToOsType(platform: string): OSType { + // The only two devices I have to test against... + switch (platform) { + case 'iOS': + return 'iOS'; + case 'xrOS': + case 'visionOS': + return 'xrOS'; + case 'macOS': + return 'macOS'; + default: + debug('Unknown destination platform (needs to be added to Expo CLI):', platform); + return platform as OSType; + } +} + +// Runs `.filter(Boolean)` on the array with correct types. +function filterBoolean(array: (T | null | undefined)[]): T[] { + return array.filter(Boolean) as T[]; +} + +function warnDestinationObject(obj: any): Destination | null { + if (!obj || typeof obj !== 'object') { + return null; + } + + if ('platform' in obj && 'id' in obj && 'name' in obj) { + return obj; + } + Log.warn('Unexpected xcode destination object:', obj); + return null; +} + +function parseXcodeDestinationString(str: string): Destination[] { + const parsedLines = filterBoolean( + str + .trim() + .split('\n') + .map((line: string) => { + line = line.trim(); + return line.startsWith('{') ? line : null; + }) + ).map((line) => { + const inner = line.match(/{(.*)}/)?.[1]; + + if (!inner) return null; + + return Object.fromEntries( + filterBoolean( + inner + .trim() + .split(', ') + .map((item) => item.trim().match(/(?[^:]+):(?.+)/)?.groups) + ).map((item) => [item!.key, item!.value]) + ); + }); + + return filterBoolean(parsedLines.map(warnDestinationObject)); +} + +function coercePhysicalDevice( + device: Destination +): Pick { + // physical device + return { + /** @example `00008101-001964A22629003A` */ + udid: device.id, + /** @example `Evan's phone` */ + name: device.name, + /** @example `iPhone13,4` */ + // model: 'UNKNOWN', + /** @example `device` */ + deviceType: 'device', + osType: coerceDestinationPlatformToOsType(device.platform), + + osVersion: '', + }; +} + +function coerceSimulatorDevice( + device: Destination +): Pick< + SimControl.Device, + | 'udid' + | 'name' + | 'osType' + | 'osVersion' + | 'runtime' + | 'isAvailable' + | 'deviceTypeIdentifier' + | 'state' + | 'windowName' +> { + // simulator + return { + /** '00E55DC0-0364-49DF-9EC6-77BE587137D4' */ + udid: device.id, + /** 'com.apple.CoreSimulator.SimRuntime.iOS-15-1' */ + runtime: '', + /** If the device is "available" which generally means that the OS files haven't been deleted (this can happen when Xcode updates). */ + isAvailable: true, + + deviceTypeIdentifier: '', + + state: 'Shutdown', + /** 'iPhone 13 Pro' */ + name: device.name, + /** Type of OS the device uses. */ + osType: device.platform === 'visionOS Simulator' ? 'xrOS' : 'iOS', + /** '15.1' */ + osVersion: device.OS!, + /** 'iPhone 13 Pro (15.1)' */ + windowName: `${device.name} (${device.OS})`, + }; +} + +function coerceDestinationObjectToKnownDeviceType(device: Destination) { + if (device.arch) { + // physical device + return coercePhysicalDevice(device); + } else if (device.OS) { + // simulator + return coerceSimulatorDevice(device); + } else { + // "Any device" + return null; + } +} + +export async function resolveDestinationsAsync( + props: Pick +): Promise<{ name: string; osType: OSType; osVersion: string; udid: string }[]> { + // xcodebuild -workspace /Users/evanbacon/Documents/GitHub/lab/apr23/ios/apr23.xcworkspace -configuration Debug -scheme apr23 -showdestinations -json + + const { stdout } = await spawnAsync('xcodebuild', [ + props.xcodeProject.isWorkspace ? '-workspace' : '-project', + props.xcodeProject.name, + '-configuration', + props.configuration, + '-scheme', + props.scheme, + '-showdestinations', + '-quiet', + ]); + + // console.log(JSON.stringify(stdout, null, 2)); + + const destinationObjects = parseXcodeDestinationString(stdout); + + return filterBoolean(destinationObjects.map(coerceDestinationObjectToKnownDeviceType)); +} diff --git a/packages/@expo/cli/src/run/ios/options/promptDevice.ts b/packages/@expo/cli/src/run/ios/options/promptDevice.ts index 1c42b8ac23c3d..678a918ccaac4 100644 --- a/packages/@expo/cli/src/run/ios/options/promptDevice.ts +++ b/packages/@expo/cli/src/run/ios/options/promptDevice.ts @@ -18,7 +18,14 @@ function isSimControlDevice(item: AnyDevice): item is SimControl.Device { export function formatDeviceChoice(item: AnyDevice): { title: string; value: string } { const isConnected = isConnectedDevice(item) && item.deviceType === 'device'; const isActive = isSimControlDevice(item) && item.state === 'Booted'; - const symbol = isConnected ? (item.connectionType === 'Network' ? '🌐 ' : '🔌 ') : ''; + const symbol = + item.osType === 'macOS' + ? '🖥️ ' + : isConnected + ? item.connectionType === 'Network' + ? '🌐 ' + : '🔌 ' + : ''; const format = isActive ? chalk.bold : (text: string) => text; return { title: `${symbol}${format(item.name)}${ diff --git a/packages/@expo/cli/src/run/ios/options/resolveDevice.ts b/packages/@expo/cli/src/run/ios/options/resolveDevice.ts index a02053ae46869..19f615ca4c9d9 100644 --- a/packages/@expo/cli/src/run/ios/options/resolveDevice.ts +++ b/packages/@expo/cli/src/run/ios/options/resolveDevice.ts @@ -1,3 +1,4 @@ +import { resolveDestinationsAsync } from './appleDestinations'; import { promptDeviceAsync } from './promptDevice'; import * as Log from '../../../log'; import { @@ -7,38 +8,92 @@ import { import { sortDefaultDeviceToBeginningAsync } from '../../../start/platforms/ios/promptAppleDevice'; import { OSType } from '../../../start/platforms/ios/simctl'; import * as SimControl from '../../../start/platforms/ios/simctl'; +import { uniqBy } from '../../../utils/array'; import { CommandError } from '../../../utils/errors'; import { profile } from '../../../utils/profile'; import { logDeviceArgument } from '../../hints'; +import { BuildProps } from '../XcodeBuild.types'; import * as AppleDevice from '../appleDevice/AppleDevice'; -type AnyDevice = SimControl.Device | AppleDevice.ConnectedDevice; +type AnyDevice = { + name: string; + osType: OSType; + osVersion: string; + udid: string; + deviceType?: string; +}; +// type AnyDevice = SimControl.Device | AppleDevice.ConnectedDevice; /** Get a list of devices (called destinations) that are connected to the host machine. Filter by `osType` if defined. */ -async function getDevicesAsync({ osType }: { osType?: OSType } = {}): Promise { - const connectedDevices = await AppleDevice.getConnectedDevicesAsync(); - - const simulators = await sortDefaultDeviceToBeginningAsync( - await profile(SimControl.getDevicesAsync)(), +async function getDevicesAsync({ + osType, + ...buildProps +}: { osType?: OSType } & Pick): Promise< + AnyDevice[] +> { + const devices = await sortDefaultDeviceToBeginningAsync( + uniqBy( + ( + await Promise.all([ + AppleDevice.getConnectedDevicesAsync(), + await profile(SimControl.getDevicesAsync)(), + resolveDestinationsAsync(buildProps), + ]) + ).flat(), + (item) => item.udid + ), osType ); - const devices = [...connectedDevices, ...simulators]; + // Sort devices to top of front of the list + + const physical: AnyDevice[] = []; + + const simulators = devices.filter((device) => { + if ('isAvailable' in device) { + return true; + } else { + physical.push(device); + return false; + } + }); + + const isPhone = (a: any) => a.osType === 'iOS'; + const sorted = [ + ...physical.sort((a, b) => { + const aPhone = isPhone(a); + const bPhone = isPhone(b); + if (aPhone && !bPhone) return -1; + if (!aPhone && bPhone) return 1; + + return 0; + }), + ...simulators, + ]; // If osType is defined, then filter out ineligible simulators. // Only do this inside of the device selection so users who pass the entire device udid can attempt to select any simulator (even if it's invalid). - return osType ? filterDevicesForOsType(devices, osType) : devices; + return osType ? filterDevicesForOsType(sorted, osType) : sorted; } /** @returns a list of devices, filtered by the provided `osType`. */ -function filterDevicesForOsType(devices: AnyDevice[], osType: OSType): AnyDevice[] { - return devices.filter((device) => !('osType' in device) || device.osType === osType); +function filterDevicesForOsType( + devices: TDevice[], + osType: OSType +): TDevice[] { + return devices.filter((device) => { + if (osType === 'iOS') { + // Compatible devices for iOS builds + return ['iOS', 'macOS', 'xrOS'].includes(device.osType); + } + return device.osType === osType; + }); } /** Given a `device` argument from the CLI, parse and prompt our way to a usable device for building. */ export async function resolveDeviceAsync( - device?: string | boolean, - { osType }: { osType?: OSType } = {} + device: string | boolean | undefined, + buildProps: { osType?: OSType } & Pick ): Promise { await AppleDeviceManager.assertSystemRequirementsAsync(); @@ -46,22 +101,21 @@ export async function resolveDeviceAsync( /** Finds the first possible device and returns in a booted state. */ const manager = await AppleDeviceManager.resolveAsync({ device: { - osType, + osType: buildProps.osType, }, }); Log.debug( - `Resolved default device (name: ${manager.device.name}, udid: ${manager.device.udid}, osType: ${osType})` + `Resolved default device (name: ${manager.device.name}, udid: ${manager.device.udid}, osType: ${buildProps.osType})` ); return manager.device; } - const devices: AnyDevice[] = await getDevicesAsync({ - osType, - }); + const devices = await getDevicesAsync(buildProps); const resolved = device === true ? // `--device` (no props after) + // @ts-expect-error await promptDeviceAsync(devices) : // `--device ` findDeviceFromSearchValue(devices, device.toLowerCase()); @@ -73,7 +127,7 @@ export async function resolveDeviceAsync( export function isSimulatorDevice(device: AnyDevice): boolean { return ( !('deviceType' in device) || - device.deviceType.startsWith('com.apple.CoreSimulator.SimDeviceType.') + !!device.deviceType?.startsWith?.('com.apple.CoreSimulator.SimDeviceType.') ); } diff --git a/packages/@expo/cli/src/run/ios/options/resolveNativeScheme.ts b/packages/@expo/cli/src/run/ios/options/resolveNativeScheme.ts index a8d6f20b6b28f..ed6dc992d489a 100644 --- a/packages/@expo/cli/src/run/ios/options/resolveNativeScheme.ts +++ b/packages/@expo/cli/src/run/ios/options/resolveNativeScheme.ts @@ -34,6 +34,7 @@ export async function promptOrQueryNativeSchemeAsync( const schemes = IOSConfig.BuildScheme.getRunnableSchemesFromXcodeproj(projectRoot, { configuration, }); + if (!schemes.length) { throw new CommandError('IOS_MALFORMED', 'No native iOS build schemes found'); } diff --git a/packages/@expo/cli/src/run/ios/options/resolveOptions.ts b/packages/@expo/cli/src/run/ios/options/resolveOptions.ts index 90429b88572e9..aaf1d78b09c16 100644 --- a/packages/@expo/cli/src/run/ios/options/resolveOptions.ts +++ b/packages/@expo/cli/src/run/ios/options/resolveOptions.ts @@ -22,18 +22,21 @@ export async function resolveOptionsAsync( xcodeProject ); + // Use the configuration or `Debug` if none is provided. + const configuration = options.configuration || 'Debug'; + // Resolve the device based on the provided device id or prompt // from a list of devices (connected or simulated) that are filtered by the scheme. const device = await resolveDeviceAsync(options.device, { // It's unclear if there's any value to asserting that we haven't hardcoded the os type in the CLI. osType: isOSType(osType) ? osType : undefined, + xcodeProject, + scheme, + configuration, }); const isSimulator = isSimulatorDevice(device); - // Use the configuration or `Debug` if none is provided. - const configuration = options.configuration || 'Debug'; - // This optimization skips resetting the Metro cache needlessly. // The cache is reset in `../node_modules/react-native/scripts/react-native-xcode.sh` when the // project is running in Debug and built onto a physical device. It seems that this is done because diff --git a/packages/@expo/cli/src/start/platforms/ios/devicectl.ts b/packages/@expo/cli/src/start/platforms/ios/devicectl.ts index a4a78ac880770..29c11c121fb06 100644 --- a/packages/@expo/cli/src/start/platforms/ios/devicectl.ts +++ b/packages/@expo/cli/src/start/platforms/ios/devicectl.ts @@ -7,7 +7,7 @@ import { getExpoHomeDirectory } from '@expo/config/build/getUserState'; import JsonFile from '@expo/json-file'; -import { SpawnOptions, SpawnResult } from '@expo/spawn-async'; +import spawnAsync, { SpawnOptions, SpawnResult } from '@expo/spawn-async'; import chalk from 'chalk'; import { spawn, execSync } from 'child_process'; import fs from 'fs'; @@ -56,7 +56,7 @@ type DeviceCtlHardwareProperties = { /** "iPhone 14 Pro Max" */ marketingName: string; /** "iOS" */ - platform: string; + platform: AnyEnum<'iOS' | 'xrOS'>; /** "iPhone15,3" */ productType: AnyEnum<'iPhone13,4' | 'iPhone15,3'>; reality: AnyEnum<'physical'>; @@ -189,6 +189,29 @@ function assertDevicesJson( ); } +export async function launchBinaryOnMacAsync( + bundleId: string, + appBinaryPath: string +): Promise { + const args = ['-b', bundleId, appBinaryPath]; + try { + await spawnAsync('open', args); + } catch (error: any) { + if ('code' in error) { + if (error.code === 1) { + throw new CommandError( + 'MACOS_LAUNCH', + 'Failed to launch the compatible binary on macOS: open ' + + args.join(' ') + + '\n\n' + + error.message + ); + } + } + throw error; + } +} + async function installAppWithDeviceCtlAsync( uuid: string, bundleIdOrAppPath: string, diff --git a/packages/@expo/cli/src/start/platforms/ios/promptAppleDevice.ts b/packages/@expo/cli/src/start/platforms/ios/promptAppleDevice.ts index f1e78ce0219e9..f4d6855b3c4af 100644 --- a/packages/@expo/cli/src/start/platforms/ios/promptAppleDevice.ts +++ b/packages/@expo/cli/src/start/platforms/ios/promptAppleDevice.ts @@ -10,10 +10,10 @@ import { createSelectionFilter, promptAsync } from '../../../utils/prompts'; * @param devices list of devices to sort. * @param osType optional sort by operating system. */ -export async function sortDefaultDeviceToBeginningAsync( - devices: Device[], +export async function sortDefaultDeviceToBeginningAsync( + devices: T[], osType?: Device['osType'] -): Promise { +): Promise { const defaultId = await getBestSimulatorAsync({ osType }); if (defaultId) { let iterations = 0; diff --git a/packages/@expo/cli/src/start/platforms/ios/simctl.ts b/packages/@expo/cli/src/start/platforms/ios/simctl.ts index 94e09c948b60d..7bed382e55abe 100644 --- a/packages/@expo/cli/src/start/platforms/ios/simctl.ts +++ b/packages/@expo/cli/src/start/platforms/ios/simctl.ts @@ -6,7 +6,7 @@ import { CommandError } from '../../../utils/errors'; type DeviceState = 'Shutdown' | 'Booted'; -export type OSType = 'iOS' | 'tvOS' | 'watchOS' | 'macOS'; +export type OSType = 'iOS' | 'tvOS' | 'watchOS' | 'macOS' | 'xrOS'; export type Device = { availabilityError?: 'runtime profile not found';