microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1.10.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/extension/exponent/exponentHelper.ts

491lines · 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// eslint-disable-next-line @typescript-eslint/triple-slash-reference
5/// <reference path="exponentHelper.d.ts" />
6
7import * as path from "path";
8import * as semver from "semver";
9import * as vscode from "vscode";
10import { sync as globSync } from "glob";
11import * as nls from "vscode-nls";
12import { stripJsonTrailingComma, getNodeModulesGlobalPath } from "../../common/utils";
13import { Package, IPackageInformation } from "../../common/node/package";
14import { ProjectVersionHelper } from "../../common/projectVersionHelper";
15import { OutputChannelLogger } from "../log/OutputChannelLogger";
16import { ErrorHelper } from "../../common/error/errorHelper";
17import { PackageLoader, PackageConfig } from "../../common/packageLoader";
18import { InternalErrorCode } from "../../common/error/internalErrorCode";
19import { FileSystem } from "../../common/node/fileSystem";
20import { SettingsHelper } from "../settingsHelper";
21import * as XDL from "./xdlInterface";
22
23nls.config({
24 messageFormat: nls.MessageFormat.bundle,
25 bundleFormat: nls.BundleFormat.standalone,
26})();
27const localize = nls.loadMessageBundle();
28
29const APP_JSON = "app.json";
30const EXP_JSON = "exp.json";
31
32const EXPONENT_INDEX = "exponentIndex.js";
33const DEFAULT_EXPONENT_INDEX = "index.js";
34const DEFAULT_IOS_INDEX = "index.ios.js";
35const DEFAULT_ANDROID_INDEX = "index.android.js";
36
37const DBL_SLASHES = /\\/g;
38
39const NGROK_PACKAGE = "@expo/ngrok";
40
41export class ExponentHelper {
42 private workspaceRootPath: string;
43 private projectRootPath: string;
44 private fs: FileSystem;
45 private hasInitialized: boolean;
46 private nodeModulesGlobalPathAddedToEnv: boolean;
47 private logger: OutputChannelLogger = OutputChannelLogger.getMainChannel();
48
49 public constructor(
50 workspaceRootPath: string,
51 projectRootPath: string,
52 fs: FileSystem = new FileSystem(),
53 ) {
54 this.workspaceRootPath = workspaceRootPath;
55 this.projectRootPath = projectRootPath;
56 this.fs = fs;
57 this.hasInitialized = false;
58 // Constructor is slim by design. This is to add as less computation as possible
59 // to the initialization of the extension. If a public method is added, make sure
60 // to call this.lazilyInitialize() at the beginning of the code to be sure all variables
61 // are correctly initialized.
62 this.nodeModulesGlobalPathAddedToEnv = false;
63 }
64
65 public async preloadExponentDependency(): Promise<[typeof xdl, typeof metroConfig]> {
66 this.logger.info(
67 localize(
68 "MakingSureYourProjectUsesCorrectExponentDependencies",
69 "Making sure your project uses the correct dependencies for Expo. This may take a while...",
70 ),
71 );
72 return Promise.all([XDL.getXDLPackage(), XDL.getMetroConfigPackage()]);
73 }
74
75 public async configureExponentEnvironment(): Promise<void> {
76 await this.lazilyInitialize();
77 this.logger.logStream(
78 localize("CheckingIfThisIsExpoApp", "Checking if this is an Expo app."),
79 );
80 const 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});
306require('${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 return this.fs.readFile(expJsonPath).then(content => {
439 return stripJsonTrailingComma(content.toString());
440 });
441 }
442
443 private async readAppJson(): Promise<AppJson> {
444 const appJsonPath = this.pathToFileInWorkspace(APP_JSON);
445 return this.fs.readFile(appJsonPath).then(content => {
446 return stripJsonTrailingComma(content.toString());
447 });
448 }
449
450 private async writeAppJson(config: AppJson): Promise<AppJson> {
451 const appJsonPath = this.pathToFileInWorkspace(APP_JSON);
452 await this.fs.writeFile(appJsonPath, JSON.stringify(config, null, 2));
453 return config;
454 }
455
456 private getAppPackageInformation(): Promise<IPackageInformation> {
457 return new Package(this.projectRootPath, { fileSystem: this.fs }).parsePackageInformation();
458 }
459
460 /**
461 * Path to a given file from the workspace root
462 */
463 private pathToFileInWorkspace(filename: string): string {
464 return path.join(this.projectRootPath, filename).replace(DBL_SLASHES, "/");
465 }
466
467 /**
468 * Works as a constructor but only initializes when it's actually needed.
469 */
470 private async lazilyInitialize(): Promise<void> {
471 if (!this.hasInitialized) {
472 this.hasInitialized = true;
473 await this.preloadExponentDependency();
474 void XDL.configReactNativeVersionWarnings();
475 void XDL.attachLoggerStream(this.projectRootPath, {
476 stream: {
477 write: (chunk: any) => {
478 if (chunk.level <= 30) {
479 this.logger.logStream(chunk.msg);
480 } else if (chunk.level === 40) {
481 this.logger.warning(chunk.msg);
482 } else {
483 this.logger.error(chunk.msg);
484 }
485 },
486 },
487 type: "raw",
488 });
489 }
490 }
491}
492