1import { ExpoConfig } from '@expo/config-types';
2import assert from 'assert';
3import path from 'path';
4import slugify from 'slugify';
5import xcode, {
6  PBXFile,
7  PBXGroup,
8  PBXNativeTarget,
9  PBXProject,
10  UUID,
11  XCBuildConfiguration,
12  XCConfigurationList,
13  XcodeProject,
14} from 'xcode';
15import pbxFile from 'xcode/lib/pbxFile';
16
17import { trimQuotes } from './string';
18import { addWarningIOS } from '../../utils/warnings';
19import * as Paths from '../Paths';
20
21export type ProjectSectionEntry = [string, PBXProject];
22
23export type NativeTargetSection = Record<string, PBXNativeTarget>;
24
25export type NativeTargetSectionEntry = [string, PBXNativeTarget];
26
27export type ConfigurationLists = Record<string, XCConfigurationList>;
28
29export type ConfigurationListEntry = [string, XCConfigurationList];
30
31export type ConfigurationSectionEntry = [string, XCBuildConfiguration];
32
33export function getProjectName(projectRoot: string) {
34  const sourceRoot = Paths.getSourceRoot(projectRoot);
35  return path.basename(sourceRoot);
36}
37
38export function resolvePathOrProject(
39  projectRootOrProject: string | XcodeProject
40): XcodeProject | null {
41  if (typeof projectRootOrProject === 'string') {
42    try {
43      return getPbxproj(projectRootOrProject);
44    } catch {
45      return null;
46    }
47  }
48  return projectRootOrProject;
49}
50
51// TODO: come up with a better solution for using app.json expo.name in various places
52export function sanitizedName(name: string) {
53  // Default to the name `app` when every safe character has been sanitized
54  return sanitizedNameForProjects(name) || sanitizedNameForProjects(slugify(name)) || 'app';
55}
56
57function sanitizedNameForProjects(name: string) {
58  return name
59    .replace(/[\W_]+/g, '')
60    .normalize('NFD')
61    .replace(/[\u0300-\u036f]/g, '');
62}
63
64// TODO: it's silly and kind of fragile that we look at app config to determine
65// the ios project paths. Overall this function needs to be revamped, just a
66// placeholder for now! Make this more robust when we support applying config
67// at any time (currently it's only applied on eject).
68export function getHackyProjectName(projectRoot: string, config: ExpoConfig): string {
69  // Attempt to get the current ios folder name (apply).
70  try {
71    return getProjectName(projectRoot);
72  } catch {
73    // If no iOS project exists then create a new one (eject).
74    const projectName = config.name;
75    assert(projectName, 'Your project needs a name in app.json/app.config.js.');
76    return sanitizedName(projectName);
77  }
78}
79
80function createProjectFileForGroup({ filepath, group }: { filepath: string; group: PBXGroup }) {
81  const file = new pbxFile(filepath);
82
83  const conflictingFile = group.children.find((child) => child.comment === file.basename);
84  if (conflictingFile) {
85    // This can happen when a file like the GoogleService-Info.plist needs to be added and the eject command is run twice.
86    // Not much we can do here since it might be a conflicting file.
87    return null;
88  }
89  return file;
90}
91
92/**
93 * Add a resource file (ex: `SplashScreen.storyboard`, `Images.xcassets`) to an Xcode project.
94 * This is akin to creating a new code file in Xcode with `⌘+n`.
95 */
96export function addResourceFileToGroup({
97  filepath,
98  groupName,
99  // Should add to `PBXBuildFile Section`
100  isBuildFile,
101  project,
102  verbose,
103  targetUuid,
104}: {
105  filepath: string;
106  groupName: string;
107  isBuildFile?: boolean;
108  project: XcodeProject;
109  verbose?: boolean;
110  targetUuid?: string;
111}): XcodeProject {
112  return addFileToGroupAndLink({
113    filepath,
114    groupName,
115    project,
116    verbose,
117    targetUuid,
118    addFileToProject({ project, file }) {
119      project.addToPbxFileReferenceSection(file);
120      if (isBuildFile) {
121        project.addToPbxBuildFileSection(file);
122      }
123      project.addToPbxResourcesBuildPhase(file);
124    },
125  });
126}
127
128/**
129 * Add a build source file (ex: `AppDelegate.m`, `ViewController.swift`) to an Xcode project.
130 * This is akin to creating a new code file in Xcode with `⌘+n`.
131 */
132export function addBuildSourceFileToGroup({
133  filepath,
134  groupName,
135  project,
136  verbose,
137  targetUuid,
138}: {
139  filepath: string;
140  groupName: string;
141  project: XcodeProject;
142  verbose?: boolean;
143  targetUuid?: string;
144}): XcodeProject {
145  return addFileToGroupAndLink({
146    filepath,
147    groupName,
148    project,
149    verbose,
150    targetUuid,
151    addFileToProject({ project, file }) {
152      project.addToPbxFileReferenceSection(file);
153      project.addToPbxBuildFileSection(file);
154      project.addToPbxSourcesBuildPhase(file);
155    },
156  });
157}
158
159// TODO(brentvatne): I couldn't figure out how to do this with an existing
160// higher level function exposed by the xcode library, but we should find out how to do
161// that and replace this with it
162export function addFileToGroupAndLink({
163  filepath,
164  groupName,
165  project,
166  verbose,
167  addFileToProject,
168  targetUuid,
169}: {
170  filepath: string;
171  groupName: string;
172  project: XcodeProject;
173  verbose?: boolean;
174  targetUuid?: string;
175  addFileToProject: (props: { file: PBXFile; project: XcodeProject }) => void;
176}): XcodeProject {
177  const group = pbxGroupByPathOrAssert(project, groupName);
178
179  const file = createProjectFileForGroup({ filepath, group });
180
181  if (!file) {
182    if (verbose) {
183      // This can happen when a file like the GoogleService-Info.plist needs to be added and the eject command is run twice.
184      // Not much we can do here since it might be a conflicting file.
185      addWarningIOS(
186        'ios-xcode-project',
187        `Skipped adding duplicate file "${filepath}" to PBXGroup named "${groupName}"`
188      );
189    }
190    return project;
191  }
192
193  if (targetUuid != null) {
194    file.target = targetUuid;
195  } else {
196    const applicationNativeTarget = project.getTarget('com.apple.product-type.application');
197    file.target = applicationNativeTarget?.uuid;
198  }
199
200  file.uuid = project.generateUuid();
201  file.fileRef = project.generateUuid();
202
203  addFileToProject({ project, file });
204
205  group.children.push({
206    value: file.fileRef,
207    comment: file.basename,
208  });
209  return project;
210}
211
212export function getApplicationNativeTarget({
213  project,
214  projectName,
215}: {
216  project: XcodeProject;
217  projectName: string;
218}) {
219  const applicationNativeTarget = project.getTarget('com.apple.product-type.application');
220  assert(
221    applicationNativeTarget,
222    `Couldn't locate application PBXNativeTarget in '.xcodeproj' file.`
223  );
224  assert(
225    String(applicationNativeTarget.target.name) === projectName,
226    `Application native target name mismatch. Expected ${projectName}, but found ${applicationNativeTarget.target.name}.`
227  );
228  return applicationNativeTarget;
229}
230
231/**
232 * Add a framework to the default app native target.
233 *
234 * @param projectName Name of the PBX project.
235 * @param framework String ending in `.framework`, i.e. `StoreKit.framework`
236 */
237export function addFramework({
238  project,
239  projectName,
240  framework,
241}: {
242  project: XcodeProject;
243  projectName: string;
244  framework: string;
245}) {
246  const target = getApplicationNativeTarget({ project, projectName });
247  return project.addFramework(framework, { target: target.uuid });
248}
249
250function splitPath(path: string): string[] {
251  // TODO: Should we account for other platforms that may not use `/`
252  return path.split('/');
253}
254
255const findGroup = (
256  group: PBXGroup | undefined,
257  name: string
258):
259  | {
260      value: UUID;
261      comment?: string;
262    }
263  | undefined => {
264  if (!group) {
265    return undefined;
266  }
267
268  return group.children.find((group) => group.comment === name);
269};
270
271function findGroupInsideGroup(
272  project: XcodeProject,
273  group: PBXGroup | undefined,
274  name: string
275): null | PBXGroup {
276  const foundGroup = findGroup(group, name);
277  if (foundGroup) {
278    return project.getPBXGroupByKey(foundGroup.value) ?? null;
279  }
280  return null;
281}
282
283function pbxGroupByPathOrAssert(project: XcodeProject, path: string): PBXGroup {
284  const { firstProject } = project.getFirstProject();
285
286  let group = project.getPBXGroupByKey(firstProject.mainGroup);
287
288  const components = splitPath(path);
289  for (const name of components) {
290    const nextGroup = findGroupInsideGroup(project, group, name);
291    if (nextGroup) {
292      group = nextGroup;
293    } else {
294      break;
295    }
296  }
297
298  if (!group) {
299    throw Error(`Xcode PBXGroup with name "${path}" could not be found in the Xcode project.`);
300  }
301
302  return group;
303}
304
305export function ensureGroupRecursively(project: XcodeProject, filepath: string): PBXGroup | null {
306  const components = splitPath(filepath);
307  const hasChild = (group: PBXGroup, name: string) =>
308    group.children.find(({ comment }) => comment === name);
309  const { firstProject } = project.getFirstProject();
310
311  let topMostGroup = project.getPBXGroupByKey(firstProject.mainGroup);
312
313  for (const pathComponent of components) {
314    if (topMostGroup && !hasChild(topMostGroup, pathComponent)) {
315      topMostGroup.children.push({
316        comment: pathComponent,
317        value: project.pbxCreateGroup(pathComponent, '""'),
318      });
319    }
320    topMostGroup = project.pbxGroupByName(pathComponent);
321  }
322  return topMostGroup ?? null;
323}
324
325/**
326 * Get the pbxproj for the given path
327 */
328export function getPbxproj(projectRoot: string): XcodeProject {
329  const projectPath = Paths.getPBXProjectPath(projectRoot);
330  const project = xcode.project(projectPath);
331  project.parseSync();
332  return project;
333}
334
335/**
336 * Get the productName for a project, if the name is using a variable `$(TARGET_NAME)`, then attempt to get the value of that variable.
337 *
338 * @param project
339 */
340export function getProductName(project: XcodeProject): string {
341  let productName = '$(TARGET_NAME)';
342  try {
343    // If the product name is numeric, this will fail (it's a getter).
344    // If the bundle identifier' final component is only numeric values, then the PRODUCT_NAME
345    // will be a numeric value, this results in a bug where the product name isn't useful,
346    // i.e. `com.bacon.001` -> `1` -- in this case, use the first target name.
347    productName = project.productName;
348  } catch {}
349
350  if (productName === '$(TARGET_NAME)') {
351    const targetName = project.getFirstTarget()?.firstTarget?.productName;
352    productName = targetName ?? productName;
353  }
354
355  return productName;
356}
357
358export function getProjectSection(project: XcodeProject) {
359  return project.pbxProjectSection();
360}
361
362export function getXCConfigurationListEntries(project: XcodeProject): ConfigurationListEntry[] {
363  const lists = project.pbxXCConfigurationList();
364  return Object.entries(lists).filter(isNotComment);
365}
366
367export function getBuildConfigurationsForListId(
368  project: XcodeProject,
369  configurationListId: string
370): ConfigurationSectionEntry[] {
371  const configurationListEntries = getXCConfigurationListEntries(project);
372  const [, configurationList] = configurationListEntries.find(
373    ([key]) => key === configurationListId
374  ) as ConfigurationListEntry;
375
376  const buildConfigurations = configurationList.buildConfigurations.map((i) => i.value);
377
378  return Object.entries(project.pbxXCBuildConfigurationSection())
379    .filter(isNotComment)
380    .filter(isBuildConfig)
381    .filter(([key]: ConfigurationSectionEntry) => buildConfigurations.includes(key));
382}
383
384export function getBuildConfigurationForListIdAndName(
385  project: XcodeProject,
386  {
387    configurationListId,
388    buildConfiguration,
389  }: { configurationListId: string; buildConfiguration: string }
390): ConfigurationSectionEntry {
391  const xcBuildConfigurationEntry = getBuildConfigurationsForListId(
392    project,
393    configurationListId
394  ).find((i) => trimQuotes(i[1].name) === buildConfiguration);
395  if (!xcBuildConfigurationEntry) {
396    throw new Error(
397      `Build configuration '${buildConfiguration}' does not exist in list with id '${configurationListId}'`
398    );
399  }
400  return xcBuildConfigurationEntry;
401}
402
403export function isBuildConfig([, sectionItem]: ConfigurationSectionEntry): boolean {
404  return sectionItem.isa === 'XCBuildConfiguration';
405}
406
407export function isNotTestHost([, sectionItem]: ConfigurationSectionEntry): boolean {
408  return !sectionItem.buildSettings.TEST_HOST;
409}
410
411export function isNotComment([key]:
412  | ConfigurationSectionEntry
413  | ProjectSectionEntry
414  | ConfigurationListEntry
415  | NativeTargetSectionEntry): boolean {
416  return !key.endsWith(`_comment`);
417}
418
419// Remove surrounding double quotes if they exist.
420export function unquote(value: string): string {
421  // projects with numeric names will fail due to a bug in the xcode package.
422  if (typeof value === 'number') {
423    value = String(value);
424  }
425  return value.match(/^"(.*)"$/)?.[1] ?? value;
426}
427