1import JsonFile, { JSONObject, JSONValue } from '@expo/json-file';
2import plist from '@expo/plist';
3import assert from 'assert';
4import fs, { promises } from 'fs';
5import path from 'path';
6import xcode, { XcodeProject } from 'xcode';
7
8import { ExportedConfig, ModConfig } from '../Plugin.types';
9import { Entitlements, Paths } from '../ios';
10import { ensureApplicationTargetEntitlementsFileConfigured } from '../ios/Entitlements';
11import { InfoPlist } from '../ios/IosConfig.types';
12import { getPbxproj } from '../ios/utils/Xcodeproj';
13import { getInfoPlistPathFromPbxproj } from '../ios/utils/getInfoPlistPath';
14import { fileExists } from '../utils/modules';
15import { sortObject } from '../utils/sortObject';
16import { addWarningIOS } from '../utils/warnings';
17import { ForwardedBaseModOptions, provider, withGeneratedBaseMods } from './createBaseMod';
18
19const { readFile, writeFile } = promises;
20
21type IosModName = keyof Required<ModConfig>['ios'];
22
23function getEntitlementsPlistTemplate() {
24  // TODO: Fetch the versioned template file if possible
25  return {};
26}
27
28function getInfoPlistTemplate() {
29  // TODO: Fetch the versioned template file if possible
30  return {
31    CFBundleDevelopmentRegion: '$(DEVELOPMENT_LANGUAGE)',
32    CFBundleExecutable: '$(EXECUTABLE_NAME)',
33    CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)',
34    CFBundleName: '$(PRODUCT_NAME)',
35    CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)',
36    CFBundleInfoDictionaryVersion: '6.0',
37    CFBundleSignature: '????',
38    LSRequiresIPhoneOS: true,
39    NSAppTransportSecurity: {
40      NSAllowsArbitraryLoads: true,
41      NSExceptionDomains: {
42        localhost: {
43          NSExceptionAllowsInsecureHTTPLoads: true,
44        },
45      },
46    },
47    UILaunchStoryboardName: 'SplashScreen',
48    UIRequiredDeviceCapabilities: ['armv7'],
49    UIViewControllerBasedStatusBarAppearance: false,
50    UIStatusBarStyle: 'UIStatusBarStyleDefault',
51  };
52}
53
54const defaultProviders = {
55  dangerous: provider<unknown>({
56    getFilePath() {
57      return '';
58    },
59    async read() {
60      return {};
61    },
62    async write() {},
63  }),
64  // Append a rule to supply AppDelegate data to mods on `mods.ios.appDelegate`
65  appDelegate: provider<Paths.AppDelegateProjectFile>({
66    getFilePath({ modRequest: { projectRoot } }) {
67      // TODO: Get application AppDelegate file from pbxproj.
68      return Paths.getAppDelegateFilePath(projectRoot);
69    },
70    async read(filePath) {
71      return Paths.getFileInfo(filePath);
72    },
73    async write(filePath: string, { modResults: { contents } }) {
74      await writeFile(filePath, contents);
75    },
76  }),
77  // Append a rule to supply Expo.plist data to mods on `mods.ios.expoPlist`
78  expoPlist: provider<JSONObject>({
79    isIntrospective: true,
80    getFilePath({ modRequest: { platformProjectRoot, projectName } }) {
81      const supportingDirectory = path.join(platformProjectRoot, projectName!, 'Supporting');
82      return path.resolve(supportingDirectory, 'Expo.plist');
83    },
84    async read(filePath, { modRequest: { introspect } }) {
85      try {
86        return plist.parse(await readFile(filePath, 'utf8'));
87      } catch (error) {
88        if (introspect) {
89          return {};
90        }
91        throw error;
92      }
93    },
94    async write(filePath, { modResults, modRequest: { introspect } }) {
95      if (introspect) {
96        return;
97      }
98      await writeFile(filePath, plist.build(sortObject(modResults)));
99    },
100  }),
101  // Append a rule to supply .xcodeproj data to mods on `mods.ios.xcodeproj`
102  xcodeproj: provider<XcodeProject>({
103    getFilePath({ modRequest: { projectRoot } }) {
104      return Paths.getPBXProjectPath(projectRoot);
105    },
106    async read(filePath) {
107      const project = xcode.project(filePath);
108      project.parseSync();
109      return project;
110    },
111    async write(filePath, { modResults }) {
112      await writeFile(filePath, modResults.writeSync());
113    },
114  }),
115  // Append a rule to supply Info.plist data to mods on `mods.ios.infoPlist`
116  infoPlist: provider<InfoPlist, ForwardedBaseModOptions>({
117    isIntrospective: true,
118    async getFilePath(config) {
119      let project: xcode.XcodeProject | null = null;
120      try {
121        project = getPbxproj(config.modRequest.projectRoot);
122      } catch {
123        // noop
124      }
125
126      // Only check / warn if a project actually exists, this'll provide
127      // more accurate warning messages for users in managed projects.
128      if (project) {
129        const infoPlistBuildProperty = getInfoPlistPathFromPbxproj(project);
130
131        if (infoPlistBuildProperty) {
132          //: [root]/myapp/ios/MyApp/Info.plist
133          const infoPlistPath = path.join(
134            //: myapp/ios
135            config.modRequest.platformProjectRoot,
136            //: MyApp/Info.plist
137            infoPlistBuildProperty
138          );
139          if (fileExists(infoPlistPath)) {
140            return infoPlistPath;
141          }
142          addWarningIOS(
143            'mods.ios.infoPlist',
144            `Info.plist file linked to Xcode project does not exist: ${infoPlistPath}`
145          );
146        } else {
147          addWarningIOS('mods.ios.infoPlist', 'Failed to find Info.plist linked to Xcode project.');
148        }
149      }
150      try {
151        // Fallback on glob...
152        return await Paths.getInfoPlistPath(config.modRequest.projectRoot);
153      } catch (error: any) {
154        if (config.modRequest.introspect) {
155          // fallback to an empty string in introspection mode.
156          return '';
157        }
158        throw error;
159      }
160    },
161    async read(filePath, config) {
162      // Apply all of the Info.plist values to the expo.ios.infoPlist object
163      // TODO: Remove this in favor of just overwriting the Info.plist with the Expo object. This will enable people to actually remove values.
164      if (!config.ios) config.ios = {};
165      if (!config.ios.infoPlist) config.ios.infoPlist = {};
166
167      let modResults: InfoPlist;
168      try {
169        const contents = await readFile(filePath, 'utf8');
170        assert(contents, 'Info.plist is empty');
171        modResults = plist.parse(contents) as InfoPlist;
172      } catch (error: any) {
173        // Throw errors in introspection mode.
174        if (!config.modRequest.introspect) {
175          throw error;
176        }
177        // Fallback to using the infoPlist object from the Expo config.
178        modResults = getInfoPlistTemplate();
179      }
180
181      config.ios.infoPlist = {
182        ...(modResults || {}),
183        ...config.ios.infoPlist,
184      };
185
186      return config.ios.infoPlist!;
187    },
188    async write(filePath, config) {
189      // Update the contents of the static infoPlist object
190      if (!config.ios) {
191        config.ios = {};
192      }
193      config.ios.infoPlist = config.modResults;
194
195      // Return early without writing, in introspection mode.
196      if (config.modRequest.introspect) {
197        return;
198      }
199
200      await writeFile(filePath, plist.build(sortObject(config.modResults)));
201    },
202  }),
203  // Append a rule to supply .entitlements data to mods on `mods.ios.entitlements`
204  entitlements: provider<JSONObject, ForwardedBaseModOptions>({
205    isIntrospective: true,
206
207    async getFilePath(config) {
208      try {
209        ensureApplicationTargetEntitlementsFileConfigured(config.modRequest.projectRoot);
210        return Entitlements.getEntitlementsPath(config.modRequest.projectRoot) ?? '';
211      } catch (error: any) {
212        if (config.modRequest.introspect) {
213          // fallback to an empty string in introspection mode.
214          return '';
215        }
216        throw error;
217      }
218    },
219
220    async read(filePath, config) {
221      let modResults: JSONObject;
222      try {
223        if (fs.existsSync(filePath)) {
224          const contents = await readFile(filePath, 'utf8');
225          assert(contents, 'Entitlements plist is empty');
226          modResults = plist.parse(contents);
227        } else {
228          modResults = getEntitlementsPlistTemplate();
229        }
230      } catch (error: any) {
231        // Throw errors in introspection mode.
232        if (!config.modRequest.introspect) {
233          throw error;
234        }
235        // Fallback to using the template file.
236        modResults = getEntitlementsPlistTemplate();
237      }
238
239      // Apply all of the .entitlements values to the expo.ios.entitlements object
240      // TODO: Remove this in favor of just overwriting the .entitlements with the Expo object. This will enable people to actually remove values.
241      if (!config.ios) config.ios = {};
242      if (!config.ios.entitlements) config.ios.entitlements = {};
243
244      config.ios.entitlements = {
245        ...(modResults || {}),
246        ...config.ios.entitlements,
247      };
248
249      return config.ios.entitlements!;
250    },
251
252    async write(filePath, config) {
253      // Update the contents of the static entitlements object
254      if (!config.ios) {
255        config.ios = {};
256      }
257      config.ios.entitlements = config.modResults;
258
259      // Return early without writing, in introspection mode.
260      if (config.modRequest.introspect) {
261        return;
262      }
263
264      await writeFile(filePath, plist.build(sortObject(config.modResults)));
265    },
266  }),
267
268  // Append a rule to supply Podfile.properties.json data to mods on `mods.ios.podfileProperties`
269  podfileProperties: provider<Record<string, JSONValue>>({
270    isIntrospective: true,
271
272    getFilePath({ modRequest: { platformProjectRoot } }) {
273      return path.resolve(platformProjectRoot, 'Podfile.properties.json');
274    },
275    async read(filePath) {
276      let results: Record<string, JSONValue> = {};
277      try {
278        results = await JsonFile.readAsync(filePath);
279      } catch {}
280      return results;
281    },
282    async write(filePath, { modResults, modRequest: { introspect } }) {
283      if (introspect) {
284        return;
285      }
286      await JsonFile.writeAsync(filePath, modResults);
287    },
288  }),
289};
290
291type IosDefaultProviders = typeof defaultProviders;
292
293export function withIosBaseMods(
294  config: ExportedConfig,
295  {
296    providers,
297    ...props
298  }: ForwardedBaseModOptions & { providers?: Partial<IosDefaultProviders> } = {}
299): ExportedConfig {
300  return withGeneratedBaseMods<IosModName>(config, {
301    ...props,
302    platform: 'ios',
303    providers: providers ?? getIosModFileProviders(),
304  });
305}
306
307export function getIosModFileProviders() {
308  return defaultProviders;
309}
310