microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1.8.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/extension/exponent/exponentHelper.ts

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