microsoft/vscode-react-native

Public

mirrored from https://github.com/microsoft/vscode-react-nativeAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
892f0aa6d2f90a4b52e551b6b4c8270ed6b96e83

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/extension/exponent/exponentHelper.ts

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