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        podfile = addLines(podfile, 'use_react_native', 0, [`  ${DEV_MENU_POD_IMPORT}`]);
120        return podfile;
121      });
122      return config;
123    },
124  ]);
125};
126
127const withDevMenuAppDelegate: ConfigPlugin = config => {
128  return withDangerousMod(config, [
129    'ios',
130    async config => {
131      await editAppDelegate(config, appDelegate => {
132        if (!appDelegate.includes(DEV_MENU_IOS_IMPORT)) {
133          const lines = appDelegate.split('\n');
134          lines.splice(1, 0, DEV_MENU_IOS_IMPORT);
135
136          appDelegate = lines.join('\n');
137        }
138
139        if (!appDelegate.includes(DEV_MENU_IOS_INIT)) {
140          const lines = appDelegate.split('\n');
141
142          const initializeReactNativeAppIndex = lines.findIndex(line =>
143            line.includes('- (RCTBridge *)initializeReactNativeApp')
144          );
145
146          const rootViewControllerIndex = lines.findIndex(
147            (line, index) =>
148              initializeReactNativeAppIndex < index && line.includes('rootViewController')
149          );
150
151          lines.splice(rootViewControllerIndex - 1, 0, DEV_MENU_IOS_INIT);
152
153          appDelegate = lines.join('\n');
154        }
155
156        return appDelegate;
157      });
158
159      return config;
160    },
161  ]);
162};
163
164const withDevMenu = (config: ExpoConfig) => {
165  config = withDevMenuActivity(config);
166  config = withDevMenuPodfile(config);
167  config = withDevMenuAppDelegate(config);
168  return config;
169};
170
171export default withDevMenu;
172