Skip to content

Commit

Permalink
feat(tools-apple): add primitives for build and run commands (#3305)
Browse files Browse the repository at this point in the history
  • Loading branch information
tido64 authored Aug 28, 2024
1 parent 8b18585 commit ba1a1fd
Show file tree
Hide file tree
Showing 22 changed files with 771 additions and 30 deletions.
5 changes: 5 additions & 0 deletions .changeset/moody-knives-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rnx-kit/tools-apple": patch
---

Added primitives for building 'build' and 'run' commands
8 changes: 8 additions & 0 deletions .changeset/twenty-eels-eat.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
98 changes: 98 additions & 0 deletions packages/cli/src/build.ts
Original file line number Diff line number Diff line change
@@ -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 <string>",
description: "Target platform",
parse: asSupportedPlatform,
},
{
name: "--workspace <string>",
description:
"Path, relative to project root, of the Xcode workspace to build (macOS only)",
},
{
name: "--scheme <string>",
description: "Name of scheme to build",
},
{
name: "--configuration <string>",
description:
"Build configuration for building the app; 'Debug' or 'Release'",
default: "Debug",
parse: asConfiguration,
},
{
name: "--destination <string>",
description:
"Destination of the built app; 'device', 'emulator', or 'simulator'",
default: "simulator",
parse: asDestination,
},
],
};
74 changes: 74 additions & 0 deletions packages/cli/src/build/apple.ts
Original file line number Diff line number Diff line change
@@ -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<BuildResult> {
return import("@rnx-kit/tools-apple").then(({ xcodebuild }) => {
return new Promise<BuildResult>((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);
}
});
});
});
}
32 changes: 32 additions & 0 deletions packages/cli/src/build/ios.ts
Original file line number Diff line number Diff line change
@@ -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<BuildResult> {
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);
}
43 changes: 43 additions & 0 deletions packages/cli/src/build/macos.ts
Original file line number Diff line number Diff line change
@@ -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<BuildResult> {
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);
}
5 changes: 3 additions & 2 deletions packages/cli/src/helpers/parsers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { InvalidArgumentError } from "commander";
import type { TransformProfile } from "metro-babel-transformer";
import * as path from "node:path";

Expand All @@ -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'.`);
}
}

Expand Down Expand Up @@ -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("', '")}'.`);
}
}
}
2 changes: 2 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions packages/cli/src/run.ts
Original file line number Diff line number Diff line change
@@ -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 <string>",
description: "The name of the device to launch the app in",
},
],
};
Loading

0 comments on commit ba1a1fd

Please sign in to comment.