1import {
2  ConfigPlugin,
3  createRunOncePlugin,
4  ExportedConfigWithProps,
5  WarningAggregator,
6  withDangerousMod,
7  withMainActivity,
8} from '@expo/config-plugins';
9import { ExpoConfig } from '@expo/config-types';
10import fs from 'fs';
11import path from 'path';
12import semver from 'semver';
13
14import { InstallationPage } from './constants';
15import { resolveExpoUpdatesVersion } from './resolveExpoUpdatesVersion';
16import { withDevLauncherAppDelegate } from './withDevLauncherAppDelegate';
17
18const pkg = require('expo-dev-launcher/package.json');
19
20const DEV_LAUNCHER_ANDROID_IMPORT = 'expo.modules.devlauncher.DevLauncherController';
21const DEV_LAUNCHER_UPDATES_ANDROID_IMPORT = 'expo.modules.updates.UpdatesDevLauncherController';
22const DEV_LAUNCHER_ON_NEW_INTENT = `
23  @Override
24  public void onNewIntent(Intent intent) {
25      if (DevLauncherController.tryToHandleIntent(this, intent)) {
26         return;
27      }
28      super.onNewIntent(intent);
29  }
30`;
31const DEV_LAUNCHER_WRAPPED_ACTIVITY_DELEGATE = `DevLauncherController.wrapReactActivityDelegate(this, () -> $1);`;
32const DEV_LAUNCHER_ANDROID_INIT = 'DevLauncherController.initialize(this, getReactNativeHost());';
33const DEV_LAUNCHER_UPDATES_ANDROID_INIT = `if (BuildConfig.DEBUG) {
34      DevLauncherController.getInstance().setUpdatesInterface(UpdatesDevLauncherController.initialize(this));
35    }`;
36const DEV_LAUNCHER_UPDATES_DEVELOPER_SUPPORT =
37  'return DevLauncherController.getInstance().getUseDeveloperSupport();';
38
39const DEV_LAUNCHER_JS_REGISTER_ERROR_HANDLERS = `import 'expo-dev-client'`;
40const DEV_LAUNCHER_JS_REGISTER_ERROR_HANDLERS_VIA_LAUNCHER = `import 'expo-dev-launcher'`;
41
42async function readFileAsync(path: string): Promise<string> {
43  return fs.promises.readFile(path, 'utf8');
44}
45
46async function saveFileAsync(path: string, content: string): Promise<void> {
47  return fs.promises.writeFile(path, content, 'utf8');
48}
49
50function addLines(content: string, find: string | RegExp, offset: number, toAdd: string[]) {
51  const lines = content.split('\n');
52
53  let lineIndex = lines.findIndex((line) => line.match(find));
54
55  for (const newLine of toAdd) {
56    if (!content.includes(newLine)) {
57      lines.splice(lineIndex + offset, 0, newLine);
58      lineIndex++;
59    }
60  }
61
62  return lines.join('\n');
63}
64
65function replaceLine(content: string, find: string | RegExp, replace: string) {
66  const lines = content.split('\n');
67
68  if (!content.includes(replace)) {
69    const lineIndex = lines.findIndex((line) => line.match(find));
70    lines.splice(lineIndex, 1, replace);
71  }
72
73  return lines.join('\n');
74}
75
76function addJavaImports(javaSource: string, javaImports: string[]): string {
77  const lines = javaSource.split('\n');
78  const lineIndexWithPackageDeclaration = lines.findIndex((line) => line.match(/^package .*;$/));
79  for (const javaImport of javaImports) {
80    if (!javaSource.includes(javaImport)) {
81      const importStatement = `import ${javaImport};`;
82      lines.splice(lineIndexWithPackageDeclaration + 1, 0, importStatement);
83    }
84  }
85  return lines.join('\n');
86}
87
88async function editMainApplication(
89  config: ExportedConfigWithProps,
90  action: (mainApplication: string) => string
91): Promise<void> {
92  const mainApplicationPath = path.join(
93    config.modRequest.platformProjectRoot,
94    'app',
95    'src',
96    'main',
97    'java',
98    ...config.android!.package!.split('.'),
99    'MainApplication.java'
100  );
101
102  try {
103    const mainApplication = action(await readFileAsync(mainApplicationPath));
104    return await saveFileAsync(mainApplicationPath, mainApplication);
105  } catch (e) {
106    WarningAggregator.addWarningAndroid(
107      'expo-dev-launcher',
108      `Couldn't modify MainApplication.java - ${e}.
109See the expo-dev-client installation instructions to modify your MainApplication.java manually: ${InstallationPage}`
110    );
111  }
112}
113
114async function editPodfile(config: ExportedConfigWithProps, action: (podfile: string) => string) {
115  const podfilePath = path.join(config.modRequest.platformProjectRoot, 'Podfile');
116  try {
117    const podfile = action(await readFileAsync(podfilePath));
118    return await saveFileAsync(podfilePath, podfile);
119  } catch (e) {
120    WarningAggregator.addWarningIOS(
121      'expo-dev-launcher',
122      `Couldn't modify AppDelegate.m - ${e}.
123See the expo-dev-client installation instructions to modify your AppDelegate.m manually: ${InstallationPage}`
124    );
125  }
126}
127
128async function editIndex(config: ExportedConfigWithProps, action: (index: string) => string) {
129  const indexPath = path.join(config.modRequest.projectRoot, 'index.js');
130  try {
131    const index = action(await readFileAsync(indexPath));
132    return await saveFileAsync(indexPath, index);
133  } catch (e) {
134    WarningAggregator.addWarningIOS(
135      'expo-dev-launcher',
136      `Couldn't modify index.js - ${e}.
137See the expo-dev-client installation instructions to modify your index.js manually: ${InstallationPage}`
138    );
139  }
140}
141
142const withDevLauncherApplication: ConfigPlugin = (config) => {
143  return withDangerousMod(config, [
144    'android',
145    async (config) => {
146      await editMainApplication(config, (mainApplication) => {
147        mainApplication = addJavaImports(mainApplication, [DEV_LAUNCHER_ANDROID_IMPORT]);
148
149        mainApplication = addLines(mainApplication, 'initializeFlipper\\(this', 0, [
150          `    ${DEV_LAUNCHER_ANDROID_INIT}`,
151        ]);
152
153        let expoUpdatesVersion;
154        try {
155          expoUpdatesVersion = resolveExpoUpdatesVersion(config.modRequest.projectRoot);
156        } catch (e) {
157          WarningAggregator.addWarningAndroid(
158            'expo-dev-launcher',
159            `Failed to check compatibility with expo-updates - ${e}`
160          );
161        }
162        if (expoUpdatesVersion && semver.gt(expoUpdatesVersion, '0.6.0')) {
163          mainApplication = addJavaImports(mainApplication, [DEV_LAUNCHER_UPDATES_ANDROID_IMPORT]);
164          mainApplication = addLines(mainApplication, 'initializeFlipper\\(this', 0, [
165            `    ${DEV_LAUNCHER_UPDATES_ANDROID_INIT}`,
166          ]);
167          mainApplication = replaceLine(
168            mainApplication,
169            'return BuildConfig.DEBUG;',
170            `      ${DEV_LAUNCHER_UPDATES_DEVELOPER_SUPPORT}`
171          );
172        }
173
174        return mainApplication;
175      });
176      return config;
177    },
178  ]);
179};
180
181const withDevLauncherActivity: ConfigPlugin = (config) => {
182  return withMainActivity(config, (config) => {
183    if (config.modResults.language === 'java') {
184      let content = addJavaImports(config.modResults.contents, [
185        DEV_LAUNCHER_ANDROID_IMPORT,
186        'android.content.Intent',
187      ]);
188
189      if (!content.includes(DEV_LAUNCHER_ON_NEW_INTENT)) {
190        const lines = content.split('\n');
191        const onCreateIndex = lines.findIndex((line) => line.includes('public class MainActivity'));
192
193        lines.splice(onCreateIndex + 1, 0, DEV_LAUNCHER_ON_NEW_INTENT);
194
195        content = lines.join('\n');
196      }
197
198      if (!content.includes('DevLauncherController.wrapReactActivityDelegate')) {
199        content = content.replace(
200          /(new ReactActivityDelegate(.*|\s)*});$/m,
201          DEV_LAUNCHER_WRAPPED_ACTIVITY_DELEGATE
202        );
203      }
204
205      config.modResults.contents = content;
206    } else {
207      WarningAggregator.addWarningAndroid(
208        'expo-dev-launcher',
209        `Cannot automatically configure MainActivity if it's not java.
210See the expo-dev-client installation instructions to modify your MainActivity manually: ${InstallationPage}`
211      );
212    }
213
214    return config;
215  });
216};
217
218const withDevLauncherPodfile: ConfigPlugin = (config) => {
219  return withDangerousMod(config, [
220    'ios',
221    async (config) => {
222      await editPodfile(config, (podfile) => {
223        podfile = podfile.replace("platform :ios, '10.0'", "platform :ios, '11.0'");
224        // Match both variations of Ruby config:
225        // unknown: pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', :configurations => :debug
226        // Rubocop: pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', configurations: :debug
227        if (
228          !podfile.match(
229            /pod ['"]expo-dev-launcher['"],\s?path: ['"][^'"]*node_modules\/expo-dev-launcher['"],\s?:?configurations:?\s(?:=>\s)?:debug/
230          )
231        ) {
232          const packagePath = path.dirname(require.resolve('expo-dev-launcher/package.json'));
233          const relativePath = path.relative(config.modRequest.platformProjectRoot, packagePath);
234          podfile = addLines(podfile, 'use_react_native', 0, [
235            `  pod 'expo-dev-launcher', path: '${relativePath}', :configurations => :debug`,
236          ]);
237        }
238        return podfile;
239      });
240      return config;
241    },
242  ]);
243};
244
245const withErrorHandling: ConfigPlugin = (config) => {
246  const injectErrorHandlers = async (config: ExportedConfigWithProps) => {
247    await editIndex(config, (index) => {
248      if (
249        !index.includes(DEV_LAUNCHER_JS_REGISTER_ERROR_HANDLERS) &&
250        !index.includes(DEV_LAUNCHER_JS_REGISTER_ERROR_HANDLERS_VIA_LAUNCHER)
251      ) {
252        index = DEV_LAUNCHER_JS_REGISTER_ERROR_HANDLERS + ';\n\n' + index;
253      }
254      return index;
255    });
256    return config;
257  };
258
259  // We need to run the same task twice to ensure it will work on both platforms,
260  // because if someone runs `expo run:ios`, it will trigger only dangerous mode for that specific platform.
261  // Note: after the first execution, the second one won't change anything.
262  config = withDangerousMod(config, ['android', injectErrorHandlers]);
263  config = withDangerousMod(config, ['ios', injectErrorHandlers]);
264
265  return config;
266};
267
268const withDevLauncher = (config: ExpoConfig) => {
269  config = withDevLauncherActivity(config);
270  config = withDevLauncherApplication(config);
271  config = withDevLauncherPodfile(config);
272  config = withDevLauncherAppDelegate(config);
273  config = withErrorHandling(config);
274  return config;
275};
276
277export default createRunOncePlugin(withDevLauncher, pkg.name, pkg.version);
278