1import os from 'os';
2import path from 'path';
3import chalk from 'chalk';
4import fs from 'fs-extra';
5import xcode from 'xcode';
6import semver from 'semver';
7import inquirer from 'inquirer';
8import glob from 'glob-promise';
9import JsonFile from '@expo/json-file';
10import spawnAsync from '@expo/spawn-async';
11import ncp from 'ncp';
12import { Command } from '@expo/commander';
13
14import * as Directories from '../Directories';
15import * as Npm from '../Npm';
16
17interface ActionOptions {
18  list: boolean;
19  listOutdated: boolean;
20  module: string;
21  platform: 'ios' | 'android' | 'all';
22  commit: string;
23  pbxproj: boolean;
24  semverPrefix: string;
25}
26
27interface VendoredModuleUpdateStep {
28  iosPrefix?: string;
29  sourceIosPath?: string;
30  targetIosPath?: string;
31  sourceAndroidPath?: string;
32  targetAndroidPath?: string;
33  sourceAndroidPackage?: string;
34  targetAndroidPackage?: string;
35  recursive?: boolean;
36  updatePbxproj?: boolean;
37}
38
39type ModuleModifier = (
40  moduleConfig: VendoredModuleConfig,
41  clonedProjectPath: string
42) => Promise<void>;
43
44interface VendoredModuleConfig {
45  repoUrl: string;
46  packageName?: string;
47  packageJsonPath?: string;
48  installableInManagedApps?: boolean;
49  semverPrefix?: '~' | '^';
50  skipCleanup?: boolean;
51  steps: VendoredModuleUpdateStep[];
52  moduleModifier?: ModuleModifier;
53  warnings?: string[];
54}
55
56const IOS_DIR = Directories.getIosDir();
57const ANDROID_DIR = Directories.getAndroidDir();
58const PACKAGES_DIR = Directories.getPackagesDir();
59const BUNDLED_NATIVE_MODULES_PATH = path.join(PACKAGES_DIR, 'expo', 'bundledNativeModules.json');
60
61const ReanimatedModifier: ModuleModifier = async function (
62  moduleConfig: VendoredModuleConfig,
63  clonedProjectPath: string
64): Promise<void> {
65  const firstStep = moduleConfig.steps[0];
66  const androidMainPathReanimated = path.join(clonedProjectPath, 'android', 'src', 'main');
67  const androidMainPathExpoview = path.join(ANDROID_DIR, 'expoview', 'src', 'main');
68  const JNIOldPackagePrefix = firstStep.sourceAndroidPackage!.split('.').join('/');
69  const JNINewPackagePrefix = firstStep.targetAndroidPackage!.split('.').join('/');
70
71  const replaceHermesByJSC = async () => {
72    const nativeProxyPath = path.join(
73      clonedProjectPath,
74      'android',
75      'src',
76      'main',
77      'cpp',
78      'NativeProxy.cpp'
79    );
80    const runtimeCreatingLineJSC = 'jsc::makeJSCRuntime();';
81    const jscImportingLine = '#include <jsi/JSCRuntime.h>';
82    const runtimeCreatingLineHermes = 'facebook::hermes::makeHermesRuntime();';
83    const hermesImportingLine = '#include <hermes/hermes.h>';
84
85    const content = await fs.readFile(nativeProxyPath, 'utf8');
86    let transformedContent = content.replace(runtimeCreatingLineHermes, runtimeCreatingLineJSC);
87    transformedContent = transformedContent.replace(
88      new RegExp(hermesImportingLine, 'g'),
89      jscImportingLine
90    );
91
92    await fs.writeFile(nativeProxyPath, transformedContent, 'utf8');
93  };
94
95  const replaceJNIPackages = async () => {
96    const cppPattern = path.join(androidMainPathReanimated, 'cpp', '**', '*.@(h|cpp)');
97    const androidCpp = await glob(cppPattern);
98    for (const file of androidCpp) {
99      const content = await fs.readFile(file, 'utf8');
100      const transformedContent = content.split(JNIOldPackagePrefix).join(JNINewPackagePrefix);
101      await fs.writeFile(file, transformedContent, 'utf8');
102    }
103  };
104
105  const copyCPP = async () => {
106    const dirs = ['Common', 'cpp'];
107    for (let dir of dirs) {
108      await fs.remove(path.join(androidMainPathExpoview, dir)); // clean
109      // copy
110      await new Promise((res, rej) => {
111        ncp(
112          path.join(androidMainPathReanimated, dir),
113          path.join(androidMainPathExpoview, dir),
114          { dereference: true },
115          () => {
116            res();
117          }
118        );
119      });
120    }
121  };
122
123  const prepareIOSNativeFiles = async () => {
124    const patternCommon = path.join(clonedProjectPath, 'Common', '**', '*.@(h|mm|cpp)');
125    const patternNative = path.join(clonedProjectPath, 'ios', 'native', '**', '*.@(h|mm|cpp)');
126    const commonFiles = await glob(patternCommon);
127    const iosOnlyFiles = await glob(patternNative);
128    const files = [...commonFiles, ...iosOnlyFiles];
129    for (let file of files) {
130      console.log(file);
131      const fileName = file.split(path.sep).slice(-1)[0];
132      await fs.copy(file, path.join(clonedProjectPath, 'ios', fileName));
133    }
134
135    await fs.remove(path.join(clonedProjectPath, 'ios', 'native'));
136  };
137
138  await replaceHermesByJSC();
139  await replaceJNIPackages();
140  await copyCPP();
141  await prepareIOSNativeFiles();
142};
143
144const vendoredModulesConfig: { [key: string]: VendoredModuleConfig } = {
145  'react-native-gesture-handler': {
146    repoUrl: 'https://github.com/software-mansion/react-native-gesture-handler.git',
147    installableInManagedApps: true,
148    semverPrefix: '~',
149    steps: [
150      {
151        sourceAndroidPath: 'android/lib/src/main/java/com/swmansion/gesturehandler',
152        targetAndroidPath: 'modules/api/components/gesturehandler',
153        sourceAndroidPackage: 'com.swmansion.gesturehandler',
154        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.components.gesturehandler',
155      },
156      {
157        recursive: true,
158        sourceIosPath: 'ios',
159        targetIosPath: 'Api/Components/GestureHandler',
160        sourceAndroidPath: 'android/src/main/java/com/swmansion/gesturehandler/react',
161        targetAndroidPath: 'modules/api/components/gesturehandler/react',
162        sourceAndroidPackage: 'com.swmansion.gesturehandler',
163        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.components.gesturehandler',
164      },
165    ],
166    warnings: [
167      `NOTE: Any files in ${chalk.magenta(
168        'com.facebook.react'
169      )} will not be updated -- you'll need to add these to expoview manually!`,
170    ],
171  },
172  'react-native-reanimated': {
173    repoUrl: 'https://github.com/software-mansion/react-native-reanimated.git',
174    installableInManagedApps: true,
175    semverPrefix: '~',
176    moduleModifier: ReanimatedModifier,
177    steps: [
178      {
179        recursive: true,
180        sourceIosPath: 'ios',
181        targetIosPath: 'Api/Reanimated',
182        sourceAndroidPath: 'android/src/main/java/com/swmansion/reanimated',
183        targetAndroidPath: 'modules/api/reanimated',
184        sourceAndroidPackage: 'com.swmansion.reanimated',
185        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.reanimated',
186      },
187    ],
188    warnings: [
189      `NOTE: Any files in ${chalk.magenta(
190        'com.facebook.react'
191      )} will not be updated -- you'll need to add these to expoview manually!`,
192      `NOTE: Some imports have to be changed from ${chalk.magenta('<>')} form to
193      ${chalk.magenta('""')}`,
194    ],
195  },
196  'react-native-screens': {
197    repoUrl: 'https://github.com/software-mansion/react-native-screens.git',
198    installableInManagedApps: true,
199    semverPrefix: '~',
200    steps: [
201      {
202        sourceIosPath: 'ios',
203        targetIosPath: 'Api/Screens',
204        sourceAndroidPath: 'android/src/main/java/com/swmansion/rnscreens',
205        targetAndroidPath: 'modules/api/screens',
206        sourceAndroidPackage: 'com.swmansion.rnscreens',
207        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.screens',
208      },
209    ],
210  },
211  'react-native-appearance': {
212    repoUrl: 'https://github.com/expo/react-native-appearance.git',
213    installableInManagedApps: true,
214    semverPrefix: '~',
215    steps: [
216      {
217        sourceIosPath: 'ios/Appearance',
218        targetIosPath: 'Api/Appearance',
219        sourceAndroidPath: 'android/src/main/java/io/expo/appearance',
220        targetAndroidPath: 'modules/api/appearance/rncappearance',
221        sourceAndroidPackage: 'io.expo.appearance',
222        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.appearance.rncappearance',
223      },
224    ],
225  },
226  'amazon-cognito-identity-js': {
227    repoUrl: 'https://github.com/aws-amplify/amplify-js.git',
228    installableInManagedApps: false,
229    steps: [
230      {
231        sourceIosPath: 'packages/amazon-cognito-identity-js/ios',
232        targetIosPath: 'Api/Cognito',
233        sourceAndroidPath:
234          'packages/amazon-cognito-identity-js/android/src/main/java/com/amazonaws',
235        targetAndroidPath: 'modules/api/cognito',
236        sourceAndroidPackage: 'com.amazonaws',
237        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.cognito',
238      },
239    ],
240  },
241  'react-native-view-shot': {
242    repoUrl: 'https://github.com/gre/react-native-view-shot.git',
243    steps: [
244      {
245        sourceIosPath: 'ios',
246        targetIosPath: 'Api/ViewShot',
247        sourceAndroidPath: 'android/src/main/java/fr/greweb/reactnativeviewshot',
248        targetAndroidPath: 'modules/api/viewshot',
249        sourceAndroidPackage: 'fr.greweb.reactnativeviewshot',
250        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.viewshot',
251      },
252    ],
253  },
254  'react-native-branch': {
255    repoUrl: 'https://github.com/BranchMetrics/react-native-branch-deep-linking.git',
256    steps: [
257      {
258        sourceIosPath: 'ios',
259        targetIosPath: '../../../../packages/expo-branch/ios/EXBranch/RNBranch',
260        sourceAndroidPath: 'android/src/main/java/io/branch/rnbranch',
261        targetAndroidPath:
262          '../../../../../../../../../packages/expo-branch/android/src/main/java/io/branch/rnbranch',
263        sourceAndroidPackage: 'io.branch.rnbranch',
264        targetAndroidPackage: 'io.branch.rnbranch',
265        recursive: false,
266        updatePbxproj: false,
267      },
268    ],
269  },
270  'lottie-react-native': {
271    repoUrl: 'https://github.com/react-native-community/lottie-react-native.git',
272    installableInManagedApps: true,
273    steps: [
274      {
275        iosPrefix: 'LRN',
276        sourceIosPath: 'src/ios/LottieReactNative',
277        targetIosPath: 'Api/Components/Lottie',
278        sourceAndroidPath: 'src/android/src/main/java/com/airbnb/android/react/lottie',
279        targetAndroidPath: 'modules/api/components/lottie',
280        sourceAndroidPackage: 'com.airbnb.android.react.lottie',
281        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.components.lottie',
282      },
283    ],
284  },
285  'react-native-svg': {
286    repoUrl: 'https://github.com/react-native-community/react-native-svg.git',
287    installableInManagedApps: true,
288    steps: [
289      {
290        recursive: true,
291        sourceIosPath: 'ios',
292        targetIosPath: 'Api/Components/Svg',
293        sourceAndroidPath: 'android/src/main/java/com/horcrux/svg',
294        targetAndroidPath: 'modules/api/components/svg',
295        sourceAndroidPackage: 'com.horcrux.svg',
296        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.components.svg',
297      },
298    ],
299  },
300  'react-native-maps': {
301    repoUrl: 'https://github.com/react-native-community/react-native-maps.git',
302    installableInManagedApps: true,
303    steps: [
304      {
305        sourceIosPath: 'lib/ios/AirGoogleMaps',
306        targetIosPath: 'Api/Components/GoogleMaps',
307      },
308      {
309        recursive: true,
310        sourceIosPath: 'lib/ios/AirMaps',
311        targetIosPath: 'Api/Components/Maps',
312        sourceAndroidPath: 'lib/android/src/main/java/com/airbnb/android/react/maps',
313        targetAndroidPath: 'modules/api/components/maps',
314        sourceAndroidPackage: 'com.airbnb.android.react.maps',
315        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.components.maps',
316      },
317    ],
318  },
319  '@react-native-community/netinfo': {
320    repoUrl: 'https://github.com/react-native-community/react-native-netinfo.git',
321    installableInManagedApps: true,
322    steps: [
323      {
324        sourceIosPath: 'ios',
325        targetIosPath: 'Api/NetInfo',
326        sourceAndroidPath: 'android/src/main/java/com/reactnativecommunity/netinfo',
327        targetAndroidPath: 'modules/api/netinfo',
328        sourceAndroidPackage: 'com.reactnativecommunity.netinfo',
329        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.netinfo',
330      },
331    ],
332  },
333  'react-native-webview': {
334    repoUrl: 'https://github.com/react-native-community/react-native-webview.git',
335    installableInManagedApps: true,
336    steps: [
337      {
338        sourceIosPath: 'apple',
339        targetIosPath: 'Api/Components/WebView',
340        sourceAndroidPath: 'android/src/main/java/com/reactnativecommunity/webview',
341        targetAndroidPath: 'modules/api/components/webview',
342        sourceAndroidPackage: 'com.reactnativecommunity.webview',
343        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.components.webview',
344      },
345    ],
346    warnings: [
347      chalk.bold.yellow(
348        `\n${chalk.green('react-native-webview')} exposes ${chalk.blue(
349          'useSharedPool'
350        )} property which has to be handled differently in Expo Client. After upgrading this library, please ensure that proper patch is in place.`
351      ),
352      chalk.bold.yellow(
353        `See commit ${chalk.cyan(
354          'https://github.com/expo/expo/commit/3aeb66e33dc391399ea1c90fd166425130d17a12'
355        )}.\n`
356      ),
357    ],
358  },
359  'react-native-safe-area-context': {
360    repoUrl: 'https://github.com/th3rdwave/react-native-safe-area-context',
361    steps: [
362      {
363        sourceIosPath: 'ios/SafeAreaView',
364        targetIosPath: 'Api/SafeAreaContext',
365        sourceAndroidPath: 'android/src/main/java/com/th3rdwave/safeareacontext',
366        targetAndroidPath: 'modules/api/safeareacontext',
367        sourceAndroidPackage: 'com.th3rdwave.safeareacontext',
368        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.safeareacontext',
369      },
370    ],
371    warnings: [
372      chalk.bold.yellow(
373        `Last time checked, ${chalk.green('react-native-safe-area-context')} used ${chalk.blue(
374          'androidx'
375        )} which wasn't at that time supported by Expo. Please ensure that the project builds on Android after upgrading or remove this warning.`
376      ),
377    ],
378  },
379  '@react-native-community/datetimepicker': {
380    repoUrl: 'https://github.com/react-native-community/react-native-datetimepicker.git',
381    installableInManagedApps: true,
382    steps: [
383      {
384        sourceIosPath: 'ios',
385        targetIosPath: 'Api/Components/DateTimePicker',
386        sourceAndroidPath: 'android/src/main/java/com/reactcommunity/rndatetimepicker',
387        targetAndroidPath: 'modules/api/components/datetimepicker',
388        sourceAndroidPackage: 'com.reactcommunity.rndatetimepicker',
389        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.components.datetimepicker',
390      },
391    ],
392    warnings: [
393      `NOTE: In Expo, native Android styles are prefixed with ${chalk.magenta(
394        'ReactAndroid'
395      )}. Please ensure that ${chalk.magenta(
396        'resourceName'
397      )}s used for grabbing style of dialogs are being resolved properly.`,
398    ],
399  },
400  '@react-native-community/masked-view': {
401    repoUrl: 'https://github.com/react-native-community/react-native-masked-view',
402    installableInManagedApps: true,
403    steps: [
404      {
405        sourceIosPath: 'ios',
406        targetIosPath: 'Api/Components/MaskedView',
407        sourceAndroidPath: 'android/src/main/java/org/reactnative/maskedview',
408        targetAndroidPath: 'modules/api/components/maskedview',
409        sourceAndroidPackage: 'org.reactnative.maskedview',
410        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.components.maskedview',
411      },
412    ],
413  },
414  '@react-native-community/viewpager': {
415    repoUrl: 'https://github.com/react-native-community/react-native-viewpager',
416    installableInManagedApps: true,
417    steps: [
418      {
419        sourceIosPath: 'ios',
420        targetIosPath: 'Api/Components/ViewPager',
421        sourceAndroidPath: 'android/src/main/java/com/reactnativecommunity/viewpager',
422        targetAndroidPath: 'modules/api/components/viewpager',
423        sourceAndroidPackage: 'com.reactnativecommunity.viewpager',
424        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.components.viewpager',
425      },
426    ],
427  },
428  'react-native-shared-element': {
429    repoUrl: 'https://github.com/IjzerenHein/react-native-shared-element',
430    installableInManagedApps: true,
431    steps: [
432      {
433        sourceIosPath: 'ios',
434        targetIosPath: 'Api/Components/SharedElement',
435        sourceAndroidPath: 'android/src/main/java/com/ijzerenhein/sharedelement',
436        targetAndroidPath: 'modules/api/components/sharedelement',
437        sourceAndroidPackage: 'com.ijzerenhein.sharedelement',
438        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.components.sharedelement',
439      },
440    ],
441  },
442  '@react-native-community/segmented-control': {
443    repoUrl: 'https://github.com/react-native-community/segmented-control',
444    installableInManagedApps: true,
445    steps: [
446      {
447        sourceIosPath: 'ios',
448        targetIosPath: 'Api/Components/SegmentedControl',
449      },
450    ],
451  },
452  '@react-native-picker/picker': {
453    repoUrl: 'https://github.com/react-native-picker/picker',
454    installableInManagedApps: true,
455    steps: [
456      {
457        sourceIosPath: 'ios',
458        targetIosPath: 'Api/Components/Picker',
459        sourceAndroidPath: 'android/src/main/java/com/reactnativecommunity/picker',
460        targetAndroidPath: 'modules/api/components/picker',
461        sourceAndroidPackage: 'com.reactnativecommunity.picker',
462        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.components.picker',
463      },
464    ],
465  },
466  '@react-native-community/slider': {
467    repoUrl: 'https://github.com/react-native-community/react-native-slider',
468    installableInManagedApps: true,
469    packageJsonPath: 'src',
470    steps: [
471      {
472        sourceIosPath: 'src/ios',
473        targetIosPath: 'Api/Components/Slider',
474        sourceAndroidPath: 'src/android/src/main/java/com/reactnativecommunity/slider',
475        targetAndroidPath: 'modules/api/components/slider',
476        sourceAndroidPackage: 'com.reactnativecommunity.slider',
477        targetAndroidPackage: 'versioned.host.exp.exponent.modules.api.components.slider',
478      },
479    ],
480  },
481};
482
483async function getBundledNativeModulesAsync(): Promise<{ [key: string]: string }> {
484  return (await JsonFile.readAsync(BUNDLED_NATIVE_MODULES_PATH)) as { [key: string]: string };
485}
486
487async function updateBundledNativeModulesAsync(updater) {
488  console.log(`\nUpdating ${chalk.magenta('bundledNativeModules.json')} ...`);
489
490  const jsonFile = new JsonFile(BUNDLED_NATIVE_MODULES_PATH);
491  const data = await jsonFile.readAsync();
492  await jsonFile.writeAsync(await updater(data));
493}
494
495async function renameIOSSymbolsAsync(file: string, iosPrefix: string) {
496  const content = await fs.readFile(file, 'utf8');
497
498  // Do something more sophisticated if this causes issues with more complex modules.
499  const transformedContent = content.replace(new RegExp(iosPrefix, 'g'), 'EX');
500  const newFileName = file.replace(iosPrefix, 'EX');
501
502  await fs.writeFile(newFileName, transformedContent, 'utf8');
503  await fs.remove(file);
504}
505
506async function findObjcFilesAsync(dir: string, recursive: boolean): Promise<string[]> {
507  const pattern = path.join(dir, recursive ? '**' : '', '*.@(h|m|c|mm|cpp)');
508  return await glob(pattern);
509}
510
511async function renamePackageAndroidAsync(
512  file: string,
513  sourceAndroidPackage: string,
514  targetAndroidPackage: string
515) {
516  const content = await fs.readFile(file, 'utf8');
517
518  // Note: this only works for a single package. If react-native-svg separates
519  // its code into multiple packages we will have to do something more
520  // sophisticated here.
521  const transformedContent = content.replace(
522    new RegExp(sourceAndroidPackage, 'g'),
523    targetAndroidPackage
524  );
525
526  await fs.writeFile(file, transformedContent, 'utf8');
527}
528
529async function findAndroidFilesAsync(dir: string): Promise<string[]> {
530  const pattern = path.join(dir, '**', '*.@(java|kt)');
531  return await glob(pattern);
532}
533
534async function loadXcodeprojFileAsync(file: string): Promise<any> {
535  return new Promise((resolve, reject) => {
536    const pbxproj = xcode.project(file);
537    pbxproj.parse((err) => (err ? reject(err) : resolve(pbxproj)));
538  });
539}
540
541function pbxGroupChild(file) {
542  const obj = Object.create(null);
543  obj.value = file.fileRef;
544  obj.comment = file.basename;
545  return obj;
546}
547
548function pbxGroupHasChildWithRef(group: any, ref: string): boolean {
549  return group.children.some((child) => child.value === ref);
550}
551
552async function addFileToPbxprojAsync(
553  filePath: string,
554  targetDir: string,
555  pbxproj: any
556): Promise<void> {
557  const fileName = path.basename(filePath);
558
559  // The parent group of the target directory that should already be created in the project, e.g. `Components` or `Api`.
560  const targetGroup = pbxproj.pbxGroupByName(path.basename(path.dirname(targetDir)));
561
562  if (!pbxproj.hasFile(fileName)) {
563    console.log(`Adding ${chalk.magenta(fileName)} to pbxproj configuration ...`);
564
565    const fileOptions = {
566      // Mute warnings from 3rd party modules.
567      compilerFlags: '-w',
568    };
569
570    // The group name is mostly just a basename of the path.
571    const groupName = path.basename(path.dirname(filePath));
572
573    // Add a file to pbxproj tree.
574    const file =
575      path.extname(fileName) === '.h'
576        ? pbxproj.addHeaderFile(fileName, fileOptions, groupName)
577        : pbxproj.addSourceFile(fileName, fileOptions, groupName);
578
579    // Search for the group where the file should be placed.
580    const group = pbxproj.pbxGroupByName(groupName);
581
582    // Our files has `includeInIndex` set to 1, so let's continue doing that.
583    file.includeInIndex = 1;
584
585    if (group) {
586      // Add a file if it is not there already.
587      if (!pbxGroupHasChildWithRef(group, file.fileRef)) {
588        group.children.push(pbxGroupChild(file));
589      }
590    } else {
591      // Create a pbx group with this file.
592      const { uuid } = pbxproj.addPbxGroup([file.path], groupName, groupName);
593
594      // Add newly created group to the parent group.
595      if (!pbxGroupHasChildWithRef(targetGroup, uuid)) {
596        targetGroup.children.push(pbxGroupChild({ fileRef: uuid, basename: groupName }));
597      }
598    }
599  }
600}
601
602async function copyFilesAsync(
603  files: string[],
604  sourceDir: string,
605  targetDir: string
606): Promise<void> {
607  for (const file of files) {
608    const fileRelativePath = path.relative(sourceDir, file);
609    const fileTargetPath = path.join(targetDir, fileRelativePath);
610
611    await fs.mkdirs(path.dirname(fileTargetPath));
612    await fs.copy(file, fileTargetPath);
613
614    console.log(chalk.yellow('>'), chalk.magenta(path.relative(targetDir, fileTargetPath)));
615  }
616}
617
618async function listAvailableVendoredModulesAsync(onlyOutdated: boolean = false) {
619  const bundledNativeModules = await getBundledNativeModulesAsync();
620  const vendoredPackageNames = Object.keys(vendoredModulesConfig);
621  const packageViews: Npm.PackageViewType[] = await Promise.all(
622    vendoredPackageNames.map((packageName: string) => Npm.getPackageViewAsync(packageName))
623  );
624
625  for (const packageName of vendoredPackageNames) {
626    const packageView = packageViews.shift();
627
628    if (!packageView) {
629      console.error(
630        chalk.red.bold(`Couldn't get package view for ${chalk.green.bold(packageName)}.\n`)
631      );
632      continue;
633    }
634
635    const moduleConfig = vendoredModulesConfig[packageName];
636    const bundledVersion = bundledNativeModules[packageName];
637    const latestVersion = packageView.versions[packageView.versions.length - 1];
638
639    if (!onlyOutdated || !bundledVersion || semver.gtr(latestVersion, bundledVersion)) {
640      console.log(chalk.bold.green(packageName));
641      console.log(`${chalk.yellow('>')} repository     : ${chalk.magenta(moduleConfig.repoUrl)}`);
642      console.log(
643        `${chalk.yellow('>')} bundled version: ${(bundledVersion ? chalk.cyan : chalk.gray)(
644          bundledVersion
645        )}`
646      );
647      console.log(`${chalk.yellow('>')} latest version : ${chalk.cyan(latestVersion)}`);
648      console.log();
649    }
650  }
651}
652
653async function askForModuleAsync(): Promise<string> {
654  const { moduleName } = await inquirer.prompt<{ moduleName: string }>([
655    {
656      type: 'list',
657      name: 'moduleName',
658      message: 'Which 3rd party module would you like to update?',
659      choices: Object.keys(vendoredModulesConfig),
660    },
661  ]);
662  return moduleName;
663}
664
665async function getPackageJsonPathsAsync(): Promise<string[]> {
666  const packageJsonPath = path.join(Directories.getAppsDir(), '**', 'package.json');
667  return await glob(packageJsonPath, { ignore: '**/node_modules/**' });
668}
669
670async function updateWorkspaceDependencies(
671  dependencyName: string,
672  versionRange: string
673): Promise<boolean> {
674  const paths = await getPackageJsonPathsAsync();
675  const results = await Promise.all(
676    paths.map((path) => updateDependencyAsync(path, dependencyName, versionRange))
677  );
678  return results.some(Boolean);
679}
680
681async function updateHomeDependencies(
682  dependencyName: string,
683  versionRange: string
684): Promise<boolean> {
685  const packageJsonPath = path.join(Directories.getExpoHomeJSDir(), 'package.json');
686  return await updateDependencyAsync(packageJsonPath, dependencyName, versionRange);
687}
688
689async function updateDependencyAsync(
690  packageJsonPath: string,
691  dependencyName: string,
692  newVersionRange: string
693): Promise<boolean> {
694  const jsonFile = new JsonFile(packageJsonPath);
695  const packageJson = await jsonFile.readAsync();
696
697  const dependencies = (packageJson || {}).dependencies || {};
698  if (dependencies[dependencyName] && dependencies[dependencyName] !== newVersionRange) {
699    console.log(
700      `${chalk.yellow('>')} ${chalk.green(packageJsonPath)}: ${chalk.magentaBright(
701        dependencies[dependencyName]
702      )} -> ${chalk.magentaBright(newVersionRange)}`
703    );
704    dependencies[dependencyName] = newVersionRange;
705    await jsonFile.writeAsync(packageJson);
706    return true;
707  }
708  return false;
709}
710
711async function action(options: ActionOptions) {
712  if (options.list || options.listOutdated) {
713    await listAvailableVendoredModulesAsync(options.listOutdated);
714    return;
715  }
716
717  const moduleName = options.module || (await askForModuleAsync());
718  const moduleConfig = vendoredModulesConfig[moduleName];
719
720  if (!moduleConfig) {
721    throw new Error(
722      `Module \`${chalk.green(
723        moduleName
724      )}\` doesn't match any of currently supported 3rd party modules. Run with \`--list\` to show a list of modules.`
725    );
726  }
727
728  moduleConfig.installableInManagedApps =
729    moduleConfig.installableInManagedApps == null ? true : moduleConfig.installableInManagedApps;
730
731  const tmpDir = path.join(os.tmpdir(), moduleName);
732
733  // Cleanup tmp dir.
734  await fs.remove(tmpDir);
735
736  console.log(
737    `Cloning ${chalk.green(moduleName)}${chalk.red('#')}${chalk.cyan(
738      options.commit
739    )} from GitHub ...`
740  );
741
742  // Clone the repository.
743  await spawnAsync('git', ['clone', moduleConfig.repoUrl, tmpDir]);
744
745  // Checkout at given commit (defaults to master).
746  await spawnAsync('git', ['checkout', options.commit], { cwd: tmpDir });
747
748  if (moduleConfig.warnings) {
749    moduleConfig.warnings.forEach((warning) => console.warn(warning));
750  }
751
752  if (moduleConfig.moduleModifier) {
753    await moduleConfig.moduleModifier(moduleConfig, tmpDir);
754  }
755
756  for (const step of moduleConfig.steps) {
757    const executeAndroid = ['all', 'android'].includes(options.platform);
758    const executeIOS = ['all', 'ios'].includes(options.platform);
759
760    step.recursive = step.recursive === true;
761    step.updatePbxproj = !(step.updatePbxproj === false);
762
763    // iOS
764    if (executeIOS && step.sourceIosPath && step.targetIosPath) {
765      const sourceDir = path.join(tmpDir, step.sourceIosPath);
766      const targetDir = path.join(IOS_DIR, 'Exponent', 'Versioned', 'Core', step.targetIosPath);
767
768      console.log(
769        `\nCleaning up iOS files at ${chalk.magenta(path.relative(IOS_DIR, targetDir))} ...`
770      );
771
772      await fs.remove(targetDir);
773      await fs.mkdirs(targetDir);
774
775      console.log('\nCopying iOS files ...');
776
777      const objcFiles = await findObjcFilesAsync(sourceDir, step.recursive);
778      const pbxprojPath = path.join(IOS_DIR, 'Exponent.xcodeproj', 'project.pbxproj');
779      const pbxproj = await loadXcodeprojFileAsync(pbxprojPath);
780
781      await copyFilesAsync(objcFiles, sourceDir, targetDir);
782
783      if (options.pbxproj && step.updatePbxproj) {
784        console.log(`\nUpdating pbxproj configuration ...`);
785
786        for (const file of objcFiles) {
787          const fileRelativePath = path.relative(sourceDir, file);
788          const fileTargetPath = path.join(targetDir, fileRelativePath);
789
790          await addFileToPbxprojAsync(fileTargetPath, targetDir, pbxproj);
791        }
792
793        console.log(
794          `Saving updated pbxproj structure to the file ${chalk.magenta(
795            path.relative(IOS_DIR, pbxprojPath)
796          )} ...`
797        );
798        await fs.writeFile(pbxprojPath, pbxproj.writeSync());
799      }
800
801      if (step.iosPrefix) {
802        console.log(
803          `\nUpdating classes prefix from ${chalk.yellow(step.iosPrefix)} to ${chalk.yellow(
804            'EX'
805          )} ...`
806        );
807
808        const files = await findObjcFilesAsync(targetDir, step.recursive);
809
810        for (const file of files) {
811          await renameIOSSymbolsAsync(file, step.iosPrefix);
812        }
813      }
814
815      console.log(
816        chalk.yellow(
817          `\nSuccessfully updated iOS files, but please make sure Xcode project files are setup correctly in ${chalk.magenta(
818            `Exponent/Versioned/Core/${step.targetIosPath}`
819          )}`
820        )
821      );
822    }
823
824    // Android
825    if (
826      executeAndroid &&
827      step.sourceAndroidPath &&
828      step.targetAndroidPath &&
829      step.sourceAndroidPackage &&
830      step.targetAndroidPackage
831    ) {
832      const sourceDir = path.join(tmpDir, step.sourceAndroidPath);
833      const targetDir = path.join(
834        ANDROID_DIR,
835        'expoview',
836        'src',
837        'main',
838        'java',
839        'versioned',
840        'host',
841        'exp',
842        'exponent',
843        step.targetAndroidPath
844      );
845
846      console.log(
847        `\nCleaning up Android files at ${chalk.magenta(path.relative(ANDROID_DIR, targetDir))} ...`
848      );
849
850      await fs.remove(targetDir);
851      await fs.mkdirs(targetDir);
852
853      console.log('\nCopying Android files ...');
854
855      const javaFiles = await findAndroidFilesAsync(sourceDir);
856
857      await copyFilesAsync(javaFiles, sourceDir, targetDir);
858
859      const files = await findAndroidFilesAsync(targetDir);
860
861      for (const file of files) {
862        await renamePackageAndroidAsync(file, step.sourceAndroidPackage, step.targetAndroidPackage);
863      }
864    }
865  }
866  const { name, version } = await JsonFile.readAsync<{
867    name: string;
868    version: string;
869  }>(path.join(tmpDir, moduleConfig.packageJsonPath ?? '', 'package.json'));
870  const semverPrefix =
871    (options.semverPrefix != null ? options.semverPrefix : moduleConfig.semverPrefix) || '';
872  const versionRange = `${semverPrefix}${version}`;
873
874  await updateBundledNativeModulesAsync(async (bundledNativeModules) => {
875    if (moduleConfig.installableInManagedApps) {
876      bundledNativeModules[name] = versionRange;
877      console.log(
878        `Updated ${chalk.green(name)} in ${chalk.magenta(
879          'bundledNativeModules.json'
880        )} to version range ${chalk.cyan(versionRange)}`
881      );
882    } else if (bundledNativeModules[name]) {
883      delete bundledNativeModules[name];
884      console.log(
885        `Removed non-installable package ${chalk.green(name)} from ${chalk.magenta(
886          'bundledNativeModules.json'
887        )}`
888      );
889    }
890    return bundledNativeModules;
891  });
892
893  console.log(`\nUpdating ${chalk.green(name)} in workspace projects...`);
894  const homeWasUpdated = await updateHomeDependencies(name, versionRange);
895  const workspaceWasUpdated = await updateWorkspaceDependencies(name, versionRange);
896
897  // We updated dependencies so we need to run yarn.
898  if (homeWasUpdated || workspaceWasUpdated) {
899    console.log(`\nRunning \`${chalk.cyan(`yarn`)}\`...`);
900    await spawnAsync('yarn', [], {
901      cwd: Directories.getExpoRepositoryRootDir(),
902    });
903  }
904
905  if (homeWasUpdated) {
906    console.log(`\nHome dependencies were updated. You need to publish the new dev home version.`);
907  }
908
909  console.log(
910    `\nFinished updating ${chalk.green(
911      moduleName
912    )}, make sure to update files in the Xcode project (if you updated iOS, see logs above) and test that it still works. ��`
913  );
914}
915
916export default (program: Command) => {
917  program
918    .command('update-vendored-module')
919    .alias('update-module', 'uvm')
920    .description('Updates 3rd party modules.')
921    .option('-l, --list', 'Shows a list of available 3rd party modules.', false)
922    .option('-o, --list-outdated', 'Shows a list of outdated 3rd party modules.', false)
923    .option('-m, --module <string>', 'Name of the module to update.')
924    .option(
925      '-p, --platform <string>',
926      'A platform on which the vendored module will be updated.',
927      'all'
928    )
929    .option(
930      '-c, --commit <string>',
931      'Git reference on which to checkout when copying 3rd party module.',
932      'master'
933    )
934    .option(
935      '-s, --semver-prefix <string>',
936      'Setting this flag forces to use given semver prefix. Some modules may specify them by the config, but in case we want to update to alpha/beta versions we should use an empty prefix to be more strict.',
937      null
938    )
939    .option('--no-pbxproj', 'Whether to skip updating project.pbxproj file.', false)
940    .asyncAction(action);
941};
942