microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1.9.2

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/extension/exponent/exponentHelper.ts

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