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