microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1.5.1

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/extension/exponent/exponentHelper.ts

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