1import { existsSync, readFileSync } from 'fs';
2import { sync as globSync } from 'glob';
3import * as path from 'path';
4
5import * as Entitlements from './Entitlements';
6import { UnexpectedError } from '../utils/errors';
7import { addWarningIOS } from '../utils/warnings';
8
9const ignoredPaths = ['**/@(Carthage|Pods|vendor|node_modules)/**'];
10
11interface ProjectFile<L extends string = string> {
12  path: string;
13  language: L;
14  contents: string;
15}
16
17type AppleLanguage = 'objc' | 'objcpp' | 'swift';
18
19export type AppDelegateProjectFile = ProjectFile<AppleLanguage>;
20
21export function getAppDelegateHeaderFilePath(projectRoot: string): string {
22  const [using, ...extra] = globSync('ios/*/AppDelegate.h', {
23    absolute: true,
24    cwd: projectRoot,
25    ignore: ignoredPaths,
26  });
27
28  if (!using) {
29    throw new UnexpectedError(
30      `Could not locate a valid AppDelegate header at root: "${projectRoot}"`
31    );
32  }
33
34  if (extra.length) {
35    warnMultipleFiles({
36      tag: 'app-delegate-header',
37      fileName: 'AppDelegate',
38      projectRoot,
39      using,
40      extra,
41    });
42  }
43
44  return using;
45}
46
47export function getAppDelegateFilePath(projectRoot: string): string {
48  const [using, ...extra] = globSync('ios/*/AppDelegate.@(m|mm|swift)', {
49    absolute: true,
50    cwd: projectRoot,
51    ignore: ignoredPaths,
52  });
53
54  if (!using) {
55    throw new UnexpectedError(`Could not locate a valid AppDelegate at root: "${projectRoot}"`);
56  }
57
58  if (extra.length) {
59    warnMultipleFiles({
60      tag: 'app-delegate',
61      fileName: 'AppDelegate',
62      projectRoot,
63      using,
64      extra,
65    });
66  }
67
68  return using;
69}
70
71export function getAppDelegateObjcHeaderFilePath(projectRoot: string): string {
72  const [using, ...extra] = globSync('ios/*/AppDelegate.h', {
73    absolute: true,
74    cwd: projectRoot,
75    ignore: ignoredPaths,
76  });
77
78  if (!using) {
79    throw new UnexpectedError(`Could not locate a valid AppDelegate.h at root: "${projectRoot}"`);
80  }
81
82  if (extra.length) {
83    warnMultipleFiles({
84      tag: 'app-delegate-objc-header',
85      fileName: 'AppDelegate.h',
86      projectRoot,
87      using,
88      extra,
89    });
90  }
91
92  return using;
93}
94
95function getLanguage(filePath: string): AppleLanguage {
96  const extension = path.extname(filePath);
97  switch (extension) {
98    case '.mm':
99      return 'objcpp';
100    case '.m':
101    case '.h':
102      return 'objc';
103    case '.swift':
104      return 'swift';
105    default:
106      throw new UnexpectedError(`Unexpected iOS file extension: ${extension}`);
107  }
108}
109
110export function getFileInfo(filePath: string) {
111  return {
112    path: path.normalize(filePath),
113    contents: readFileSync(filePath, 'utf8'),
114    language: getLanguage(filePath),
115  };
116}
117
118export function getAppDelegate(projectRoot: string): AppDelegateProjectFile {
119  const filePath = getAppDelegateFilePath(projectRoot);
120  return getFileInfo(filePath);
121}
122
123export function getSourceRoot(projectRoot: string): string {
124  const appDelegate = getAppDelegate(projectRoot);
125  return path.dirname(appDelegate.path);
126}
127
128export function findSchemePaths(projectRoot: string): string[] {
129  return globSync('ios/*.xcodeproj/xcshareddata/xcschemes/*.xcscheme', {
130    absolute: true,
131    cwd: projectRoot,
132    ignore: ignoredPaths,
133  });
134}
135
136export function findSchemeNames(projectRoot: string): string[] {
137  const schemePaths = findSchemePaths(projectRoot);
138  return schemePaths.map((schemePath) => path.parse(schemePath).name);
139}
140
141export function getAllXcodeProjectPaths(projectRoot: string): string[] {
142  const iosFolder = 'ios';
143  const pbxprojPaths = globSync('ios/**/*.xcodeproj', { cwd: projectRoot, ignore: ignoredPaths })
144    .filter(
145      (project) => !/test|example|sample/i.test(project) || path.dirname(project) === iosFolder
146    )
147    // sort alphabetically to ensure this works the same across different devices (Fail in CI (linux) without this)
148    .sort()
149    .sort((a, b) => {
150      const isAInIos = path.dirname(a) === iosFolder;
151      const isBInIos = path.dirname(b) === iosFolder;
152      // preserve previous sort order
153      if ((isAInIos && isBInIos) || (!isAInIos && !isBInIos)) {
154        return 0;
155      }
156      return isAInIos ? -1 : 1;
157    });
158
159  if (!pbxprojPaths.length) {
160    throw new UnexpectedError(
161      `Failed to locate the ios/*.xcodeproj files relative to path "${projectRoot}".`
162    );
163  }
164  return pbxprojPaths.map((value) => path.join(projectRoot, value));
165}
166
167/**
168 * Get the pbxproj for the given path
169 */
170export function getXcodeProjectPath(projectRoot: string): string {
171  const [using, ...extra] = getAllXcodeProjectPaths(projectRoot);
172
173  if (extra.length) {
174    warnMultipleFiles({
175      tag: 'xcodeproj',
176      fileName: '*.xcodeproj',
177      projectRoot,
178      using,
179      extra,
180    });
181  }
182
183  return using;
184}
185
186export function getAllPBXProjectPaths(projectRoot: string): string[] {
187  const projectPaths = getAllXcodeProjectPaths(projectRoot);
188  const paths = projectPaths
189    .map((value) => path.join(value, 'project.pbxproj'))
190    .filter((value) => existsSync(value));
191
192  if (!paths.length) {
193    throw new UnexpectedError(
194      `Failed to locate the ios/*.xcodeproj/project.pbxproj files relative to path "${projectRoot}".`
195    );
196  }
197  return paths;
198}
199
200export function getPBXProjectPath(projectRoot: string): string {
201  const [using, ...extra] = getAllPBXProjectPaths(projectRoot);
202
203  if (extra.length) {
204    warnMultipleFiles({
205      tag: 'project-pbxproj',
206      fileName: 'project.pbxproj',
207      projectRoot,
208      using,
209      extra,
210    });
211  }
212
213  return using;
214}
215
216export function getAllInfoPlistPaths(projectRoot: string): string[] {
217  const paths = globSync('ios/*/Info.plist', {
218    absolute: true,
219    cwd: projectRoot,
220    ignore: ignoredPaths,
221  }).sort(
222    // longer name means more suffixes, we want the shortest possible one to be first.
223    (a, b) => a.length - b.length
224  );
225
226  if (!paths.length) {
227    throw new UnexpectedError(
228      `Failed to locate Info.plist files relative to path "${projectRoot}".`
229    );
230  }
231  return paths;
232}
233
234export function getInfoPlistPath(projectRoot: string): string {
235  const [using, ...extra] = getAllInfoPlistPaths(projectRoot);
236
237  if (extra.length) {
238    warnMultipleFiles({
239      tag: 'info-plist',
240      fileName: 'Info.plist',
241      projectRoot,
242      using,
243      extra,
244    });
245  }
246
247  return using;
248}
249
250export function getAllEntitlementsPaths(projectRoot: string): string[] {
251  const paths = globSync('ios/*/*.entitlements', {
252    absolute: true,
253    cwd: projectRoot,
254    ignore: ignoredPaths,
255  });
256  return paths;
257}
258
259/**
260 * @deprecated: use Entitlements.getEntitlementsPath instead
261 */
262export function getEntitlementsPath(projectRoot: string): string | null {
263  return Entitlements.getEntitlementsPath(projectRoot);
264}
265
266export function getSupportingPath(projectRoot: string): string {
267  return path.resolve(projectRoot, 'ios', path.basename(getSourceRoot(projectRoot)), 'Supporting');
268}
269
270export function getExpoPlistPath(projectRoot: string): string {
271  const supportingPath = getSupportingPath(projectRoot);
272  return path.join(supportingPath, 'Expo.plist');
273}
274
275function warnMultipleFiles({
276  tag,
277  fileName,
278  projectRoot,
279  using,
280  extra,
281}: {
282  tag: string;
283  fileName: string;
284  projectRoot?: string;
285  using: string;
286  extra: string[];
287}) {
288  const usingPath = projectRoot ? path.relative(projectRoot, using) : using;
289  const extraPaths = projectRoot ? extra.map((v) => path.relative(projectRoot, v)) : extra;
290  addWarningIOS(
291    `paths-${tag}`,
292    `Found multiple ${fileName} file paths, using "${usingPath}". Ignored paths: ${JSON.stringify(
293      extraPaths
294    )}`
295  );
296}
297