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      return Paths.getAppDelegateFilePath(projectRoot);
68    },
69    async read(filePath) {
70      return Paths.getFileInfo(filePath);
71    },
72    async write(filePath: string, { modResults: { contents } }) {
73      await writeFile(filePath, contents);
74    },
75  }),
76  // Append a rule to supply Expo.plist data to mods on `mods.ios.expoPlist`
77  expoPlist: provider<JSONObject>({
78    isIntrospective: true,
79    getFilePath({ modRequest: { platformProjectRoot, projectName } }) {
80      const supportingDirectory = path.join(platformProjectRoot, projectName!, 'Supporting');
81      return path.resolve(supportingDirectory, 'Expo.plist');
82    },
83    async read(filePath, { modRequest: { introspect } }) {
84      try {
85        return plist.parse(await readFile(filePath, 'utf8'));
86      } catch (error) {
87        if (introspect) {
88          return {};
89        }
90        throw error;
91      }
92    },
93    async write(filePath, { modResults, modRequest: { introspect } }) {
94      if (introspect) {
95        return;
96      }
97      await writeFile(filePath, plist.build(sortObject(modResults)));
98    },
99  }),
100  // Append a rule to supply .xcodeproj data to mods on `mods.ios.xcodeproj`
101  xcodeproj: provider<XcodeProject>({
102    getFilePath({ modRequest: { projectRoot } }) {
103      return Paths.getPBXProjectPath(projectRoot);
104    },
105    async read(filePath) {
106      const project = xcode.project(filePath);
107      project.parseSync();
108      return project;
109    },
110    async write(filePath, { modResults }) {
111      await writeFile(filePath, modResults.writeSync());
112    },
113  }),
114  // Append a rule to supply Info.plist data to mods on `mods.ios.infoPlist`
115  infoPlist: provider<InfoPlist, ForwardedBaseModOptions>({
116    isIntrospective: true,
117    async getFilePath(config) {
118      let project: xcode.XcodeProject | null = null;
119      try {
120        project = getPbxproj(config.modRequest.projectRoot);
121      } catch {
122        // noop
123      }
124
125      // Only check / warn if a project actually exists, this'll provide
126      // more accurate warning messages for users in managed projects.
127      if (project) {
128        const infoPlistBuildProperty = getInfoPlistPathFromPbxproj(project);
129
130        if (infoPlistBuildProperty) {
131          //: [root]/myapp/ios/MyApp/Info.plist
132          const infoPlistPath = path.join(
133            //: myapp/ios
134            config.modRequest.platformProjectRoot,
135            //: MyApp/Info.plist
136            infoPlistBuildProperty
137          );
138          if (fileExists(infoPlistPath)) {
139            return infoPlistPath;
140          }
141          addWarningIOS(
142            'mods.ios.infoPlist',
143            `Info.plist file linked to Xcode project does not exist: ${infoPlistPath}`
144          );
145        } else {
146          addWarningIOS('mods.ios.infoPlist', 'Failed to find Info.plist linked to Xcode project.');
147        }
148      }
149      try {
150        // Fallback on glob...
151        return await Paths.getInfoPlistPath(config.modRequest.projectRoot);
152      } catch (error: any) {
153        if (config.modRequest.introspect) {
154          // fallback to an empty string in introspection mode.
155          return '';
156        }
157        throw error;
158      }
159    },
160    async read(filePath, config) {
161      // Apply all of the Info.plist values to the expo.ios.infoPlist object
162      // TODO: Remove this in favor of just overwriting the Info.plist with the Expo object. This will enable people to actually remove values.
163      if (!config.ios) config.ios = {};
164      if (!config.ios.infoPlist) config.ios.infoPlist = {};
165
166      let modResults: InfoPlist;
167      try {
168        const contents = await readFile(filePath, 'utf8');
169        assert(contents, 'Info.plist is empty');
170        modResults = plist.parse(contents) as InfoPlist;
171      } catch (error: any) {
172        // Throw errors in introspection mode.
173        if (!config.modRequest.introspect) {
174          throw error;
175        }
176        // Fallback to using the infoPlist object from the Expo config.
177        modResults = getInfoPlistTemplate();
178      }
179
180      config.ios.infoPlist = {
181        ...(modResults || {}),
182        ...config.ios.infoPlist,
183      };
184
185      return config.ios.infoPlist!;
186    },
187    async write(filePath, config) {
188      // Update the contents of the static infoPlist object
189      if (!config.ios) {
190        config.ios = {};
191      }
192      config.ios.infoPlist = config.modResults;
193
194      // Return early without writing, in introspection mode.
195      if (config.modRequest.introspect) {
196        return;
197      }
198
199      await writeFile(filePath, plist.build(sortObject(config.modResults)));
200    },
201  }),
202  // Append a rule to supply .entitlements data to mods on `mods.ios.entitlements`
203  entitlements: provider<JSONObject, ForwardedBaseModOptions>({
204    isIntrospective: true,
205
206    async getFilePath(config) {
207      try {
208        ensureApplicationTargetEntitlementsFileConfigured(config.modRequest.projectRoot);
209        return Entitlements.getEntitlementsPath(config.modRequest.projectRoot) ?? '';
210      } catch (error: any) {
211        if (config.modRequest.introspect) {
212          // fallback to an empty string in introspection mode.
213          return '';
214        }
215        throw error;
216      }
217    },
218
219    async read(filePath, config) {
220      let modResults: JSONObject;
221      try {
222        if (fs.existsSync(filePath)) {
223          const contents = await readFile(filePath, 'utf8');
224          assert(contents, 'Entitlements plist is empty');
225          modResults = plist.parse(contents);
226        } else {
227          modResults = getEntitlementsPlistTemplate();
228        }
229      } catch (error: any) {
230        // Throw errors in introspection mode.
231        if (!config.modRequest.introspect) {
232          throw error;
233        }
234        // Fallback to using the template file.
235        modResults = getEntitlementsPlistTemplate();
236      }
237
238      // Apply all of the .entitlements values to the expo.ios.entitlements object
239      // TODO: Remove this in favor of just overwriting the .entitlements with the Expo object. This will enable people to actually remove values.
240      if (!config.ios) config.ios = {};
241      if (!config.ios.entitlements) config.ios.entitlements = {};
242
243      config.ios.entitlements = {
244        ...(modResults || {}),
245        ...config.ios.entitlements,
246      };
247
248      return config.ios.entitlements!;
249    },
250
251    async write(filePath, config) {
252      // Update the contents of the static entitlements object
253      if (!config.ios) {
254        config.ios = {};
255      }
256      config.ios.entitlements = config.modResults;
257
258      // Return early without writing, in introspection mode.
259      if (config.modRequest.introspect) {
260        return;
261      }
262
263      await writeFile(filePath, plist.build(sortObject(config.modResults)));
264    },
265  }),
266
267  // Append a rule to supply Podfile.properties.json data to mods on `mods.ios.podfileProperties`
268  podfileProperties: provider<Record<string, JSONValue>>({
269    isIntrospective: true,
270
271    getFilePath({ modRequest: { platformProjectRoot } }) {
272      return path.resolve(platformProjectRoot, 'Podfile.properties.json');
273    },
274    async read(filePath) {
275      let results: Record<string, JSONValue> = {};
276      try {
277        results = await JsonFile.readAsync(filePath);
278      } catch {}
279      return results;
280    },
281    async write(filePath, { modResults, modRequest: { introspect } }) {
282      if (introspect) {
283        return;
284      }
285      await JsonFile.writeAsync(filePath, modResults);
286    },
287  }),
288};
289
290type IosDefaultProviders = typeof defaultProviders;
291
292export function withIosBaseMods(
293  config: ExportedConfig,
294  {
295    providers,
296    ...props
297  }: ForwardedBaseModOptions & { providers?: Partial<IosDefaultProviders> } = {}
298): ExportedConfig {
299  return withGeneratedBaseMods<IosModName>(config, {
300    ...props,
301    platform: 'ios',
302    providers: providers ?? getIosModFileProviders(),
303  });
304}
305
306export function getIosModFileProviders() {
307  return defaultProviders;
308}
309