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