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 { addLines, replaceLine } from './utils'; 17import { withDevLauncherAppDelegate } from './withDevLauncherAppDelegate'; 18 19const pkg = require('expo-dev-launcher/package.json'); 20 21const DEV_LAUNCHER_ANDROID_IMPORT = 'expo.modules.devlauncher.DevLauncherController'; 22const DEV_LAUNCHER_UPDATES_ANDROID_IMPORT = 'expo.modules.updates.UpdatesDevLauncherController'; 23const DEV_LAUNCHER_ON_NEW_INTENT = [ 24 '', 25 ' @Override', 26 ' public void onNewIntent(Intent intent) {', 27 ' super.onNewIntent(intent);', 28 ' }', 29 '', 30].join('\n'); 31const DEV_LAUNCHER_HANDLE_INTENT = [ 32 ' if (DevLauncherController.tryToHandleIntent(this, intent)) {', 33 ' return;', 34 ' }', 35].join('\n'); 36const DEV_LAUNCHER_WRAPPED_ACTIVITY_DELEGATE = (activityDelegateDeclaration: string) => 37 `DevLauncherController.wrapReactActivityDelegate(this, () -> ${activityDelegateDeclaration})`; 38const DEV_LAUNCHER_ANDROID_INIT = 'DevLauncherController.initialize(this, getReactNativeHost());'; 39const DEV_LAUNCHER_UPDATES_ANDROID_INIT = `if (BuildConfig.DEBUG) { 40 DevLauncherController.getInstance().setUpdatesInterface(UpdatesDevLauncherController.initialize(this)); 41 }`; 42const DEV_LAUNCHER_UPDATES_DEVELOPER_SUPPORT = 43 'return DevLauncherController.getInstance().getUseDeveloperSupport();'; 44 45const DEV_LAUNCHER_JS_REGISTER_ERROR_HANDLERS_REGEX = 46 /import ['"](?:expo-dev-client|expo-dev-launcher)['"]/; 47 48async function readFileAsync(path: string): Promise<string> { 49 return fs.promises.readFile(path, 'utf8'); 50} 51 52async function saveFileAsync(path: string, content: string): Promise<void> { 53 return fs.promises.writeFile(path, content, 'utf8'); 54} 55 56function findClosingBracketMatchIndex(str: string, pos: number) { 57 if (str[pos] !== '(') { 58 throw new Error("No '(' at index " + pos); 59 } 60 let depth = 1; 61 for (let i = pos + 1; i < str.length; i++) { 62 switch (str[i]) { 63 case '(': 64 depth++; 65 break; 66 case ')': 67 if (--depth === 0) { 68 return i; 69 } 70 break; 71 } 72 } 73 return -1; // No matching closing parenthesis 74} 75 76const replaceBetween = (origin: string, startIndex: number, endIndex: number, insertion: string) => 77 `${origin.substring(0, startIndex)}${insertion}${origin.substring(endIndex)}`; 78 79function addJavaImports(javaSource: string, javaImports: string[]): string { 80 const lines = javaSource.split('\n'); 81 const lineIndexWithPackageDeclaration = lines.findIndex((line) => line.match(/^package .*;$/)); 82 for (const javaImport of javaImports) { 83 if (!javaSource.includes(javaImport)) { 84 const importStatement = `import ${javaImport};`; 85 lines.splice(lineIndexWithPackageDeclaration + 1, 0, importStatement); 86 } 87 } 88 return lines.join('\n'); 89} 90 91async function editMainApplication( 92 config: ExportedConfigWithProps, 93 action: (mainApplication: string) => string 94): Promise<void> { 95 const mainApplicationPath = path.join( 96 config.modRequest.platformProjectRoot, 97 'app', 98 'src', 99 'main', 100 'java', 101 ...config.android!.package!.split('.'), 102 'MainApplication.java' 103 ); 104 105 try { 106 const mainApplication = action(await readFileAsync(mainApplicationPath)); 107 return await saveFileAsync(mainApplicationPath, mainApplication); 108 } catch (e) { 109 WarningAggregator.addWarningAndroid( 110 'expo-dev-launcher', 111 `Couldn't modify MainApplication.java - ${e}. 112See the expo-dev-client installation instructions to modify your MainApplication.java manually: ${InstallationPage}` 113 ); 114 } 115} 116 117async function editPodfile(config: ExportedConfigWithProps, action: (podfile: string) => string) { 118 const podfilePath = path.join(config.modRequest.platformProjectRoot, 'Podfile'); 119 try { 120 const podfile = action(await readFileAsync(podfilePath)); 121 return await saveFileAsync(podfilePath, podfile); 122 } catch (e) { 123 WarningAggregator.addWarningIOS( 124 'expo-dev-launcher', 125 `Couldn't modify AppDelegate.m - ${e}. 126See the expo-dev-client installation instructions to modify your AppDelegate.m manually: ${InstallationPage}` 127 ); 128 } 129} 130 131async function editIndex(config: ExportedConfigWithProps, action: (index: string) => string) { 132 const indexPath = path.join(config.modRequest.projectRoot, 'index.js'); 133 try { 134 const index = action(await readFileAsync(indexPath)); 135 return await saveFileAsync(indexPath, index); 136 } catch (e) { 137 WarningAggregator.addWarningIOS( 138 'expo-dev-launcher', 139 `Couldn't modify index.js - ${e}. 140See the expo-dev-client installation instructions to modify your index.js manually: ${InstallationPage}` 141 ); 142 } 143} 144 145const withDevLauncherApplication: ConfigPlugin = (config) => { 146 return withDangerousMod(config, [ 147 'android', 148 async (config) => { 149 await editMainApplication(config, (mainApplication) => { 150 mainApplication = addJavaImports(mainApplication, [DEV_LAUNCHER_ANDROID_IMPORT]); 151 152 mainApplication = addLines(mainApplication, 'initializeFlipper\\(this', 0, [ 153 ` ${DEV_LAUNCHER_ANDROID_INIT}`, 154 ]); 155 156 let expoUpdatesVersion; 157 try { 158 expoUpdatesVersion = resolveExpoUpdatesVersion(config.modRequest.projectRoot); 159 } catch (e) { 160 WarningAggregator.addWarningAndroid( 161 'expo-dev-launcher', 162 `Failed to check compatibility with expo-updates - ${e}` 163 ); 164 } 165 if (expoUpdatesVersion && semver.gt(expoUpdatesVersion, '0.6.0')) { 166 mainApplication = addJavaImports(mainApplication, [DEV_LAUNCHER_UPDATES_ANDROID_IMPORT]); 167 mainApplication = addLines(mainApplication, 'initializeFlipper\\(this', 0, [ 168 ` ${DEV_LAUNCHER_UPDATES_ANDROID_INIT}`, 169 ]); 170 mainApplication = replaceLine( 171 mainApplication, 172 'return BuildConfig.DEBUG;', 173 ` ${DEV_LAUNCHER_UPDATES_DEVELOPER_SUPPORT}` 174 ); 175 } 176 177 return mainApplication; 178 }); 179 return config; 180 }, 181 ]); 182}; 183 184export function modifyJavaMainActivity(content: string): string { 185 content = addJavaImports(content, [DEV_LAUNCHER_ANDROID_IMPORT, 'android.content.Intent']); 186 187 if (!content.includes('onNewIntent')) { 188 const lines = content.split('\n'); 189 const onCreateIndex = lines.findIndex((line) => line.includes('public class MainActivity')); 190 191 lines.splice(onCreateIndex + 1, 0, DEV_LAUNCHER_ON_NEW_INTENT); 192 193 content = lines.join('\n'); 194 } 195 if (!content.includes(DEV_LAUNCHER_HANDLE_INTENT)) { 196 content = addLines(content, /super\.onNewIntent\(intent\)/, 0, [DEV_LAUNCHER_HANDLE_INTENT]); 197 } 198 199 if (!content.includes('DevLauncherController.wrapReactActivityDelegate')) { 200 const activityDelegateMatches = Array.from( 201 content.matchAll(/new ReactActivityDelegate(Wrapper)/g) 202 ); 203 204 if (activityDelegateMatches.length !== 1) { 205 WarningAggregator.addWarningAndroid( 206 'expo-dev-launcher', 207 `Failed to wrap 'ReactActivityDelegate' 208See the expo-dev-client installation instructions to modify your MainActivity.java manually: ${InstallationPage}` 209 ); 210 return content; 211 } 212 213 const activityDelegateMatch = activityDelegateMatches[0]; 214 const matchIndex = activityDelegateMatch.index!; 215 const openingBracketIndex = matchIndex + activityDelegateMatch[0].length; // next character after `new ReactActivityDelegateWrapper` 216 217 const closingBracketIndex = findClosingBracketMatchIndex(content, openingBracketIndex); 218 const reactActivityDelegateDeclaration = content.substring(matchIndex, closingBracketIndex + 1); 219 220 content = replaceBetween( 221 content, 222 matchIndex, 223 closingBracketIndex + 1, 224 DEV_LAUNCHER_WRAPPED_ACTIVITY_DELEGATE(reactActivityDelegateDeclaration) 225 ); 226 } 227 return content; 228} 229 230const withDevLauncherActivity: ConfigPlugin = (config) => { 231 return withMainActivity(config, (config) => { 232 if (config.modResults.language === 'java') { 233 config.modResults.contents = modifyJavaMainActivity(config.modResults.contents); 234 } else { 235 WarningAggregator.addWarningAndroid( 236 'expo-dev-launcher', 237 `Cannot automatically configure MainActivity if it's not java. 238See the expo-dev-client installation instructions to modify your MainActivity manually: ${InstallationPage}` 239 ); 240 } 241 242 return config; 243 }); 244}; 245 246const withDevLauncherPodfile: ConfigPlugin = (config) => { 247 return withDangerousMod(config, [ 248 'ios', 249 async (config) => { 250 await editPodfile(config, (podfile) => { 251 // replace all iOS versions below 12 252 podfile = podfile.replace(/platform :ios, '((\d\.0)|(1[0-1].0))'/, "platform :ios, '12.0'"); 253 // Match both variations of Ruby config: 254 // unknown: pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', :configurations => :debug 255 // Rubocop: pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', configurations: :debug 256 if ( 257 !podfile.match( 258 /pod ['"]expo-dev-launcher['"],\s?path: ['"][^'"]*node_modules\/expo-dev-launcher['"],\s?:?configurations:?\s(?:=>\s)?:debug/ 259 ) 260 ) { 261 const packagePath = path.dirname(require.resolve('expo-dev-launcher/package.json')); 262 const relativePath = path.relative(config.modRequest.platformProjectRoot, packagePath); 263 podfile = addLines(podfile, 'use_react_native', 0, [ 264 ` pod 'expo-dev-launcher', path: '${relativePath}', :configurations => :debug`, 265 ]); 266 } 267 return podfile; 268 }); 269 return config; 270 }, 271 ]); 272}; 273 274const withErrorHandling: ConfigPlugin = (config) => { 275 const injectErrorHandlers = async (config: ExportedConfigWithProps) => { 276 await editIndex(config, (index) => { 277 if (!DEV_LAUNCHER_JS_REGISTER_ERROR_HANDLERS_REGEX.test(index)) { 278 index = `import 'expo-dev-client';\n\n${index}`; 279 } 280 return index; 281 }); 282 return config; 283 }; 284 285 // We need to run the same task twice to ensure it will work on both platforms, 286 // because if someone runs `expo run:ios`, it will trigger only dangerous mode for that specific platform. 287 // Note: after the first execution, the second one won't change anything. 288 config = withDangerousMod(config, ['android', injectErrorHandlers]); 289 config = withDangerousMod(config, ['ios', injectErrorHandlers]); 290 291 return config; 292}; 293 294const withDevLauncher = (config: ExpoConfig) => { 295 // projects using SDKs before 45 need the old regex-based integration 296 // TODO: remove these once we drop support for SDK 44 297 if (config.sdkVersion && semver.lt(config.sdkVersion, '45.0.0')) { 298 config = withDevLauncherActivity(config); 299 config = withDevLauncherApplication(config); 300 config = withDevLauncherPodfile(config); 301 config = withDevLauncherAppDelegate(config); 302 } 303 config = withErrorHandling(config); 304 return config; 305}; 306 307export default createRunOncePlugin(withDevLauncher, pkg.name, pkg.version); 308