microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1.7.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/extension/exponent/exponentHelper.ts

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