microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1.4.2

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/extension/exponent/exponentHelper.ts

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