1import {
2  ConfigPlugin,
3  withDangerousMod,
4  withMainActivity,
5  WarningAggregator,
6  ExportedConfigWithProps,
7} from '@expo/config-plugins';
8import { ExpoConfig } from '@expo/config-types';
9import fs from 'fs';
10import path from 'path';
11
12const DEV_MENU_ANDROID_IMPORT = 'expo.modules.devmenu.react.DevMenuAwareReactActivity';
13const DEV_MENU_ACTIVITY_CLASS = 'public class MainActivity extends DevMenuAwareReactActivity {';
14
15const DEV_MENU_POD_IMPORT =
16  "pod 'expo-dev-menu', path: '../node_modules/expo-dev-menu', :configurations => :debug";
17
18const DEV_MENU_IOS_IMPORT = `
19#if defined(EX_DEV_MENU_ENABLED)
20@import EXDevMenu;
21#endif`;
22
23const DEV_MENU_IOS_INIT = `
24#if defined(EX_DEV_MENU_ENABLED)
25  [DevMenuManager configureWithBridge:bridge];
26#endif`;
27
28async function readFileAsync(path: string): Promise<string> {
29  return fs.promises.readFile(path, 'utf8');
30}
31
32async function saveFileAsync(path: string, content: string): Promise<void> {
33  return fs.promises.writeFile(path, content, 'utf8');
34}
35
36function addJavaImports(javaSource: string, javaImports: string[]): string {
37  const lines = javaSource.split('\n');
38  const lineIndexWithPackageDeclaration = lines.findIndex(line => line.match(/^package .*;$/));
39  for (const javaImport of javaImports) {
40    if (!javaSource.includes(javaImport)) {
41      const importStatement = `import ${javaImport};`;
42      lines.splice(lineIndexWithPackageDeclaration + 1, 0, importStatement);
43    }
44  }
45  return lines.join('\n');
46}
47
48function addLines(content: string, find: string | RegExp, offset: number, toAdd: string[]) {
49  const lines = content.split('\n');
50
51  let lineIndex = lines.findIndex(line => line.match(find));
52
53  for (const newLine of toAdd) {
54    if (!content.includes(newLine)) {
55      lines.splice(lineIndex + offset, 0, newLine);
56      lineIndex++;
57    }
58  }
59
60  return lines.join('\n');
61}
62
63async function editPodfile(config: ExportedConfigWithProps, action: (podfile: string) => string) {
64  const podfilePath = path.join(config.modRequest.platformProjectRoot, 'Podfile');
65  try {
66    const podfile = action(await readFileAsync(podfilePath));
67
68    return await saveFileAsync(podfilePath, podfile);
69  } catch (e) {
70    WarningAggregator.addWarningIOS('ios-devMenu', `Couldn't modified AppDelegate.m - ${e}.`);
71  }
72}
73
74async function editAppDelegate(
75  config: ExportedConfigWithProps,
76  action: (appDelegate: string) => string
77) {
78  const appDelegatePath = path.join(
79    config.modRequest.platformProjectRoot,
80    config.modRequest.projectName!,
81    'AppDelegate.m'
82  );
83
84  try {
85    const appDelegate = action(await readFileAsync(appDelegatePath));
86    return await saveFileAsync(appDelegatePath, appDelegate);
87  } catch (e) {
88    WarningAggregator.addWarningIOS('ios-devMenu', `Couldn't modified AppDelegate.m - ${e}.`);
89  }
90}
91
92const withDevMenuActivity: ConfigPlugin = config => {
93  return withMainActivity(config, config => {
94    if (config.modResults.language === 'java') {
95      let content = config.modResults.contents;
96      content = addJavaImports(content, [DEV_MENU_ANDROID_IMPORT]);
97      content = content.replace(
98        'public class MainActivity extends ReactActivity {',
99        DEV_MENU_ACTIVITY_CLASS
100      );
101      config.modResults.contents = content;
102    } else {
103      WarningAggregator.addWarningAndroid(
104        'android-devMenu',
105        `Cannot automatically configure MainActivity if it's not java`
106      );
107    }
108
109    return config;
110  });
111};
112
113const withDevMenuPodfile: ConfigPlugin = config => {
114  return withDangerousMod(config, [
115    'ios',
116    async config => {
117      await editPodfile(config, podfile => {
118        podfile = podfile.replace("platform :ios, '10.0'", "platform :ios, '11.0'");
119        // Match both variations of Ruby config:
120        // unknown: pod 'expo-dev-menu', path: '../node_modules/expo-dev-menu', :configurations => :debug
121        // Rubocop: pod 'expo-dev-menu', path: '../node_modules/expo-dev-menu', configurations: :debug
122        if (
123          !podfile.match(
124            /pod ['"]expo-dev-menu['"],\s?path: ['"]\.\.\/node_modules\/expo-dev-menu['"],\s?:?configurations:?\s(?:=>\s)?:debug/
125          )
126        ) {
127          podfile = addLines(podfile, 'use_react_native', 0, [`  ${DEV_MENU_POD_IMPORT}`]);
128        }
129        return podfile;
130      });
131      return config;
132    },
133  ]);
134};
135
136const withDevMenuAppDelegate: ConfigPlugin = config => {
137  return withDangerousMod(config, [
138    'ios',
139    async config => {
140      await editAppDelegate(config, appDelegate => {
141        if (!appDelegate.includes(DEV_MENU_IOS_IMPORT)) {
142          const lines = appDelegate.split('\n');
143          lines.splice(1, 0, DEV_MENU_IOS_IMPORT);
144
145          appDelegate = lines.join('\n');
146        }
147
148        if (!appDelegate.includes(DEV_MENU_IOS_INIT)) {
149          const lines = appDelegate.split('\n');
150
151          const initializeReactNativeAppIndex = lines.findIndex(line =>
152            line.includes('- (RCTBridge *)initializeReactNativeApp')
153          );
154
155          const rootViewControllerIndex = lines.findIndex(
156            (line, index) =>
157              initializeReactNativeAppIndex < index && line.includes('rootViewController')
158          );
159
160          lines.splice(rootViewControllerIndex - 1, 0, DEV_MENU_IOS_INIT);
161
162          appDelegate = lines.join('\n');
163        }
164
165        return appDelegate;
166      });
167
168      return config;
169    },
170  ]);
171};
172
173const withDevMenu = (config: ExpoConfig) => {
174  config = withDevMenuActivity(config);
175  config = withDevMenuPodfile(config);
176  config = withDevMenuAppDelegate(config);
177  return config;
178};
179
180export default withDevMenu;
181