1import { ExpoConfig } from '@expo/config-types';
2import plist, { PlistObject } from '@expo/plist';
3import assert from 'assert';
4import fs from 'fs';
5import xcode, { XCBuildConfiguration } from 'xcode';
6
7import { ConfigPlugin } from '../Plugin.types';
8import { withDangerousMod } from '../plugins/withDangerousMod';
9import { InfoPlist } from './IosConfig.types';
10import { getAllInfoPlistPaths, getAllPBXProjectPaths, getPBXProjectPath } from './Paths';
11import { findFirstNativeTarget, getXCBuildConfigurationFromPbxproj } from './Target';
12import { ConfigurationSectionEntry, getBuildConfigurationsForListId } from './utils/Xcodeproj';
13import { trimQuotes } from './utils/string';
14
15export const withBundleIdentifier: ConfigPlugin<{ bundleIdentifier?: string }> = (
16  config,
17  { bundleIdentifier }
18) => {
19  return withDangerousMod(config, [
20    'ios',
21    async (config) => {
22      const bundleId = bundleIdentifier ?? config.ios?.bundleIdentifier;
23      assert(
24        bundleId,
25        '`bundleIdentifier` must be defined in the app config (`expo.ios.bundleIdentifier`) or passed to the plugin `withBundleIdentifier`.'
26      );
27      await setBundleIdentifierForPbxproj(config.modRequest.projectRoot, bundleId!);
28      return config;
29    },
30  ]);
31};
32
33function getBundleIdentifier(config: Pick<ExpoConfig, 'ios'>): string | null {
34  return config.ios?.bundleIdentifier ?? null;
35}
36
37/**
38 * In Turtle v1 we set the bundleIdentifier directly on Info.plist rather
39 * than in pbxproj
40 */
41function setBundleIdentifier(config: ExpoConfig, infoPlist: InfoPlist): InfoPlist {
42  const bundleIdentifier = getBundleIdentifier(config);
43
44  if (!bundleIdentifier) {
45    return infoPlist;
46  }
47
48  return {
49    ...infoPlist,
50    CFBundleIdentifier: bundleIdentifier,
51  };
52}
53
54/**
55 * Gets the bundle identifier defined in the Xcode project found in the project directory.
56 *
57 * A bundle identifier is stored as a value in XCBuildConfiguration entry.
58 * Those entries exist for every pair (build target, build configuration).
59 * Unless target name is passed, the first target defined in the pbxproj is used
60 * (to keep compatibility with the inaccurate legacy implementation of this function).
61 * The build configuration is usually 'Release' or 'Debug'. However, it could be any arbitrary string.
62 * Defaults to 'Release'.
63 *
64 * @param {string} projectRoot Path to project root containing the ios directory
65 * @param {string} targetName Target name
66 * @param {string} buildConfiguration Build configuration. Defaults to 'Release'.
67 * @returns {string | null} bundle identifier of the Xcode project or null if the project is not configured
68 */
69function getBundleIdentifierFromPbxproj(
70  projectRoot: string,
71  {
72    targetName,
73    buildConfiguration = 'Release',
74  }: { targetName?: string; buildConfiguration?: string } = {}
75): string | null {
76  let pbxprojPath: string;
77  try {
78    pbxprojPath = getPBXProjectPath(projectRoot);
79  } catch {
80    return null;
81  }
82  const project = xcode.project(pbxprojPath);
83  project.parseSync();
84
85  const xcBuildConfiguration = getXCBuildConfigurationFromPbxproj(project, {
86    targetName,
87    buildConfiguration,
88  });
89  if (!xcBuildConfiguration) {
90    return null;
91  }
92  return getProductBundleIdentifierFromBuildConfiguration(xcBuildConfiguration);
93}
94
95function getProductBundleIdentifierFromBuildConfiguration(
96  xcBuildConfiguration: XCBuildConfiguration
97): string | null {
98  const bundleIdentifierRaw = xcBuildConfiguration.buildSettings.PRODUCT_BUNDLE_IDENTIFIER;
99  if (bundleIdentifierRaw) {
100    const bundleIdentifier = trimQuotes(bundleIdentifierRaw);
101    // it's possible to use interpolation for the bundle identifier
102    // the most common case is when the last part of the id is set to `$(PRODUCT_NAME:rfc1034identifier)`
103    // in this case, PRODUCT_NAME should be replaced with its value
104    // the `rfc1034identifier` modifier replaces all non-alphanumeric characters with dashes
105    const bundleIdentifierParts = bundleIdentifier.split('.');
106    if (
107      bundleIdentifierParts[bundleIdentifierParts.length - 1] ===
108        '$(PRODUCT_NAME:rfc1034identifier)' &&
109      xcBuildConfiguration.buildSettings.PRODUCT_NAME
110    ) {
111      bundleIdentifierParts[bundleIdentifierParts.length - 1] =
112        xcBuildConfiguration.buildSettings.PRODUCT_NAME.replace(/[^a-zA-Z0-9]/g, '-');
113    }
114    return bundleIdentifierParts.join('.');
115  } else {
116    return null;
117  }
118}
119
120/**
121 * Updates the bundle identifier for a given pbxproj
122 *
123 * @param {string} pbxprojPath Path to pbxproj file
124 * @param {string} bundleIdentifier Bundle identifier to set in the pbxproj
125 * @param {boolean} [updateProductName=true]  Whether to update PRODUCT_NAME
126 */
127function updateBundleIdentifierForPbxproj(
128  pbxprojPath: string,
129  bundleIdentifier: string,
130  updateProductName: boolean = true
131): void {
132  const project = xcode.project(pbxprojPath);
133  project.parseSync();
134
135  const [, nativeTarget] = findFirstNativeTarget(project);
136
137  getBuildConfigurationsForListId(project, nativeTarget.buildConfigurationList).forEach(
138    ([, item]: ConfigurationSectionEntry) => {
139      if (item.buildSettings.PRODUCT_BUNDLE_IDENTIFIER === bundleIdentifier) {
140        return;
141      }
142
143      item.buildSettings.PRODUCT_BUNDLE_IDENTIFIER = `"${bundleIdentifier}"`;
144
145      if (updateProductName) {
146        const productName = bundleIdentifier.split('.').pop();
147        if (!productName?.includes('$')) {
148          item.buildSettings.PRODUCT_NAME = productName;
149        }
150      }
151    }
152  );
153  fs.writeFileSync(pbxprojPath, project.writeSync());
154}
155
156/**
157 * Updates the bundle identifier for pbx projects inside the ios directory of the given project root
158 *
159 * @param {string} projectRoot Path to project root containing the ios directory
160 * @param {string} bundleIdentifier Desired bundle identifier
161 * @param {boolean} [updateProductName=true]  Whether to update PRODUCT_NAME
162 */
163function setBundleIdentifierForPbxproj(
164  projectRoot: string,
165  bundleIdentifier: string,
166  updateProductName: boolean = true
167): void {
168  // Get all pbx projects in the ${projectRoot}/ios directory
169  let pbxprojPaths: string[] = [];
170  try {
171    pbxprojPaths = getAllPBXProjectPaths(projectRoot);
172  } catch {}
173
174  for (const pbxprojPath of pbxprojPaths) {
175    updateBundleIdentifierForPbxproj(pbxprojPath, bundleIdentifier, updateProductName);
176  }
177}
178
179/**
180 * Reset bundle identifier field in Info.plist to use PRODUCT_BUNDLE_IDENTIFIER, as recommended by Apple.
181 */
182
183const defaultBundleId = '$(PRODUCT_BUNDLE_IDENTIFIER)';
184
185function resetAllPlistBundleIdentifiers(projectRoot: string): void {
186  const infoPlistPaths = getAllInfoPlistPaths(projectRoot);
187
188  for (const plistPath of infoPlistPaths) {
189    resetPlistBundleIdentifier(plistPath);
190  }
191}
192
193function resetPlistBundleIdentifier(plistPath: string): void {
194  const rawPlist = fs.readFileSync(plistPath, 'utf8');
195  const plistObject = plist.parse(rawPlist) as PlistObject;
196
197  if (plistObject.CFBundleIdentifier) {
198    if (plistObject.CFBundleIdentifier === defaultBundleId) return;
199
200    // attempt to match default Info.plist format
201    const format = { pretty: true, indent: `\t` };
202
203    const xml = plist.build(
204      {
205        ...plistObject,
206        CFBundleIdentifier: defaultBundleId,
207      },
208      format
209    );
210
211    if (xml !== rawPlist) {
212      fs.writeFileSync(plistPath, xml);
213    }
214  }
215}
216
217export {
218  getBundleIdentifier,
219  setBundleIdentifier,
220  getBundleIdentifierFromPbxproj,
221  updateBundleIdentifierForPbxproj,
222  setBundleIdentifierForPbxproj,
223  resetAllPlistBundleIdentifiers,
224  resetPlistBundleIdentifier,
225};
226