From ba1a1fd03093cda65d52632350179649bd218e53 Mon Sep 17 00:00:00 2001 From: Tommy Nguyen <4123478+tido64@users.noreply.github.com> Date: Wed, 28 Aug 2024 11:19:40 +0200 Subject: [PATCH] feat(tools-apple): add primitives for build and run commands (#3305) --- .changeset/moody-knives-think.md | 5 ++ .changeset/twenty-eels-eat.md | 8 ++ packages/cli/package.json | 1 + packages/cli/src/build.ts | 98 ++++++++++++++++++++++ packages/cli/src/build/apple.ts | 74 +++++++++++++++++ packages/cli/src/build/ios.ts | 32 ++++++++ packages/cli/src/build/macos.ts | 43 ++++++++++ packages/cli/src/helpers/parsers.ts | 5 +- packages/cli/src/index.ts | 2 + packages/cli/src/run.ts | 38 +++++++++ packages/cli/src/run/ios.ts | 75 +++++++++++++++++ packages/cli/src/run/macos.ts | 38 +++++++++ packages/cli/tsconfig.json | 5 ++ packages/tools-apple/README.md | 23 +++--- packages/tools-apple/package.json | 3 +- packages/tools-apple/src/index.ts | 18 +++- packages/tools-apple/src/ios.ts | 122 ++++++++++++++++++++++++--- packages/tools-apple/src/macos.ts | 16 ++++ packages/tools-apple/src/scheme.ts | 40 +++++++++ packages/tools-apple/src/types.ts | 30 +++++++ packages/tools-apple/src/xcode.ts | 123 +++++++++++++++++++++++++++- yarn.lock | 2 + 22 files changed, 771 insertions(+), 30 deletions(-) create mode 100644 .changeset/moody-knives-think.md create mode 100644 .changeset/twenty-eels-eat.md create mode 100644 packages/cli/src/build.ts create mode 100644 packages/cli/src/build/apple.ts create mode 100644 packages/cli/src/build/ios.ts create mode 100644 packages/cli/src/build/macos.ts create mode 100644 packages/cli/src/run.ts create mode 100644 packages/cli/src/run/ios.ts create mode 100644 packages/cli/src/run/macos.ts create mode 100644 packages/tools-apple/src/scheme.ts diff --git a/.changeset/moody-knives-think.md b/.changeset/moody-knives-think.md new file mode 100644 index 000000000..061c621b2 --- /dev/null +++ b/.changeset/moody-knives-think.md @@ -0,0 +1,5 @@ +--- +"@rnx-kit/tools-apple": patch +--- + +Added primitives for building 'build' and 'run' commands diff --git a/.changeset/twenty-eels-eat.md b/.changeset/twenty-eels-eat.md new file mode 100644 index 000000000..0d8eae7e1 --- /dev/null +++ b/.changeset/twenty-eels-eat.md @@ -0,0 +1,8 @@ +--- +"@rnx-kit/cli": patch +--- + +Added experimental commands for building and running iOS/macOS apps. These need +more testing, preferably outside of rnx-kit, and as such, will not be publicly +available. But if you are willing to keep patches, there are ways to access +them. diff --git a/packages/cli/package.json b/packages/cli/package.json index 18ecac915..d6ebe7cc1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,6 +50,7 @@ "@rnx-kit/metro-serializer-esbuild": "^0.1.38", "@rnx-kit/metro-service": "^3.1.6", "@rnx-kit/third-party-notices": "^1.3.4", + "@rnx-kit/tools-apple": "^0.1.1", "@rnx-kit/tools-language": "^2.0.0", "@rnx-kit/tools-node": "^2.1.1", "@rnx-kit/tools-react-native": "^1.4.1", diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts new file mode 100644 index 000000000..a99a4ac13 --- /dev/null +++ b/packages/cli/src/build.ts @@ -0,0 +1,98 @@ +import type { Config } from "@react-native-community/cli-types"; +import { InvalidArgumentError } from "commander"; +import type { + BuildConfiguration, + DeviceType, + InputParams, +} from "./build/apple"; +import { buildIOS } from "./build/ios"; +import { buildMacOS } from "./build/macos"; + +function asConfiguration(configuration: string): BuildConfiguration { + switch (configuration) { + case "Debug": + case "Release": + return configuration; + + default: + throw new InvalidArgumentError("Expected 'Debug' or 'Release'."); + } +} + +function asDestination(destination: string): DeviceType { + switch (destination) { + case "device": + case "emulator": + case "simulator": + return destination; + + default: + throw new InvalidArgumentError( + "Expected 'device', 'emulator', or 'simulator'." + ); + } +} + +function asSupportedPlatform(platform: string): InputParams["platform"] { + switch (platform) { + case "ios": + case "macos": + case "visionos": + return platform; + default: + throw new InvalidArgumentError( + "Supported platforms: 'ios', 'macos', 'visionos'." + ); + } +} + +export function rnxBuild( + _argv: string[], + config: Config, + buildParams: InputParams +) { + switch (buildParams.platform) { + case "ios": + case "visionos": + return buildIOS(config, buildParams); + case "macos": + return buildMacOS(config, buildParams); + } +} + +export const rnxBuildCommand = { + name: "rnx-build", + description: + "Build your native app for testing in emulator/simulator or on device", + func: rnxBuild, + options: [ + { + name: "--platform ", + description: "Target platform", + parse: asSupportedPlatform, + }, + { + name: "--workspace ", + description: + "Path, relative to project root, of the Xcode workspace to build (macOS only)", + }, + { + name: "--scheme ", + description: "Name of scheme to build", + }, + { + name: "--configuration ", + description: + "Build configuration for building the app; 'Debug' or 'Release'", + default: "Debug", + parse: asConfiguration, + }, + { + name: "--destination ", + description: + "Destination of the built app; 'device', 'emulator', or 'simulator'", + default: "simulator", + parse: asDestination, + }, + ], +}; diff --git a/packages/cli/src/build/apple.ts b/packages/cli/src/build/apple.ts new file mode 100644 index 000000000..60c8e3cd7 --- /dev/null +++ b/packages/cli/src/build/apple.ts @@ -0,0 +1,74 @@ +import type { Ora } from "ora"; + +// Copy of types from `@rnx-kit/tools-apple`. If Jest wasn't such a pain to +// configure, we would have added an `import type` at the top instead: +// +// import type { BuildParams } from "@rnx-kit/tools-apple" with { "resolution-mode": "import" }; +// +// But Jest doesn't like import attributes and it doesn't matter if we add +// `@babel/plugin-syntax-import-attributes` in the config. +// +// TOOD: Remove the `DeviceType`, `BuildConfiguration` and `BuildParams` when we +// can migrate away from Jest in this package. +export type DeviceType = "device" | "emulator" | "simulator"; +export type BuildConfiguration = "Debug" | "Release"; +type BuildParams = + | { + platform: "ios" | "visionos"; + scheme?: string; + destination?: DeviceType; + configuration?: BuildConfiguration; + archs?: string; + isBuiltRemotely?: boolean; + } + | { + platform: "macos"; + scheme?: string; + configuration?: BuildConfiguration; + isBuiltRemotely?: boolean; + }; + +export type BuildArgs = { + xcworkspace: string; + args: string[]; +}; + +export type BuildResult = BuildArgs | number | null; + +export type InputParams = BuildParams & { + device?: string; + workspace?: string; +}; + +export function runBuild( + xcworkspace: string, + buildParams: BuildParams, + logger: Ora +): Promise { + return import("@rnx-kit/tools-apple").then(({ xcodebuild }) => { + return new Promise((resolve) => { + logger.start("Building..."); + + const errors: Buffer[] = []; + const proc = xcodebuild(xcworkspace, buildParams, (text) => { + const current = logger.text; + logger.info(text); + logger.start(current); + }); + + proc.stdout.on("data", () => (logger.text += ".")); + proc.stderr.on("data", (data) => errors.push(data)); + + proc.on("close", (code) => { + if (code === 0) { + logger.succeed("Build succeeded"); + resolve({ xcworkspace, args: proc.spawnargs }); + } else { + logger.fail(Buffer.concat(errors).toString()); + process.exitCode = code ?? 1; + resolve(code); + } + }); + }); + }); +} diff --git a/packages/cli/src/build/ios.ts b/packages/cli/src/build/ios.ts new file mode 100644 index 000000000..37bddd8f7 --- /dev/null +++ b/packages/cli/src/build/ios.ts @@ -0,0 +1,32 @@ +import type { Config } from "@react-native-community/cli-types"; +import * as path from "node:path"; +import ora from "ora"; +import type { BuildResult, InputParams } from "./apple"; +import { runBuild } from "./apple"; + +export function buildIOS( + config: Config, + buildParams: InputParams, + logger = ora() +): Promise { + const { platform } = buildParams; + const { sourceDir, xcodeProject } = config.project[platform] ?? {}; + if (!sourceDir || !xcodeProject) { + const root = platform.substring(0, platform.length - 2); + logger.fail(`No ${root}OS project was found`); + process.exitCode = 1; + return Promise.resolve(1); + } + + const { name, path: projectDir } = xcodeProject; + if (!name?.endsWith(".xcworkspace")) { + logger.fail( + "No Xcode workspaces were found; did you forget to run `pod install`?" + ); + process.exitCode = 1; + return Promise.resolve(1); + } + + const xcworkspace = path.resolve(sourceDir, projectDir, name); + return runBuild(xcworkspace, buildParams, logger); +} diff --git a/packages/cli/src/build/macos.ts b/packages/cli/src/build/macos.ts new file mode 100644 index 000000000..8977a0f71 --- /dev/null +++ b/packages/cli/src/build/macos.ts @@ -0,0 +1,43 @@ +import type { Config } from "@react-native-community/cli-types"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import ora from "ora"; +import type { BuildResult, InputParams } from "./apple"; +import { runBuild } from "./apple"; + +function findXcodeWorkspaces(searchDir: string) { + return fs.existsSync(searchDir) + ? fs.readdirSync(searchDir).filter((file) => file.endsWith(".xcworkspace")) + : []; +} + +export function buildMacOS( + _config: Config, + { workspace, ...buildParams }: InputParams, + logger = ora() +): Promise { + if (workspace) { + return runBuild(workspace, buildParams, logger); + } + + const sourceDir = "macos"; + const workspaces = findXcodeWorkspaces(sourceDir); + if (workspaces.length === 0) { + logger.fail( + "No Xcode workspaces were found; specify an Xcode workspace with `--workspace`" + ); + process.exitCode = 1; + return Promise.resolve(1); + } + + if (workspaces.length > 1) { + logger.fail( + `Multiple Xcode workspaces were found; picking the first one: ${workspaces.join(", ")}` + ); + logger.fail( + "If this is wrong, specify another workspace with `--workspace`" + ); + } + + return runBuild(path.join(sourceDir, workspaces[0]), buildParams, logger); +} diff --git a/packages/cli/src/helpers/parsers.ts b/packages/cli/src/helpers/parsers.ts index af379bc1f..0f4f9e060 100644 --- a/packages/cli/src/helpers/parsers.ts +++ b/packages/cli/src/helpers/parsers.ts @@ -1,3 +1,4 @@ +import { InvalidArgumentError } from "commander"; import type { TransformProfile } from "metro-babel-transformer"; import * as path from "node:path"; @@ -8,7 +9,7 @@ export function asBoolean(value: string): boolean { case "true": return true; default: - throw new Error(`Expected 'true' or 'false; got '${value}'`); + throw new InvalidArgumentError(`Expected 'true' or 'false'.`); } } @@ -37,7 +38,7 @@ export function asTransformProfile(val: string): TransformProfile { "hermes-canary", "default", ]; - throw new Error(`Expected '${profiles.join("', '")}'; got ${val}`); + throw new InvalidArgumentError(`Expected '${profiles.join("', '")}'.`); } } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 510a2cefb..00b83a264 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -22,10 +22,12 @@ export const reactNativeConfig = { }; export { rnxAlignDeps, rnxAlignDepsCommand } from "./align-deps"; +export { rnxBuild, rnxBuildCommand } from "./build"; export { rnxBundle, rnxBundleCommand } from "./bundle"; export { rnxClean, rnxCleanCommand } from "./clean"; export { copyProjectAssets, rnxCopyAssetsCommand } from "./copy-assets"; export { rnxRamBundle, rnxRamBundleCommand } from "./ram-bundle"; +export { rnxRun, rnxRunCommand } from "./run"; export { rnxStart, rnxStartCommand } from "./start"; export { rnxTest, rnxTestCommand } from "./test"; export { diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts new file mode 100644 index 000000000..c7bf51f7d --- /dev/null +++ b/packages/cli/src/run.ts @@ -0,0 +1,38 @@ +import type { Config } from "@react-native-community/cli-types"; +import { rnxBuildCommand } from "./build"; +import type { InputParams } from "./build/apple"; +import { runIOS } from "./run/ios"; +import { runMacOS } from "./run/macos"; + +export function rnxRun( + _argv: string[], + config: Config, + buildParams: InputParams +) { + switch (buildParams.platform) { + case "ios": + case "visionos": + return runIOS(config, buildParams); + case "macos": + return runMacOS(config, buildParams); + default: + // @ts-expect-error Safe guard against user input + console.error(`Unsupported platform: ${buildParams.platform}`); + process.exitCode = 1; + return Promise.resolve(); + } +} + +export const rnxRunCommand = { + name: "rnx-run", + description: + "Build and run your native app for testing in emulator/simulator or on device", + func: rnxRun, + options: [ + ...rnxBuildCommand.options, + { + name: "--device ", + description: "The name of the device to launch the app in", + }, + ], +}; diff --git a/packages/cli/src/run/ios.ts b/packages/cli/src/run/ios.ts new file mode 100644 index 000000000..282a33322 --- /dev/null +++ b/packages/cli/src/run/ios.ts @@ -0,0 +1,75 @@ +import type { Config } from "@react-native-community/cli-types"; +import * as path from "node:path"; +import ora from "ora"; +import type { InputParams } from "../build/apple"; +import { buildIOS } from "../build/ios"; + +export async function runIOS(config: Config, buildParams: InputParams) { + const { platform } = buildParams; + if (platform !== "ios" && platform !== "visionos") { + throw new Error("Expected iOS/visionOS build configuration"); + } + + const logger = ora(); + + const result = await buildIOS(config, buildParams, logger); + if (!result || typeof result !== "object") { + return; + } + + logger.start("Preparing to launch app..."); + + const { + getBuildSettings, + getDevicePlatformIdentifier, + install, + launch, + selectDevice, + } = await import("@rnx-kit/tools-apple"); + + const { destination = "simulator", device: deviceName } = buildParams; + const deviceOrPlatformIdentifier = + deviceName ?? getDevicePlatformIdentifier(buildParams); + + const [settings, device] = await Promise.all([ + getBuildSettings(result.xcworkspace, result.args), + selectDevice(deviceOrPlatformIdentifier, destination, logger), + ]); + + if (!settings) { + logger.fail("Failed to launch app: Could not get build settings"); + process.exitCode = 1; + return; + } + + if (!device) { + logger.fail("Failed to launch app: Could not find an appropriate device"); + process.exitCode = 1; + return; + } + + const { EXECUTABLE_FOLDER_PATH, FULL_PRODUCT_NAME, TARGET_BUILD_DIR } = + settings.buildSettings; + const app = path.join(TARGET_BUILD_DIR, EXECUTABLE_FOLDER_PATH); + + logger.start(`Installing '${FULL_PRODUCT_NAME}' on ${device.name}...`); + + const installError = await install(device, app); + if (installError) { + logger.fail(installError.message); + process.exitCode = 1; + return; + } + + logger.succeed(`Installed '${FULL_PRODUCT_NAME}' on ${device.name}`); + logger.start(`Starting '${FULL_PRODUCT_NAME}' on ${device.name}`); + + const launchError = await launch(device, app); + if (launchError) { + logger.fail(launchError.message); + process.exitCode = 1; + return; + } + + logger.succeed(`Started '${FULL_PRODUCT_NAME}' on ${device.name}`); +} diff --git a/packages/cli/src/run/macos.ts b/packages/cli/src/run/macos.ts new file mode 100644 index 000000000..b7ed97db6 --- /dev/null +++ b/packages/cli/src/run/macos.ts @@ -0,0 +1,38 @@ +import type { Config } from "@react-native-community/cli-types"; +import * as path from "node:path"; +import ora from "ora"; +import type { InputParams } from "../build/apple"; +import { buildMacOS } from "../build/macos"; + +export async function runMacOS(config: Config, buildParams: InputParams) { + const logger = ora(); + + const result = await buildMacOS(config, buildParams, logger); + if (!result || typeof result !== "object") { + return; + } + + const { getBuildSettings, open } = await import("@rnx-kit/tools-apple"); + + logger.start("Launching app..."); + + const settings = await getBuildSettings(result.xcworkspace, result.args); + if (!settings) { + logger.fail("Failed to launch app: Could not get build settings"); + process.exitCode = 1; + return; + } + + const { FULL_PRODUCT_NAME, TARGET_BUILD_DIR } = settings.buildSettings; + const appPath = path.join(TARGET_BUILD_DIR, FULL_PRODUCT_NAME); + + logger.text = `Launching '${FULL_PRODUCT_NAME}'...`; + + const { stderr, status } = await open(appPath); + if (status !== 0) { + logger.fail(`Failed to launch app: ${stderr}`); + process.exitCode = status ?? 1; + } else { + logger.succeed(`Launched '${FULL_PRODUCT_NAME}'`); + } +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 179a9ad24..29fe78dbe 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,4 +1,9 @@ { "extends": "@rnx-kit/tsconfig/tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "node16", + "moduleResolution": "node16" + }, "include": ["src"] } diff --git a/packages/tools-apple/README.md b/packages/tools-apple/README.md index 829a37e28..9bc216f40 100644 --- a/packages/tools-apple/README.md +++ b/packages/tools-apple/README.md @@ -15,15 +15,18 @@ import * as tools from "@rnx-kit/tools-apple"; -| Category | Function | Description | -| -------- | ---------------------------------------------- | ---------------------------------------------------------------------------- | -| ios | `bootSimulator(simulator)` | Boots the simulator with the specified UDID. | -| ios | `getAvailableSimulators(search)` | Returns a list of available iOS simulators. | -| ios | `getDevices()` | Returns a list of available iOS simulators and physical devices. | -| ios | `install(device, app)` | Installs the specified app bundle on specified simulator or physical device. | -| ios | `launch(device, app)` | Launches the specified app bundle on specified simulator or physical device. | -| ios | `selectDevice(deviceName, deviceType, logger)` | Returns the simulator or physical device with the specified name. | -| xcode | `getDeveloperDirectory()` | Returns the path to the active developer directory. | -| xcode | `parsePlist(app)` | Parses and returns the information property list of specified bundle. | +| Category | Function | Description | +| -------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | +| ios | `bootSimulator(simulator)` | Boots the simulator with the specified UDID. | +| ios | `getAvailableSimulators(search)` | Returns a list of available iOS simulators. | +| ios | `getDevices()` | Returns a list of available iOS simulators and physical devices. | +| ios | `install(device, app)` | Installs the specified app bundle on specified simulator or physical device. | +| ios | `launch(device, app)` | Launches the specified app bundle on specified simulator or physical device. | +| ios | `selectDevice(deviceNameOrPlatformIdentifier, deviceType, logger)` | Returns the simulator or physical device with the specified name. | +| xcode | `getBuildSettings(xcworkspace, params)` | Returns build settings for specified Xcode workspace and the parameters used to build it. | +| xcode | `getDeveloperDirectory()` | Returns the path to the active developer directory. | +| xcode | `getDevicePlatformIdentifier(buildParams)` | Returns device platform identifier for specified platform and destination. | +| xcode | `parsePlist(app)` | Parses and returns the information property list of specified bundle. | +| xcode | `xcodebuild(xcworkspace, params, log)` | Builds the specified `.xcworkspace`. | diff --git a/packages/tools-apple/package.json b/packages/tools-apple/package.json index f2e0cf51c..8a208f505 100644 --- a/packages/tools-apple/package.json +++ b/packages/tools-apple/package.json @@ -49,7 +49,8 @@ "update-readme": "rnx-kit-scripts update-api-readme" }, "dependencies": { - "@rnx-kit/tools-shell": "^0.1.2" + "@rnx-kit/tools-shell": "^0.1.2", + "fast-xml-parser": "^4.0.0" }, "devDependencies": { "@rnx-kit/eslint-config": "*", diff --git a/packages/tools-apple/src/index.ts b/packages/tools-apple/src/index.ts index d0a924433..3e3ffdd0e 100644 --- a/packages/tools-apple/src/index.ts +++ b/packages/tools-apple/src/index.ts @@ -8,5 +8,19 @@ export { selectDevice, } from "./ios.js"; export { open } from "./macos.js"; -export type { Device, DeviceType, Simulator } from "./types.js"; -export { getDeveloperDirectory, parsePlist, xcrun } from "./xcode.js"; +export type { + BuildConfiguration, + BuildParams, + BuildSettings, + Device, + DeviceType, + Simulator, +} from "./types.js"; +export { + getBuildSettings, + getDeveloperDirectory, + getDevicePlatformIdentifier, + parsePlist, + xcodebuild, + xcrun, +} from "./xcode.js"; diff --git a/packages/tools-apple/src/ios.ts b/packages/tools-apple/src/ios.ts index 7e0ba8ae4..145cf9a14 100644 --- a/packages/tools-apple/src/ios.ts +++ b/packages/tools-apple/src/ios.ts @@ -1,11 +1,50 @@ import { retry } from "@rnx-kit/tools-shell/async"; import { ensure, makeCommand } from "@rnx-kit/tools-shell/command"; import * as readline from "node:readline"; -import type { Device, DeviceType, Logger, Simulator } from "./types.js"; +import { open } from "./macos.js"; +import type { + BuildParams, + Device, + DeviceType, + Logger, + Simulator, +} from "./types.js"; import { parsePlist, xcrun } from "./xcode.js"; +const DEFAULT_SIMS: Record = { + "com.apple.platform.iphonesimulator": /^iPhone \d\d(?: Pro)?$/, + "com.apple.platform.xrsimulator": /^Apple Vision Pro/, +}; + +const XCODE_SDKS = { + ios: { + device: { + sdk: "iphoneos", + destination: "generic/platform=iOS", + }, + simulator: { + sdk: "iphonesimulator", + destination: "generic/platform=iOS Simulator", + }, + }, + visionos: { + device: { + sdk: "xros", + destination: "generic/platform=visionOS", + }, + simulator: { + sdk: "xrsimulator", + destination: "generic/platform=visionOS Simulator", + }, + }, +}; + export const iosDeploy = makeCommand("ios-deploy"); +function ensureSimulatorAppIsOpen() { + return open("-a", "Simulator"); +} + /** * Returns a list of available iOS simulators. */ @@ -58,12 +97,38 @@ export async function bootSimulator( return new Error(stderr); } - const result = await retry(async () => { + const booted = await retry(async () => { const simulators = await getAvailableSimulators(udid); const device = pickSimulator(simulators); return device?.state === "Booted" || null; }, 4); - return result ? null : new Error("Timed out waiting for the simulator"); + if (!booted) { + return new Error("Timed out waiting for the simulator"); + } + + // Make sure `Simulator.app` is foregrounded. `simctl boot` may only start the + // background process. + await ensureSimulatorAppIsOpen(); + + return null; +} + +function findSimulator( + devices: Device[], + deviceName: string | undefined, + platformIdentifier: string +) { + if (deviceName) { + return devices.find( + ({ simulator, name }) => simulator && name === deviceName + ); + } + + const defaultSimulator = + DEFAULT_SIMS[platformIdentifier || "com.apple.platform.iphonesimulator"]; + return devices.reverse().find(({ simulator, available, modelName }) => { + return simulator && available && defaultSimulator.test(modelName); + }); } /** @@ -82,6 +147,37 @@ export async function install( return status === 0 ? null : new Error(stderr); } +/** + * Adds iOS specific build flags. + */ +export function iosSpecificBuildFlags( + params: BuildParams, + args: string[] +): string[] { + const { platform } = params; + if (platform === "ios" || platform === "visionos") { + const sdks = XCODE_SDKS[platform]; + const { destination, archs } = params; + if (destination === "device") { + const { sdk, destination } = sdks.device; + args.push("-sdk", sdk, "-destination", destination); + } else { + const { sdk, destination } = sdks.simulator; + args.push( + "-sdk", + sdk, + "-destination", + destination, + "CODE_SIGNING_ALLOWED=NO" + ); + if (archs) { + args.push(`ARCHS=${archs}`); + } + } + } + return args; +} + /** * Launches the specified app bundle on specified simulator or physical device. */ @@ -132,17 +228,22 @@ export async function launch( * If a simulator is found, it is also booted if necessary */ export async function selectDevice( - deviceName: string | undefined, + deviceNameOrPlatformIdentifier: string | undefined, deviceType: DeviceType, logger: Logger ): Promise { const devices = await getDevices(); + const [deviceName, platformIdentifier]: [string | undefined, string] = + deviceNameOrPlatformIdentifier?.startsWith("com.apple.platform.") + ? [undefined, deviceNameOrPlatformIdentifier] + : [deviceNameOrPlatformIdentifier, "com.apple.platform.iphoneos"]; + if (deviceType === "device") { const search: (device: Device) => boolean = deviceName ? ({ simulator, name }) => !simulator && name === deviceName : ({ simulator, platform }) => - !simulator && platform === "com.apple.platform.iphoneos"; + !simulator && platform === platformIdentifier; const physicalDevice = devices.find(search); if (!physicalDevice) { // Device detection can sometimes be flaky. Prompt the user to make sure @@ -167,13 +268,7 @@ export async function selectDevice( return physicalDevice; } - const device = deviceName - ? devices.find(({ simulator, name }) => simulator && name === deviceName) - : devices.reverse().find(({ simulator, available, modelName }) => { - return ( - simulator && available && /^iPhone \d\d(?: Pro)?$/.test(modelName) - ); - }); + const device = findSimulator(devices, deviceName, platformIdentifier); if (!device) { const foundDevices = devices .reduce((list, device) => { @@ -188,7 +283,7 @@ export async function selectDevice( const message = [ deviceName ? `Failed to find ${deviceName} simulator:` - : "Failed to find an iPhone simulator:", + : "Failed to find a simulator:", ...foundDevices, ].join("\n\t- "); logger.fail(message); @@ -211,6 +306,7 @@ export async function selectDevice( logger.succeed(`Booted ${name} simulator`); } else { logger.info(`${name} simulator has already been booted`); + await ensureSimulatorAppIsOpen(); } return device; } diff --git a/packages/tools-apple/src/macos.ts b/packages/tools-apple/src/macos.ts index 88f5aebf4..933d7e4f1 100644 --- a/packages/tools-apple/src/macos.ts +++ b/packages/tools-apple/src/macos.ts @@ -1,3 +1,19 @@ import { makeCommand } from "@rnx-kit/tools-shell/command"; +import type { BuildParams } from "./types.js"; export const open = makeCommand("open"); + +/** + * Adds macOS specific build flags. + */ +export function macosSpecificBuildFlags( + { platform, configuration }: BuildParams, + args: string[] +): string[] { + if (platform === "macos") { + if (configuration !== "Release") { + args.push("CODE_SIGNING_ALLOWED=NO"); + } + } + return args; +} diff --git a/packages/tools-apple/src/scheme.ts b/packages/tools-apple/src/scheme.ts new file mode 100644 index 000000000..8f0c26364 --- /dev/null +++ b/packages/tools-apple/src/scheme.ts @@ -0,0 +1,40 @@ +import { XMLParser } from "fast-xml-parser"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +export function findSchemes(xcworkspace: string): string[] { + const UTF8 = { encoding: "utf-8" } as const; + + const xcworkspacedata = path.join(xcworkspace, "contents.xcworkspacedata"); + const workspace = fs.readFileSync(xcworkspacedata, UTF8); + + const xmlParser = new XMLParser({ ignoreAttributes: false }); + const fileRef = xmlParser.parse(workspace).Workspace.FileRef; + const refs = Array.isArray(fileRef) ? fileRef : [fileRef]; + + const schemes = new Set(); + for (const ref of refs) { + const location = ref["@_location"]; + + // Ignore the project generated by CocoaPods + if (location.endsWith("/Pods.xcodeproj")) { + continue; + } + + const xcschemesDir = path.resolve( + path.dirname(xcworkspace), + location.replace("group:", ""), + "xcshareddata", + "xcschemes" + ); + for (const filename of fs.readdirSync(xcschemesDir)) { + const xcscheme = path.join(xcschemesDir, filename); + const scheme = xmlParser.parse(fs.readFileSync(xcscheme, UTF8)).Scheme; + if (scheme.LaunchAction) { + schemes.add(path.basename(filename, ".xcscheme")); + } + } + } + + return Array.from(schemes); +} diff --git a/packages/tools-apple/src/types.ts b/packages/tools-apple/src/types.ts index 4d6fa06ea..559256461 100644 --- a/packages/tools-apple/src/types.ts +++ b/packages/tools-apple/src/types.ts @@ -47,3 +47,33 @@ export type Simulator = { dataPath: string; availabilityError?: string; }; + +export type BuildConfiguration = "Debug" | "Release"; + +export type BuildParams = + | { + platform: "ios" | "visionos"; + scheme?: string; + destination?: DeviceType; + configuration?: BuildConfiguration; + archs?: string; + isBuiltRemotely?: boolean; + } + | { + platform: "macos"; + scheme?: string; + configuration?: BuildConfiguration; + isBuiltRemotely?: boolean; + }; + +export type BuildSettings = { + action: string; + buildSettings: { + EXECUTABLE_FOLDER_PATH: string; + FULL_PRODUCT_NAME: string; + PRODUCT_BUNDLE_IDENTIFIER: string; + TARGET_BUILD_DIR: string; + WRAPPER_EXTENSION?: string; + }; + target: string; +}; diff --git a/packages/tools-apple/src/xcode.ts b/packages/tools-apple/src/xcode.ts index a9fb24f5e..1988c9840 100644 --- a/packages/tools-apple/src/xcode.ts +++ b/packages/tools-apple/src/xcode.ts @@ -1,9 +1,84 @@ import { makeCommand, makeCommandSync } from "@rnx-kit/tools-shell/command"; -import * as path from "path"; -import type { JSObject } from "./types.js"; +import { spawn } from "node:child_process"; +import * as path from "node:path"; +import { iosSpecificBuildFlags } from "./ios.js"; +import { macosSpecificBuildFlags } from "./macos.js"; +import { findSchemes } from "./scheme.js"; +import type { BuildParams, BuildSettings, Device, JSObject } from "./types.js"; export const xcrun = makeCommand("xcrun"); +function remoteSpecificBuildFlags( + { isBuiltRemotely }: BuildParams, + args: string[] +): string[] { + if (isBuiltRemotely) { + const NO_SANITIZE = + "-fno-sanitize=undefined -fno-sanitize=bounds -fstack-protector-strong"; + args.push( + "CLANG_ADDRESS_SANITIZER=NO", + "CLANG_UNDEFINED_BEHAVIOR_SANITIZER=NO", + `OTHER_CFLAGS=$(inherited) ${NO_SANITIZE}`, + `OTHER_LDFLAGS=$(inherited) ${NO_SANITIZE}` + ); + } + return args; +} + +/** + * Returns build settings for specified Xcode workspace and the parameters used + * to build it. + */ +export async function getBuildSettings( + xcworkspace: string, + params: string[] +): Promise { + const reusedFlags = ["-scheme", "-configuration", "-sdk", "-derivedDataPath"]; + + const buildSettingsArgs = ["-workspace", xcworkspace]; + for (const flag of reusedFlags) { + const i = params.lastIndexOf(flag); + if (i >= 0) { + buildSettingsArgs.push(params[i], params[i + 1]); + } + } + + buildSettingsArgs.push("-showBuildSettings", "-json"); + + const xcodebuild = makeCommand("xcodebuild"); + const { status, stdout } = await xcodebuild(...buildSettingsArgs); + if (status !== 0) { + return undefined; + } + + const buildSettings: BuildSettings[] = JSON.parse(stdout); + return buildSettings.find( + ({ buildSettings }) => buildSettings.WRAPPER_EXTENSION === "app" + ); +} + +/** + * Returns device platform identifier for specified platform and destination. + */ +export function getDevicePlatformIdentifier( + buildParams: BuildParams +): Device["platform"] { + switch (buildParams.platform) { + case "ios": + return buildParams.destination === "device" + ? "com.apple.platform.iphoneos" + : "com.apple.platform.iphonesimulator"; + + case "macos": + return "com.apple.platform.macosx"; + + case "visionos": + return buildParams.destination === "device" + ? "com.apple.platform.xros" + : "com.apple.platform.xrsimulator"; + } +} + /** * Returns the path to the active developer directory. */ @@ -27,3 +102,47 @@ export async function parsePlist(app: string): Promise { ? JSON.parse(stdout) : new Error(`Failed to parse 'Info.plist' of '${app}'`); } + +/** + * Builds the specified `.xcworkspace`. + */ +export function xcodebuild( + xcworkspace: string, + params: BuildParams, + log = console.log +) { + const args = ["-workspace", xcworkspace]; + + const { scheme, configuration = "Debug" } = params; + if (scheme) { + args.push("-scheme", scheme); + } else { + const schemes = findSchemes(xcworkspace); + if (schemes.length > 0) { + if (schemes.length > 1) { + const choices = schemes.join(", "); + log(`Multiple schemes were found; picking the first one: ${choices}`); + log("If this is wrong, specify another scheme with `--scheme`"); + } + args.push("-scheme", schemes[0]); + } else { + log("No schemes were found; leaving it to Xcode to figure things out"); + log("If this is wrong, specify a scheme with `--scheme`"); + } + } + + args.push( + "-configuration", + configuration, + "-derivedDataPath", + path.join(path.dirname(xcworkspace), "DerivedData") + ); + + iosSpecificBuildFlags(params, args); + macosSpecificBuildFlags(params, args); + remoteSpecificBuildFlags(params, args); + + args.push("COMPILER_INDEX_STORE_ENABLE=NO", "build"); + + return spawn("xcodebuild", args); +} diff --git a/yarn.lock b/yarn.lock index 871932c44..77924b610 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3819,6 +3819,7 @@ __metadata: "@rnx-kit/metro-service": "npm:^3.1.6" "@rnx-kit/scripts": "npm:*" "@rnx-kit/third-party-notices": "npm:^1.3.4" + "@rnx-kit/tools-apple": "npm:^0.1.1" "@rnx-kit/tools-filesystem": "npm:*" "@rnx-kit/tools-language": "npm:^2.0.0" "@rnx-kit/tools-node": "npm:^2.1.1" @@ -4560,6 +4561,7 @@ __metadata: "@rnx-kit/tsconfig": "npm:*" "@types/node": "npm:^20.0.0" eslint: "npm:^8.56.0" + fast-xml-parser: "npm:^4.0.0" prettier: "npm:^3.0.0" typescript: "npm:^5.0.0" languageName: unknown