microsoft/vscode-react-native

Public

mirrored fromhttps://github.com/microsoft/vscode-react-nativeAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
dev/v-peq/add-expo-packager-command-tests

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

src/extension/exponent/exponentHelper.ts

542lines · modecode

1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT license. See LICENSE file in the project root for details.
3
4// eslint-disable-next-line @typescript-eslint/triple-slash-reference
5/// <reference path="exponentHelper.d.ts" />
6
7import * as path from "path";
8import * as fs from "fs";
9import * as semver from "semver";
10import * as vscode from "vscode";
11import { sync as globSync } from "glob";
12import * as nls from "vscode-nls";
13import { logger } from "@vscode/debugadapter";
14import { stripJsonTrailingComma, getNodeModulesGlobalPath } from "../../common/utils";
15import { Package, IPackageInformation } from "../../common/node/package";
16import { ProjectVersionHelper } from "../../common/projectVersionHelper";
17import { OutputChannelLogger } from "../log/OutputChannelLogger";
18import { ErrorHelper } from "../../common/error/errorHelper";
19import { PackageLoader, PackageConfig } from "../../common/packageLoader";
20import { InternalErrorCode } from "../../common/error/internalErrorCode";
21import { FileSystem } from "../../common/node/fileSystem";
22import { SettingsHelper } from "../settingsHelper";
23import * as XDL from "./xdlInterface";
24
25nls.config({
26 messageFormat: nls.MessageFormat.bundle,
27 bundleFormat: nls.BundleFormat.standalone,
28})();
29const localize = nls.loadMessageBundle();
30
31const APP_JSON = "app.json";
32const EXP_JSON = "exp.json";
33
34const EXPONENT_INDEX = "exponentIndex.js";
35const DEFAULT_EXPONENT_INDEX = "index.js";
36const DEFAULT_IOS_INDEX = "index.ios.js";
37const DEFAULT_ANDROID_INDEX = "index.android.js";
38
39const DBL_SLASHES = /\\/g;
40
41const NGROK_PACKAGE = "@expo/ngrok";
42
43export class ExponentHelper {
44 private workspaceRootPath: string;
45 private projectRootPath: string;
46 private fs: FileSystem;
47 private hasInitialized: boolean;
48 private nodeModulesGlobalPathAddedToEnv: boolean;
49 private logger: OutputChannelLogger = OutputChannelLogger.getMainChannel();
50
51 public constructor(
52 workspaceRootPath: string,
53 projectRootPath: string,
54 fs: FileSystem = new FileSystem(),
55 ) {
56 this.workspaceRootPath = workspaceRootPath;
57 this.projectRootPath = projectRootPath;
58 this.fs = fs;
59 this.hasInitialized = false;
60 // Constructor is slim by design. This is to add as less computation as possible
61 // to the initialization of the extension. If a public method is added, make sure
62 // to call this.lazilyInitialize() at the beginning of the code to be sure all variables
63 // are correctly initialized.
64 this.nodeModulesGlobalPathAddedToEnv = false;
65 }
66
67 public async preloadExponentDependency(): Promise<[typeof xdl, typeof metroConfig]> {
68 this.logger.info(
69 localize(
70 "MakingSureYourProjectUsesCorrectExponentDependencies",
71 "Making sure your project uses the correct dependencies for Expo. This may take a while...",
72 ),
73 );
74 return Promise.all([XDL.getXDLPackage(), XDL.getMetroConfigPackage()]);
75 }
76
77 public async configureExponentEnvironment(): Promise<void> {
78 await this.lazilyInitialize();
79 this.logger.logStream(
80 localize("CheckingIfThisIsExpoApp", "Checking if this is an Expo app."),
81 );
82 this.logger.logStream("\n");
83
84 const packageJson = await this.getAppPackageInformation();
85 if (!packageJson.name || !packageJson.version) {
86 this.logger.warning(
87 localize(
88 "MissingFieldsInExpoApp",
89 "Missing 'name' or 'version' field in package.json. These fields might be required for your application.",
90 ),
91 );
92 }
93
94 const isExpo = await this.isExpoManagedApp(true);
95 if (!isExpo) {
96 if (!(await this.appHasExpoInstalled())) {
97 // Expo requires expo package to be installed inside RN application in order to be able to run it
98 // https://github.com/expo/expo-cli/issues/255#issuecomment-453214632
99 this.logger.logStream("\n");
100 this.logger.logStream(
101 localize(
102 "ExpoPackageIsNotInstalled",
103 '[Warning] Please make sure that expo package is installed locally for your project, otherwise further errors may occur. Please, run "npm install expo --save-dev" inside your project to install it.',
104 ),
105 );
106 this.logger.logStream("\n");
107 }
108 }
109 this.logger.logStream(".\n");
110 await this.patchAppJson(isExpo);
111 }
112
113 /**
114 * Returns the current user. If there is none, asks user for username and password and logins to exponent servers.
115 */
116 public async loginToExponent(
117 promptForInformation: (message: string, password: boolean) => Promise<string>,
118 showMessage: (message: string) => Promise<string>,
119 ): Promise<XDL.IUser> {
120 await this.lazilyInitialize();
121 let user = await XDL.currentUser();
122 if (!user) {
123 await showMessage(
124 localize(
125 "YouNeedToLoginToExpo",
126 "You need to login to Expo. Please provide your Expo account username and password in the input boxes after closing this window. If you don't have an account, please go to https://expo.io to create one.",
127 ),
128 );
129 const username = await promptForInformation(
130 localize("ExpoUsername", "Expo username"),
131 false,
132 );
133 const password = await promptForInformation(
134 localize("ExpoPassword", "Expo password"),
135 true,
136 );
137 user = await XDL.login(username, password);
138 }
139 return user;
140 }
141
142 public async getExpPackagerOptions(projectRoot: string): Promise<ExpMetroConfig> {
143 await this.lazilyInitialize();
144 const options = await this.getFromExpConfig<any>("packagerOpts").then(opts => opts || {});
145 const metroConfig = await this.getArgumentsFromExpoMetroConfig(projectRoot);
146 return { ...options, ...metroConfig };
147 }
148
149 public async appHasExpoInstalled(): Promise<boolean> {
150 const packageJson = await this.getAppPackageInformation();
151 if (packageJson.dependencies && packageJson.dependencies.expo) {
152 this.logger.debug("'expo' package is found in 'dependencies' section of package.json");
153 return true;
154 } else if (packageJson.devDependencies && packageJson.devDependencies.expo) {
155 this.logger.debug(
156 "'expo' package is found in 'devDependencies' section of package.json",
157 );
158 return true;
159 }
160 return false;
161 }
162
163 public async isExpoManagedApp(showProgress: boolean = false): Promise<boolean> {
164 if (showProgress) {
165 this.logger.logStream("...");
166 }
167
168 try {
169 const expoInstalled = await this.appHasExpoInstalled();
170 if (!expoInstalled) return false;
171
172 const isBareWorkflowProject = await this.isBareWorkflowProject();
173 if (showProgress) this.logger.logStream(".");
174 return !isBareWorkflowProject;
175 } catch (e) {
176 this.logger.error((e as any as Error).message, e as any, (e as any).stack);
177 if (showProgress) {
178 this.logger.logStream(".");
179 }
180 // Not in a react-native project
181 return false;
182 }
183 }
184
185 public async findOrInstallNgrokGlobally(): Promise<void> {
186 let ngrokInstalled: boolean;
187 try {
188 await this.addNodeModulesPathToEnvIfNotPresent();
189 ngrokInstalled = await XDL.isNgrokInstalled(this.projectRootPath);
190 } catch (e) {
191 ngrokInstalled = false;
192 }
193 if (!ngrokInstalled) {
194 const ngrokVersion = SettingsHelper.getExpoDependencyVersion("@expo/ngrok");
195 const ngrokPackageConfig = new PackageConfig(NGROK_PACKAGE, ngrokVersion);
196
197 const outputMessage = localize(
198 "ExpoInstallNgrokGlobally",
199 'It seems that "{0}" package isn\'t installed globally. This package is required to use Expo tunnels, would you like to install it globally?',
200 ngrokPackageConfig.getStringForInstall(),
201 );
202 const installButton = localize("InstallNgrokGloballyButtonOK", "Install");
203 const cancelButton = localize("InstallNgrokGloballyButtonCancel", "Cancel");
204
205 const selectedItem = await vscode.window.showWarningMessage(
206 outputMessage,
207 installButton,
208 cancelButton,
209 );
210 if (selectedItem === installButton) {
211 await PackageLoader.getInstance().installGlobalPackage(
212 ngrokPackageConfig,
213 this.projectRootPath,
214 );
215 this.logger.info(
216 localize(
217 "NgrokInstalledGlobally",
218 '"{0}" package has been successfully installed globally.',
219 ngrokPackageConfig.getStringForInstall(),
220 ),
221 );
222 } else {
223 throw ErrorHelper.getInternalError(
224 InternalErrorCode.NgrokIsNotInstalledGlobally,
225 ngrokPackageConfig.getVersion(true),
226 );
227 }
228 }
229 }
230
231 public removeNodeModulesPathFromEnvIfWasSet(): void {
232 if (this.nodeModulesGlobalPathAddedToEnv) {
233 delete process.env.NODE_MODULES;
234 this.nodeModulesGlobalPathAddedToEnv = false;
235 }
236 }
237
238 public async addNodeModulesPathToEnvIfNotPresent(): Promise<void> {
239 if (!process.env.NODE_MODULES) {
240 process.env.NODE_MODULES = await getNodeModulesGlobalPath();
241 this.nodeModulesGlobalPathAddedToEnv = true;
242 }
243 }
244
245 private async isBareWorkflowProject(): Promise<boolean> {
246 const packageJson = await this.getAppPackageInformation();
247
248 if (packageJson.dependencies && packageJson.dependencies.expokit) {
249 return false;
250 }
251 if (packageJson.devDependencies && packageJson.devDependencies.expokit) {
252 return false;
253 }
254
255 const xcodeprojFiles = globSync("ios/**/*.xcodeproj", {
256 absolute: true,
257 cwd: this.projectRootPath,
258 });
259 if (xcodeprojFiles.length) {
260 return true;
261 }
262 const gradleFiles = globSync("android/**/*.gradle", {
263 absolute: true,
264 cwd: this.projectRootPath,
265 });
266 if (gradleFiles.length) {
267 return true;
268 }
269
270 return false;
271 }
272
273 private async getArgumentsFromExpoMetroConfig(projectRoot: string): Promise<ExpMetroConfig> {
274 const config = await XDL.getMetroConfig(projectRoot);
275 return { sourceExts: config.resolver.sourceExts };
276 }
277
278 /**
279 * Path to a given file inside the .vscode directory
280 */
281 private dotvscodePath(filename: string, isAbsolute: boolean): string {
282 let paths = [".vscode", filename];
283 if (isAbsolute) {
284 paths = [this.workspaceRootPath].concat(...paths);
285 }
286 return path.join(...paths);
287 }
288
289 private async createExpoEntry(name: string): Promise<void> {
290 await this.lazilyInitialize();
291 const entryPoint = await this.detectEntry();
292 const content = this.generateFileContent(name, entryPoint);
293 return await this.fs.writeFile(this.dotvscodePath(EXPONENT_INDEX, true), content);
294 }
295
296 private async detectEntry(): Promise<string> {
297 await this.lazilyInitialize();
298 const [expo, ios] = await Promise.all([
299 this.fs.exists(this.pathToFileInWorkspace(DEFAULT_EXPONENT_INDEX)),
300 this.fs.exists(this.pathToFileInWorkspace(DEFAULT_IOS_INDEX)),
301 this.fs.exists(this.pathToFileInWorkspace(DEFAULT_ANDROID_INDEX)),
302 ]);
303 return expo
304 ? this.pathToFileInWorkspace(DEFAULT_EXPONENT_INDEX)
305 : ios
306 ? this.pathToFileInWorkspace(DEFAULT_IOS_INDEX)
307 : this.pathToFileInWorkspace(DEFAULT_ANDROID_INDEX);
308 }
309
310 private generateFileContent(name: string, entryPoint: string): string {
311 return `// This file is automatically generated by VS Code
312// Please do not modify it manually. All changes will be lost.
313var React = require('${this.pathToFileInWorkspace("/node_modules/react")}');
314var { Component } = React;
315var ReactNative = require('${this.pathToFileInWorkspace("/node_modules/react-native")}');
316var { AppRegistry } = ReactNative;
317AppRegistry.registerRunnable('main', function(appParameters) {
318 AppRegistry.runApplication('${name}', appParameters);
319});
320require('${entryPoint}');`;
321 }
322
323 private async patchAppJson(isExpo: boolean = true): Promise<void> {
324 let appJson: AppJson;
325 try {
326 appJson = await this.readAppJson();
327 } catch {
328 // If app.json doesn't exist, we will create it
329 logger.log("Cannot get existing app.json file. Create new one.");
330 appJson = <AppJson>{};
331 }
332 const packageName = await this.getPackageName();
333
334 const expoConfig = <ExpConfig>(appJson.expo || {});
335 if (!expoConfig.name || !expoConfig.slug) {
336 expoConfig.slug = expoConfig.slug || appJson.name || packageName.replace(" ", "-");
337 expoConfig.name = expoConfig.name || appJson.name || packageName;
338 appJson.expo = expoConfig;
339 }
340
341 if (!appJson.name) {
342 appJson.name = packageName;
343 }
344
345 if (!appJson.expo.sdkVersion) {
346 const sdkVersion = await this.exponentSdk(true);
347 appJson.expo.sdkVersion = sdkVersion;
348 }
349
350 if (!isExpo) {
351 // entryPoint must be relative
352 // https://docs.expo.io/versions/latest/workflow/configuration/#entrypoint
353 appJson.expo.entryPoint = this.dotvscodePath(EXPONENT_INDEX, false);
354 }
355
356 appJson = appJson ? await this.writeAppJson(appJson) : appJson;
357
358 if (!isExpo) {
359 await this.createExpoEntry(appJson.expo.name);
360 }
361 }
362
363 /**
364 * Exponent sdk version that maps to the current react-native version
365 * If react native version is not supported it returns null.
366 */
367 public async exponentSdk(showProgress: boolean = false): Promise<string> {
368 if (showProgress) {
369 this.logger.logStream("...");
370 }
371
372 const versions = await ProjectVersionHelper.getReactNativeVersions(this.projectRootPath);
373 if (showProgress) {
374 this.logger.logStream(".");
375 }
376 const sdkVersion = await this.mapFacebookReactNativeVersionToExpoVersion(
377 versions.reactNativeVersion,
378 );
379 if (!sdkVersion) {
380 const supportedVersions = await this.getFacebookReactNativeVersions();
381 throw ErrorHelper.getInternalError(
382 InternalErrorCode.RNVersionNotSupportedByExponent,
383 supportedVersions.join(", "),
384 );
385 }
386 return sdkVersion;
387 }
388
389 private async getFacebookReactNativeVersions(): Promise<string[]> {
390 const sdkVersions = await XDL.getExpoSdkVersions();
391 const facebookReactNativeVersions = new Set(
392 Object.values(sdkVersions)
393 .map(data => data.facebookReactNativeVersion)
394 .filter(version => version),
395 );
396 return Array.from(facebookReactNativeVersions);
397 }
398
399 private async mapFacebookReactNativeVersionToExpoVersion(
400 outerFacebookReactNativeVersion: string,
401 ): Promise<string | null> {
402 if (!semver.valid(outerFacebookReactNativeVersion)) {
403 throw new Error(
404 `${outerFacebookReactNativeVersion} is not a valid version. It must be in the form of x.y.z`,
405 );
406 }
407
408 const sdkVersions = await XDL.getReleasedExpoSdkVersions();
409 let currentSdkVersion: string | null = null;
410 for (const [version, { facebookReactNativeVersion }] of Object.entries(sdkVersions)) {
411 if (
412 semver.major(outerFacebookReactNativeVersion) ===
413 semver.major(facebookReactNativeVersion) &&
414 semver.minor(outerFacebookReactNativeVersion) ===
415 semver.minor(facebookReactNativeVersion) &&
416 (!currentSdkVersion || semver.gt(version, currentSdkVersion))
417 ) {
418 currentSdkVersion = version;
419 }
420 }
421 return currentSdkVersion;
422 }
423
424 /**
425 * Name specified on user's package.json
426 */
427 private getPackageName(): Promise<string> {
428 return new Package(this.projectRootPath, { fileSystem: this.fs }).name();
429 }
430
431 private async getExpConfig(): Promise<ExpConfig> {
432 try {
433 return this.readExpJson();
434 } catch (err) {
435 if ((err as NodeJS.ErrnoException).code === "ENOENT") {
436 const appJson = await this.readAppJson();
437 return appJson.expo || {};
438 }
439 throw err;
440 }
441 }
442
443 private async getFromExpConfig<T>(key: string): Promise<T> {
444 const config = await this.getExpConfig();
445 return (config as any)[key];
446 }
447
448 /**
449 * Returns the specified setting from exp.json if it exists
450 */
451 private async readExpJson(): Promise<ExpConfig> {
452 const expJsonPath = this.pathToFileInWorkspace(EXP_JSON);
453 return this.fs.readFile(expJsonPath).then(content => {
454 return stripJsonTrailingComma(content.toString());
455 });
456 }
457
458 private async readAppJson(): Promise<AppJson> {
459 const appJsonPath = this.pathToFileInWorkspace(APP_JSON);
460 logger.log(`Getting app.json path: ${appJsonPath}`);
461 return this.fs.readFile(appJsonPath).then(content => {
462 return stripJsonTrailingComma(content.toString());
463 });
464 }
465
466 private async writeAppJson(config: AppJson): Promise<AppJson> {
467 const appJsonPath = this.pathToFileInWorkspace(APP_JSON);
468 await this.fs.writeFile(appJsonPath, JSON.stringify(config, null, 2));
469 return config;
470 }
471
472 private getAppPackageInformation(): Promise<IPackageInformation> {
473 return new Package(this.projectRootPath, { fileSystem: this.fs }).parsePackageInformation();
474 }
475
476 /**
477 * Path to a given file from the workspace root
478 */
479 private pathToFileInWorkspace(filename: string): string {
480 return path.join(this.projectRootPath, filename).replace(DBL_SLASHES, "/");
481 }
482
483 /**
484 * Works as a constructor but only initializes when it's actually needed.
485 */
486 private async lazilyInitialize(): Promise<void> {
487 if (!this.hasInitialized) {
488 this.hasInitialized = true;
489 await this.preloadExponentDependency();
490 void XDL.configReactNativeVersionWarnings();
491 void XDL.attachLoggerStream(this.projectRootPath, {
492 stream: {
493 write: (chunk: any) => {
494 if (chunk.level <= 30) {
495 this.logger.logStream(chunk.msg);
496 this.logger.logStream("\n");
497 } else if (chunk.level === 40) {
498 this.logger.warning(chunk.msg);
499 } else {
500 this.logger.error(chunk.msg);
501 }
502 },
503 },
504 type: "raw",
505 });
506 }
507 }
508
509 public async getExpoEasProjectOwner(): Promise<string | null> {
510 const appJsonPath = this.pathToFileInWorkspace(APP_JSON);
511 try {
512 return JSON.parse(fs.readFileSync(appJsonPath, "utf-8")).expo.owner == undefined
513 ? null
514 : JSON.parse(fs.readFileSync(appJsonPath, "utf-8")).expo.owner;
515 } catch {
516 return null;
517 }
518 }
519
520 public async getExpoEasProjectId(): Promise<string | null> {
521 const appJsonPath = this.pathToFileInWorkspace(APP_JSON);
522 try {
523 return JSON.parse(fs.readFileSync(appJsonPath, "utf-8")).expo.extra.eas.projectId ==
524 undefined
525 ? null
526 : JSON.parse(fs.readFileSync(appJsonPath, "utf-8")).expo.extra.eas.projectId;
527 } catch {
528 return null;
529 }
530 }
531
532 public async getExpoEasProjectName(): Promise<string | null> {
533 const appJsonPath = this.pathToFileInWorkspace(APP_JSON);
534 try {
535 return JSON.parse(fs.readFileSync(appJsonPath, "utf-8")).expo.name == undefined
536 ? null
537 : JSON.parse(fs.readFileSync(appJsonPath, "utf-8")).expo.name;
538 } catch {
539 return null;
540 }
541 }
542}
543