Skip to content

Commit db82e27

Browse files
authored
fix(cli): add support for building Windows apps (#3856)
1 parent e8bbe48 commit db82e27

File tree

19 files changed

+246
-66
lines changed

19 files changed

+246
-66
lines changed

.changeset/calm-spoons-live.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@rnx-kit/cli": patch
3+
---
4+
5+
Add support for building Windows apps

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"@babel/core": "^7.20.0",
7777
"@babel/preset-env": "^7.20.0",
7878
"@react-native-community/cli-types": "^20.0.0",
79+
"@react-native-windows/cli": "^0.79.0",
7980
"@rnx-kit/eslint-config": "*",
8081
"@rnx-kit/jest-preset": "*",
8182
"@rnx-kit/scripts": "*",

packages/cli/src/build.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import type { Config } from "@react-native-community/cli-types";
22
import { InvalidArgumentError } from "commander";
3-
import { RNX_FAST_PATH } from "./bin/constants";
4-
import { buildAndroid } from "./build/android";
5-
import { setCcacheDir, setCcacheHome } from "./build/ccache";
6-
import { buildIOS } from "./build/ios";
7-
import { buildMacOS } from "./build/macos";
3+
import { RNX_FAST_PATH } from "./bin/constants.ts";
4+
import { buildAndroid } from "./build/android.ts";
5+
import { setCcacheDir, setCcacheHome } from "./build/ccache.ts";
6+
import { buildIOS } from "./build/ios.ts";
7+
import { buildMacOS } from "./build/macos.ts";
88
import type {
99
BuildConfiguration,
1010
DeviceType,
1111
InputParams,
12-
} from "./build/types";
12+
} from "./build/types.ts";
13+
import { buildWindows } from "./build/windows.ts";
1314

1415
function asConfiguration(configuration: string): BuildConfiguration {
1516
switch (configuration) {
@@ -42,10 +43,11 @@ function asSupportedPlatform(platform: string): InputParams["platform"] {
4243
case "ios":
4344
case "macos":
4445
case "visionos":
46+
case "windows":
4547
return platform;
4648
default:
4749
throw new InvalidArgumentError(
48-
"Supported platforms: 'android', 'ios', 'macos', 'visionos'."
50+
"Supported platforms: 'android', 'ios', 'macos', 'visionos', 'windows'."
4951
);
5052
}
5153
}
@@ -65,6 +67,9 @@ export function rnxBuild(
6567

6668
case "macos":
6769
return buildMacOS(config, buildParams);
70+
71+
case "windows":
72+
return buildWindows(config, buildParams, argv);
6873
}
6974
}
7075

@@ -82,6 +87,11 @@ export const rnxBuildCommand = {
8287
description: "Target platform",
8388
parse: asSupportedPlatform,
8489
},
90+
{
91+
name: "--solution <string>",
92+
description:
93+
"Path, relative to project root, of the Visual Studio solution to build (Windows only)",
94+
},
8595
{
8696
name: "--workspace <string>",
8797
description:

packages/cli/src/build/android.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { Config } from "@react-native-community/cli-types";
22
import { invalidateState } from "@rnx-kit/tools-react-native/cache";
33
import ora from "ora";
4-
import type { AndroidBuildParams } from "./types";
5-
import { watch } from "./watcher";
4+
import type { AndroidBuildParams } from "./types.ts";
5+
import { watch } from "./watcher.ts";
66

77
export type BuildResult = string | number | null;
88

packages/cli/src/build/apple.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Ora } from "ora";
2-
import type { AppleBuildParams } from "./types";
3-
import { watch } from "./watcher";
2+
import type { AppleBuildParams } from "./types.ts";
3+
import { watch } from "./watcher.ts";
44

55
export type BuildArgs = {
66
xcworkspace: string;

packages/cli/src/build/ios.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,21 @@ import type { Config } from "@react-native-community/cli-types";
22
import { invalidateState } from "@rnx-kit/tools-react-native/cache";
33
import * as path from "node:path";
44
import ora from "ora";
5-
import type { BuildResult } from "./apple";
6-
import { runBuild } from "./apple";
7-
import type { AppleInputParams } from "./types";
5+
import type { BuildResult } from "./apple.ts";
6+
import { runBuild } from "./apple.ts";
7+
import type { AppleInputParams } from "./types.ts";
88

99
export function buildIOS(
1010
config: Config,
1111
buildParams: AppleInputParams,
1212
logger = ora()
1313
): Promise<BuildResult> {
1414
const { platform } = buildParams;
15+
if (process.platform !== "darwin") {
16+
logger.fail(`${platform} builds can only be performed on macOS hosts`);
17+
return Promise.resolve(1);
18+
}
19+
1520
const { sourceDir, xcodeProject } = config.project[platform] ?? {};
1621
if (!sourceDir || !xcodeProject) {
1722
invalidateState();

packages/cli/src/build/macos.ts

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,44 +3,54 @@ import { invalidateState } from "@rnx-kit/tools-react-native/cache";
33
import * as fs from "node:fs";
44
import * as path from "node:path";
55
import ora from "ora";
6-
import type { BuildResult } from "./apple";
7-
import { runBuild } from "./apple";
8-
import type { AppleInputParams } from "./types";
6+
import type { BuildResult } from "./apple.ts";
7+
import { runBuild } from "./apple.ts";
8+
import type { AppleInputParams } from "./types.ts";
99

10-
function findXcodeWorkspaces(searchDir: string) {
11-
return fs.existsSync(searchDir)
10+
function findXcodeWorkspaces(
11+
searchDir: string,
12+
logger: ora.Ora
13+
): string | undefined {
14+
const workspaces = fs.existsSync(searchDir)
1215
? fs.readdirSync(searchDir).filter((file) => file.endsWith(".xcworkspace"))
1316
: [];
14-
}
1517

16-
export function buildMacOS(
17-
_config: Config,
18-
{ workspace, ...buildParams }: AppleInputParams,
19-
logger = ora()
20-
): Promise<BuildResult> {
21-
if (workspace) {
22-
return runBuild(workspace, buildParams, logger);
23-
}
24-
25-
const sourceDir = "macos";
26-
const workspaces = findXcodeWorkspaces(sourceDir);
2718
if (workspaces.length === 0) {
2819
invalidateState();
2920
process.exitCode = 1;
3021
logger.fail(
3122
"No Xcode workspaces were found; specify an Xcode workspace with `--workspace`"
3223
);
33-
return Promise.resolve(1);
24+
return undefined;
3425
}
3526

3627
if (workspaces.length > 1) {
37-
logger.fail(
28+
logger.info(
3829
`Multiple Xcode workspaces were found; picking the first one: ${workspaces.join(", ")}`
3930
);
40-
logger.fail(
31+
logger.info(
4132
"If this is wrong, specify another workspace with `--workspace`"
4233
);
4334
}
4435

45-
return runBuild(path.join(sourceDir, workspaces[0]), buildParams, logger);
36+
return path.join(searchDir, workspaces[0]);
37+
}
38+
39+
export function buildMacOS(
40+
_config: Config,
41+
{ workspace, ...buildParams }: AppleInputParams,
42+
logger = ora()
43+
): Promise<BuildResult> {
44+
if (process.platform !== "darwin") {
45+
logger.fail("macOS builds can only be performed on macOS hosts");
46+
return Promise.resolve(1);
47+
}
48+
49+
const sourceDir = "macos";
50+
const xcworkspace = workspace || findXcodeWorkspaces(sourceDir, logger);
51+
if (!xcworkspace) {
52+
return Promise.resolve(1);
53+
}
54+
55+
return runBuild(xcworkspace, buildParams, logger);
4656
}

packages/cli/src/build/types.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,19 @@ export type AppleInputParams = AppleBuildParams & {
4747
workspace?: string;
4848
};
4949

50-
export type InputParams = AndroidInputParams | AppleInputParams;
50+
export type WindowsBuildParams = {
51+
platform: "windows";
52+
configuration?: BuildConfiguration;
53+
architecture?: "arm64" | "x64";
54+
launch?: boolean;
55+
deploy?: boolean;
56+
};
57+
58+
export type WindowsInputParams = WindowsBuildParams & {
59+
solution?: string;
60+
};
61+
62+
export type InputParams =
63+
| AndroidInputParams
64+
| AppleInputParams
65+
| WindowsInputParams;

packages/cli/src/build/windows.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type { Command, Config } from "@react-native-community/cli-types";
2+
import { invalidateState } from "@rnx-kit/tools-react-native/cache";
3+
import * as fs from "node:fs";
4+
import * as os from "node:os";
5+
import * as path from "node:path";
6+
import ora from "ora";
7+
import type { WindowsBuildParams, WindowsInputParams } from "./types.ts";
8+
9+
export type BuildArgs = {
10+
solution: string;
11+
args: string[];
12+
};
13+
14+
export type BuildResult = BuildArgs | number | null;
15+
16+
function findRunCommand(startDir: string): Command | undefined {
17+
try {
18+
const fromProjectRoot = { paths: [startDir] };
19+
const rnwPath = require.resolve(
20+
"react-native-windows/package.json",
21+
fromProjectRoot
22+
);
23+
24+
const fromRnwDir = { paths: [path.dirname(rnwPath)] };
25+
const cliPath = require.resolve("@react-native-windows/cli", fromRnwDir);
26+
27+
const cli = require(cliPath) as typeof import("@react-native-windows/cli");
28+
return cli.commands.find((cmd) => cmd.name === "run-windows");
29+
} catch (_) {
30+
// Handled by caller
31+
}
32+
33+
return undefined;
34+
}
35+
36+
function findSolution(searchDir: string, logger: ora.Ora): string | undefined {
37+
const solutions = fs.existsSync(searchDir)
38+
? fs.readdirSync(searchDir).filter((file) => file.endsWith(".sln"))
39+
: [];
40+
41+
if (solutions.length === 0) {
42+
invalidateState();
43+
process.exitCode = 1;
44+
logger.fail(
45+
"No Visual Studio solutions were found; specify a Visual Studio solution with `--solution`"
46+
);
47+
return undefined;
48+
}
49+
50+
if (solutions.length > 1) {
51+
logger.info(
52+
`Multiple Visual Studio solutions were found; picking the first one: ${solutions.join(", ")}`
53+
);
54+
logger.info("If this is wrong, specify another solution with `--solution`");
55+
}
56+
57+
return path.join(searchDir, solutions[0]);
58+
}
59+
60+
function toRunWindowsOptions(
61+
sln: string,
62+
{ root }: Config,
63+
{ configuration, architecture, launch, deploy }: WindowsBuildParams
64+
) {
65+
return {
66+
release: configuration === "Release",
67+
root,
68+
arch: architecture ?? os.arch(),
69+
packager: false,
70+
bundle: false,
71+
launch: Boolean(launch),
72+
autolink: true,
73+
build: true,
74+
deploy: Boolean(deploy),
75+
sln,
76+
};
77+
}
78+
79+
export function runWindowsCommand(
80+
config: Config,
81+
params: WindowsInputParams,
82+
logger: ora.Ora,
83+
callback: (
84+
solution: string,
85+
run: Command["func"],
86+
options: Record<string, unknown>
87+
) => Promise<BuildResult>
88+
) {
89+
if (process.platform !== "win32") {
90+
logger.fail("Windows builds can only be performed on Windows hosts");
91+
return Promise.resolve(1);
92+
}
93+
94+
const sourceDir = "windows";
95+
const solution = params.solution || findSolution(sourceDir, logger);
96+
if (!solution) {
97+
return Promise.resolve(1);
98+
}
99+
100+
const runCommand = findRunCommand(config.root);
101+
if (!runCommand) {
102+
logger.fail(
103+
"Failed to find `@react-native-windows/cli`, make sure `react-native-windows` is installed."
104+
);
105+
return Promise.resolve(1);
106+
}
107+
108+
const options = toRunWindowsOptions(solution, config, params);
109+
return callback(solution, runCommand.func, options);
110+
}
111+
112+
export function buildWindows(
113+
config: Config,
114+
params: WindowsInputParams,
115+
additionalArgs: string[],
116+
logger = ora()
117+
): Promise<BuildResult> {
118+
return runWindowsCommand(config, params, logger, (solution, run, options) => {
119+
const build = run(additionalArgs, config, options);
120+
return build
121+
? build.then(() => ({ solution, args: additionalArgs }))
122+
: Promise.resolve(1);
123+
});
124+
}

packages/cli/src/run.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { Config } from "@react-native-community/cli-types";
2-
import { RNX_FAST_PATH } from "./bin/constants";
3-
import { rnxBuildCommand } from "./build";
4-
import type { InputParams } from "./build/types";
5-
import { runAndroid } from "./run/android";
6-
import { runIOS } from "./run/ios";
7-
import { runMacOS } from "./run/macos";
2+
import { RNX_FAST_PATH } from "./bin/constants.ts";
3+
import { rnxBuildCommand } from "./build.ts";
4+
import type { InputParams } from "./build/types.ts";
5+
import { runAndroid } from "./run/android.ts";
6+
import { runIOS } from "./run/ios.ts";
7+
import { runMacOS } from "./run/macos.ts";
8+
import { runWindows } from "./run/windows.ts";
89

910
export function rnxRun(
1011
argv: string[],
@@ -21,6 +22,9 @@ export function rnxRun(
2122

2223
case "macos":
2324
return runMacOS(config, buildParams);
25+
26+
case "windows":
27+
return runWindows(config, buildParams, argv);
2428
}
2529
}
2630

0 commit comments

Comments
 (0)