1import { ExpoConfig } from 'expo/config'; 2import { 3 ConfigPlugin, 4 createRunOncePlugin, 5 ExportedConfigWithProps, 6 WarningAggregator, 7 withDangerousMod, 8 withMainActivity, 9} from 'expo/config-plugins'; 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 45async function readFileAsync(path: string): Promise<string> { 46 return fs.promises.readFile(path, 'utf8'); 47} 48 49async function saveFileAsync(path: string, content: string): Promise<void> { 50 return fs.promises.writeFile(path, content, 'utf8'); 51} 52 53function findClosingBracketMatchIndex(str: string, pos: number) { 54 if (str[pos] !== '(') { 55 throw new Error("No '(' at index " + pos); 56 } 57 let depth = 1; 58 for (let i = pos + 1; i < str.length; i++) { 59 switch (str[i]) { 60 case '(': 61 depth++; 62 break; 63 case ')': 64 if (--depth === 0) { 65 return i; 66 } 67 break; 68 } 69 } 70 return -1; // No matching closing parenthesis 71} 72 73const replaceBetween = (origin: string, startIndex: number, endIndex: number, insertion: string) => 74 `${origin.substring(0, startIndex)}${insertion}${origin.substring(endIndex)}`; 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 128const withDevLauncherApplication: ConfigPlugin = (config) => { 129 return withDangerousMod(config, [ 130 'android', 131 async (config) => { 132 await editMainApplication(config, (mainApplication) => { 133 mainApplication = addJavaImports(mainApplication, [DEV_LAUNCHER_ANDROID_IMPORT]); 134 135 mainApplication = addLines(mainApplication, 'initializeFlipper\\(this', 0, [ 136 ` ${DEV_LAUNCHER_ANDROID_INIT}`, 137 ]); 138 139 let expoUpdatesVersion; 140 try { 141 expoUpdatesVersion = resolveExpoUpdatesVersion(config.modRequest.projectRoot); 142 } catch (e) { 143 WarningAggregator.addWarningAndroid( 144 'expo-dev-launcher', 145 `Failed to check compatibility with expo-updates - ${e}` 146 ); 147 } 148 if (expoUpdatesVersion && semver.gt(expoUpdatesVersion, '0.6.0')) { 149 mainApplication = addJavaImports(mainApplication, [DEV_LAUNCHER_UPDATES_ANDROID_IMPORT]); 150 mainApplication = addLines(mainApplication, 'initializeFlipper\\(this', 0, [ 151 ` ${DEV_LAUNCHER_UPDATES_ANDROID_INIT}`, 152 ]); 153 mainApplication = replaceLine( 154 mainApplication, 155 'return BuildConfig.DEBUG;', 156 ` ${DEV_LAUNCHER_UPDATES_DEVELOPER_SUPPORT}` 157 ); 158 } 159 160 return mainApplication; 161 }); 162 return config; 163 }, 164 ]); 165}; 166 167export function modifyJavaMainActivity(content: string): string { 168 content = addJavaImports(content, [DEV_LAUNCHER_ANDROID_IMPORT, 'android.content.Intent']); 169 170 if (!content.includes('onNewIntent')) { 171 const lines = content.split('\n'); 172 const onCreateIndex = lines.findIndex((line) => line.includes('public class MainActivity')); 173 174 lines.splice(onCreateIndex + 1, 0, DEV_LAUNCHER_ON_NEW_INTENT); 175 176 content = lines.join('\n'); 177 } 178 if (!content.includes(DEV_LAUNCHER_HANDLE_INTENT)) { 179 content = addLines(content, /super\.onNewIntent\(intent\)/, 0, [DEV_LAUNCHER_HANDLE_INTENT]); 180 } 181 182 if (!content.includes('DevLauncherController.wrapReactActivityDelegate')) { 183 const activityDelegateMatches = Array.from( 184 content.matchAll(/new ReactActivityDelegate(Wrapper)/g) 185 ); 186 187 if (activityDelegateMatches.length !== 1) { 188 WarningAggregator.addWarningAndroid( 189 'expo-dev-launcher', 190 `Failed to wrap 'ReactActivityDelegate' 191See the expo-dev-client installation instructions to modify your MainActivity.java manually: ${InstallationPage}` 192 ); 193 return content; 194 } 195 196 const activityDelegateMatch = activityDelegateMatches[0]; 197 const matchIndex = activityDelegateMatch.index!; 198 const openingBracketIndex = matchIndex + activityDelegateMatch[0].length; // next character after `new ReactActivityDelegateWrapper` 199 200 const closingBracketIndex = findClosingBracketMatchIndex(content, openingBracketIndex); 201 const reactActivityDelegateDeclaration = content.substring(matchIndex, closingBracketIndex + 1); 202 203 content = replaceBetween( 204 content, 205 matchIndex, 206 closingBracketIndex + 1, 207 DEV_LAUNCHER_WRAPPED_ACTIVITY_DELEGATE(reactActivityDelegateDeclaration) 208 ); 209 } 210 return content; 211} 212 213const withDevLauncherActivity: ConfigPlugin = (config) => { 214 return withMainActivity(config, (config) => { 215 if (config.modResults.language === 'java') { 216 config.modResults.contents = modifyJavaMainActivity(config.modResults.contents); 217 } else { 218 WarningAggregator.addWarningAndroid( 219 'expo-dev-launcher', 220 `Cannot automatically configure MainActivity if it's not java. 221See the expo-dev-client installation instructions to modify your MainActivity manually: ${InstallationPage}` 222 ); 223 } 224 225 return config; 226 }); 227}; 228 229const withDevLauncherPodfile: ConfigPlugin = (config) => { 230 return withDangerousMod(config, [ 231 'ios', 232 async (config) => { 233 await editPodfile(config, (podfile) => { 234 // replace all iOS versions below 12 235 podfile = podfile.replace(/platform :ios, '((\d\.0)|(1[0-1].0))'/, "platform :ios, '13.0'"); 236 // Match both variations of Ruby config: 237 // unknown: pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', :configurations => :debug 238 // Rubocop: pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', configurations: :debug 239 if ( 240 !podfile.match( 241 /pod ['"]expo-dev-launcher['"],\s?path: ['"][^'"]*node_modules\/expo-dev-launcher['"],\s?:?configurations:?\s(?:=>\s)?:debug/ 242 ) 243 ) { 244 const packagePath = path.dirname(require.resolve('expo-dev-launcher/package.json')); 245 const relativePath = path.relative(config.modRequest.platformProjectRoot, packagePath); 246 podfile = addLines(podfile, 'use_react_native', 0, [ 247 ` pod 'expo-dev-launcher', path: '${relativePath}', :configurations => :debug`, 248 ]); 249 } 250 return podfile; 251 }); 252 return config; 253 }, 254 ]); 255}; 256 257const withDevLauncher = (config: ExpoConfig) => { 258 // projects using SDKs before 45 need the old regex-based integration 259 // TODO: remove these once we drop support for SDK 44 260 if (config.sdkVersion && semver.lt(config.sdkVersion, '45.0.0')) { 261 config = withDevLauncherActivity(config); 262 config = withDevLauncherApplication(config); 263 config = withDevLauncherPodfile(config); 264 config = withDevLauncherAppDelegate(config); 265 } 266 return config; 267}; 268 269export default createRunOncePlugin(withDevLauncher, pkg.name, pkg.version); 270