microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1.5.2

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/extension/exponent/exponentHelper.ts

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