xref: /expo/tools/src/prebuilds/XcodeGen.ts (revision f860e1b6)
1import fs from 'fs-extra';
2import path from 'path';
3import semver from 'semver';
4
5import { Podspec } from '../CocoaPods';
6import { EXPOTOOLS_DIR, IOS_DIR } from '../Constants';
7import { arrayize, spawnAsync } from '../Utils';
8import {
9  ProjectSpec,
10  ProjectSpecDependency,
11  ProjectSpecPlatform,
12  XcodeConfig,
13} from './XcodeGen.types';
14
15const PODS_DIR = path.join(IOS_DIR, 'Pods');
16const PODS_PUBLIC_HEADERS_DIR = path.join(PODS_DIR, 'Headers', 'Public');
17const PODS_PRIVATE_HEADERS_DIR = path.join(PODS_DIR, 'Headers', 'Private');
18const PLATFORMS_MAPPING: Record<string, ProjectSpecPlatform> = {
19  ios: 'iOS',
20  osx: 'macOS',
21  macos: 'macOS',
22  tvos: 'tvOS',
23  watchos: 'watchOS',
24};
25
26export const INFO_PLIST_FILENAME = 'Info-generated.plist';
27export const GENERATED_MODULEMAP_FILENAME = 'generated.modulemap';
28
29/**
30 * Generates `.xcodeproj` from given project spec and saves it at given dir.
31 */
32export async function generateXcodeProjectAsync(dir: string, spec: ProjectSpec): Promise<string> {
33  const specPath = path.join(dir, `${spec.name}.spec.json`);
34
35  // Save the spec to the file so `xcodegen` can use it.
36  await fs.outputJSON(specPath, spec, {
37    spaces: 2,
38  });
39
40  // Generate `.xcodeproj` from given spec. The binary is provided by `@expo/xcodegen` package.
41  await spawnAsync('yarn', ['--silent', 'run', 'xcodegen', '--quiet', '--spec', specPath], {
42    cwd: EXPOTOOLS_DIR,
43    stdio: 'inherit',
44  });
45
46  // Remove temporary spec file.
47  await fs.remove(specPath);
48
49  return path.join(dir, `${spec.name}.xcodeproj`);
50}
51
52/**
53 * Creates `xcodegen` spec from the podspec. It's very naive, but covers all our cases so far.
54 */
55export async function createSpecFromPodspecAsync(
56  podspec: Podspec,
57  dependencyResolver: (dependencyName: string) => Promise<ProjectSpecDependency | null>
58): Promise<ProjectSpec> {
59  const platforms = Object.keys(podspec.platforms);
60  const deploymentTarget = platforms.reduce((acc, platform) => {
61    acc[PLATFORMS_MAPPING[platform]] = podspec.platforms[platform];
62    return acc;
63  }, {} as Record<ProjectSpecPlatform, string>);
64
65  const dependenciesNames = podspec.dependencies ? Object.keys(podspec.dependencies) : [];
66
67  const dependencies = (
68    await Promise.all(dependenciesNames.map((dependencyName) => dependencyResolver(dependencyName)))
69  ).filter(Boolean) as ProjectSpecDependency[];
70
71  const bundleId = podNameToBundleId(podspec.name);
72
73  return {
74    name: podspec.name,
75    targets: {
76      [podspec.name]: {
77        type: 'framework',
78        platform: platforms.map((platform) => PLATFORMS_MAPPING[platform]),
79        sources: [
80          {
81            path: '',
82            name: podspec.name,
83            createIntermediateGroups: true,
84            includes: arrayize(podspec.source_files),
85            excludes: [
86              INFO_PLIST_FILENAME,
87              `${podspec.name}.spec.json`,
88              '*.xcodeproj',
89              '*.xcframework',
90              '*.podspec',
91              ...arrayize(podspec.exclude_files),
92            ],
93            compilerFlags: podspec.compiler_flags,
94          },
95        ],
96        dependencies: [
97          ...arrayize(podspec.frameworks).map((framework) => ({
98            sdk: `${framework}.framework`,
99          })),
100          ...dependencies,
101        ],
102        settings: {
103          base: mergeXcodeConfigs(podspec.pod_target_xcconfig ?? {}, {
104            MACH_O_TYPE: 'staticlib',
105          }),
106        },
107        info: {
108          path: INFO_PLIST_FILENAME,
109          properties: mergeXcodeConfigs(
110            {
111              CFBundleIdentifier: bundleId,
112              CFBundleName: podspec.name,
113              CFBundleShortVersionString: podspec.version,
114              CFBundleVersion: String(semver.major(podspec.version)),
115            },
116            podspec.info_plist ?? {}
117          ),
118        },
119      },
120    },
121    options: {
122      minimumXcodeGenVersion: '2.18.0',
123      deploymentTarget,
124    },
125    settings: {
126      base: {
127        PRODUCT_BUNDLE_IDENTIFIER: bundleId,
128        IPHONEOS_DEPLOYMENT_TARGET: podspec.platforms.ios,
129        FRAMEWORK_SEARCH_PATHS: constructFrameworkSearchPaths(dependencies),
130        HEADER_SEARCH_PATHS: constructHeaderSearchPaths(dependenciesNames),
131        MODULEMAP_FILE: podspec.modulemap_file ?? '',
132
133        // Suppresses deprecation warnings coming from frameworks like OpenGLES.
134        VALIDATE_WORKSPACE_SKIPPED_SDK_FRAMEWORKS: arrayize(podspec.frameworks).join(' '),
135      },
136    },
137  };
138}
139
140function constructFrameworkSearchPaths(dependencies: ProjectSpecDependency[]): string {
141  const frameworks = dependencies.filter((dependency) => !!dependency.framework) as {
142    framework: string;
143  }[];
144
145  return (
146    '$(inherited) ' + frameworks.map((dependency) => path.dirname(dependency.framework)).join(' ')
147  ).trim();
148}
149
150function constructHeaderSearchPaths(dependencies: string[]): string {
151  // A set of pod names to include in header search paths.
152  // For simplicity, we add some more (usually transitive) than direct dependencies.
153  const podsToSearchForHeaders = new Set([
154    // Some pods' have headers at its root level (ZXingObjC and all our modules).
155    // Without this we would have to use `#import <ZXingObjC*.h>` instead of `#import <ZXingObjC/ZXingObjC*.h>`
156    '',
157
158    ...dependencies,
159
160    'DoubleConversion',
161    'React-callinvoker',
162    'React-Core',
163    'React-cxxreact',
164    'React-jsi',
165    'React-jsiexecutor',
166    'React-jsinspector',
167    'Yoga',
168    'glog',
169  ]);
170
171  function headerSearchPathsForDir(dir: string): string {
172    return [...podsToSearchForHeaders]
173      .map((podName) => '"' + path.join(dir, podName) + '"')
174      .join(' ');
175  }
176
177  return [
178    '$(inherited)',
179    headerSearchPathsForDir(PODS_PUBLIC_HEADERS_DIR),
180    headerSearchPathsForDir(PODS_PRIVATE_HEADERS_DIR),
181  ].join(' ');
182}
183
184/**
185 * Merges Xcode config from left to right.
186 * Values containing `$(inherited)` are properly taken into account.
187 */
188function mergeXcodeConfigs(...configs: XcodeConfig[]): XcodeConfig {
189  const result: XcodeConfig = {};
190
191  for (const config of configs) {
192    for (const key in config) {
193      const value = config[key];
194      result[key] = mergeXcodeConfigValue(result[key], value);
195    }
196  }
197  return result;
198}
199
200function mergeXcodeConfigValue(prevValue: string | undefined, nextValue: string): string {
201  if (prevValue && typeof prevValue === 'string' && prevValue.includes('$(inherited)')) {
202    return '$(inherited) ' + (prevValue + ' ' + nextValue).replace(/\\s*$\(inherited\)\s*/g, ' ');
203  }
204  return nextValue;
205}
206
207/**
208 * Simple conversion from pod name to framework's bundle identifier.
209 */
210function podNameToBundleId(podName: string): string {
211  return podName
212    .replace(/^UM/, 'unimodules')
213    .replace(/^EX/, 'expo')
214    .replace(/(\_|[^\w\d\.])+/g, '.')
215    .replace(/\.*([A-Z]+)/g, (_, p1) => `.${p1.toLowerCase()}`);
216}
217
218/**
219 * Generate custom modulemap for expo-modules-core which needs to make React-Core headers modular
220 */
221export async function generateExpoModulesCoreModulemapAsync(destDir: string): Promise<string> {
222  const expoModulesCoreUmbrellaHeaderName = 'ExpoModulesCore-umbrella.h';
223  const expoModulesCoreUmbrellaHeader = path.join(
224    PODS_PUBLIC_HEADERS_DIR,
225    'ExpoModulesCore',
226    expoModulesCoreUmbrellaHeaderName
227  );
228  await fs.copyFile(
229    expoModulesCoreUmbrellaHeader,
230    path.join(destDir, expoModulesCoreUmbrellaHeaderName)
231  );
232
233  const reactCoreHeaderDir = path.join(PODS_PUBLIC_HEADERS_DIR, 'React-Core');
234  const yogaUmbrellaHeader = path.join(PODS_PUBLIC_HEADERS_DIR, 'Yoga', 'Yoga-umbrella.h');
235  const modulemapContent = `
236framework module ExpoModulesCore {
237  umbrella header "ExpoModulesCore.h"
238  export *
239  module * { export * }
240
241  module React {
242    umbrella "${reactCoreHeaderDir}"
243    export *
244    module * { export * }
245  }
246
247  module Yoga {
248    umbrella header "${yogaUmbrellaHeader}"
249    export *
250    module * { export * }
251  }
252}
253`;
254
255  const modulemapFile = path.join(destDir, GENERATED_MODULEMAP_FILENAME);
256  await fs.writeFile(modulemapFile, modulemapContent);
257  return modulemapFile;
258}
259