xref: /expo/tools/src/prebuilds/XcodeProject.ts (revision dad749c2)
1import fs from 'fs-extra';
2import os from 'os';
3import path from 'path';
4
5import { formatXcodeBuildOutput } from '../Formatter';
6import { spawnAsync } from '../Utils';
7import { generateXcodeProjectAsync } from './XcodeGen';
8import { ProjectSpec } from './XcodeGen.types';
9import { Flavor, Framework, XcodebuildSettings } from './XcodeProject.types';
10
11/**
12 * Path to the shared derived data directory.
13 */
14const SHARED_DERIVED_DATA_DIR = path.join(os.tmpdir(), 'Expo/DerivedData');
15
16/**
17 * Path to the products in derived data directory. We pick `.framework` files from there.
18 */
19const PRODUCTS_DIR = path.join(SHARED_DERIVED_DATA_DIR, 'Build/Products');
20
21/**
22 * A class representing single Xcode project and operating on its `.xcodeproj` file.
23 */
24export default class XcodeProject {
25  /**
26   * Creates `XcodeProject` instance from given path to `.xcodeproj` file.
27   */
28  static async fromXcodeprojPathAsync(xcodeprojPath: string): Promise<XcodeProject> {
29    if (!(await fs.pathExists(xcodeprojPath))) {
30      throw new Error(`Xcodeproj not found at path: ${xcodeprojPath}`);
31    }
32    return new XcodeProject(xcodeprojPath);
33  }
34
35  /**
36   * Generates `.xcodeproj` file based on given spec and returns it.
37   */
38  static async generateProjectFromSpec(dir: string, spec: ProjectSpec): Promise<XcodeProject> {
39    const xcodeprojPath = await generateXcodeProjectAsync(dir, spec);
40    return new XcodeProject(xcodeprojPath);
41  }
42
43  /**
44   * Name of the project. It should stay in sync with its filename.
45   */
46  name: string;
47
48  /**
49   * Root directory of the project and at which the `.xcodeproj` file is placed.
50   */
51  rootDir: string;
52
53  constructor(xcodeprojPath: string) {
54    this.name = path.basename(xcodeprojPath, '.xcodeproj');
55    this.rootDir = path.dirname(xcodeprojPath);
56  }
57
58  /**
59   * Returns output path to where the `.xcframework` file will be stored after running `buildXcframeworkAsync`.
60   */
61  getXcframeworkPath(): string {
62    return path.join(this.rootDir, `${this.name}.xcframework`);
63  }
64
65  /**
66   * Builds `.framework` for given target name and flavor specifying,
67   * configuration, the SDK and a list of architectures to compile against.
68   */
69  async buildFrameworkAsync(
70    target: string,
71    flavor: Flavor,
72    options?: XcodebuildSettings
73  ): Promise<Framework> {
74    await this.xcodebuildAsync(
75      [
76        'build',
77        '-project',
78        `${this.name}.xcodeproj`,
79        '-scheme',
80        `${target}_iOS`,
81        '-configuration',
82        flavor.configuration,
83        '-sdk',
84        flavor.sdk,
85        ...spreadArgs('-arch', flavor.archs),
86        '-derivedDataPath',
87        SHARED_DERIVED_DATA_DIR,
88      ],
89      options
90    );
91
92    const frameworkPath = flavorToFrameworkPath(target, flavor);
93    const stat = await fs.lstat(path.join(frameworkPath, target));
94
95    // Remove `Headers` as each our module contains headers as part of the provided source code
96    // and CocoaPods exposes them through HEADER_SEARCH_PATHS either way.
97    await fs.remove(path.join(frameworkPath, 'Headers'));
98
99    // `_CodeSignature` is apparently generated only for simulator, afaik we don't need it.
100    await fs.remove(path.join(frameworkPath, '_CodeSignature'));
101
102    return {
103      target,
104      flavor,
105      frameworkPath,
106      binarySize: stat.size,
107    };
108  }
109
110  /**
111   * Builds universal `.xcframework` from given frameworks.
112   */
113  async buildXcframeworkAsync(
114    frameworks: Framework[],
115    options?: XcodebuildSettings
116  ): Promise<string> {
117    const frameworkPaths = frameworks.map((framework) => framework.frameworkPath);
118    const outputPath = this.getXcframeworkPath();
119
120    await fs.remove(outputPath);
121
122    await this.xcodebuildAsync(
123      ['-create-xcframework', ...spreadArgs('-framework', frameworkPaths), '-output', outputPath],
124      options
125    );
126    return outputPath;
127  }
128
129  /**
130   * Removes `.xcframework` artifact produced by `buildXcframeworkAsync`.
131   */
132  async cleanXcframeworkAsync(): Promise<void> {
133    await fs.remove(this.getXcframeworkPath());
134  }
135
136  /**
137   * Generic function spawning `xcodebuild` process.
138   */
139  async xcodebuildAsync(args: string[], settings?: XcodebuildSettings) {
140    // `xcodebuild` writes error details to stdout but we don't want to pollute our output if nothing wrong happens.
141    // Spawn it quietly, pipe stderr to stdout and pass it to the current process stdout only when it fails.
142    const finalArgs = ['-quiet', ...args, '2>&1'];
143
144    if (settings) {
145      finalArgs.unshift(
146        ...Object.entries(settings).map(([key, value]) => {
147          return `${key}=${parseXcodeSettingsValue(value)}`;
148        })
149      );
150    }
151    try {
152      await spawnAsync('xcodebuild', finalArgs, {
153        cwd: this.rootDir,
154        shell: true,
155        stdio: ['ignore', 'pipe', 'inherit'],
156      });
157    } catch (e) {
158      // Print formatted Xcode logs (merged from stdout and stderr).
159      process.stdout.write(formatXcodeBuildOutput(e.stdout));
160      throw e;
161    }
162  }
163
164  /**
165   * Cleans shared derived data directory.
166   */
167  static async cleanBuildFolderAsync(): Promise<void> {
168    await fs.remove(SHARED_DERIVED_DATA_DIR);
169  }
170}
171
172/**
173 * Returns a path to the prebuilt framework for given flavor.
174 */
175function flavorToFrameworkPath(target: string, flavor: Flavor): string {
176  return path.join(PRODUCTS_DIR, `${flavor.configuration}-${flavor.sdk}`, `${target}.framework`);
177}
178
179/**
180 * Spreads given args under specific flag.
181 * Example: `spreadArgs('-arch', ['arm64', 'x86_64'])` returns `['-arch', 'arm64', '-arch', 'x86_64']`
182 */
183function spreadArgs(argName: string, args: string[]): string[] {
184  return ([] as string[]).concat(...args.map((arg) => [argName, arg]));
185}
186
187/**
188 * Converts boolean values to its Xcode build settings format. Value of other type just passes through.
189 */
190function parseXcodeSettingsValue(value: string | boolean): string {
191  if (typeof value === 'boolean') {
192    return value ? 'YES' : 'NO';
193  }
194  return value;
195}
196