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