microsoft/vscode-react-native
Publicmirrored from https://github.com/microsoft/vscode-react-nativeAvailable
src/extension/exponent/exponentHelper.ts
376lines · modeblame
1c32fe84Patricio Beltran9 years ago | 1 | // Copyright (c) Microsoft Corporation. All rights reserved. |
| 2 | // Licensed under the MIT license. See LICENSE file in the project root for details. | |
| 3 | | |
94cd5149Artem Egorov8 years ago | 4 | /// <reference path="exponentHelper.d.ts" /> |
| 5 | | |
1c32fe84Patricio Beltran9 years ago | 6 | import * as path from "path"; |
| 7 | import * as Q from "q"; | |
7059d307Patricio Beltran9 years ago | 8 | import * as XDL from "./xdlInterface"; |
66412fdfRuslan Bikkinin7 years ago | 9 | import { Package, IPackageInformation } from "../../common/node/package"; |
0a68f8dbArtem Egorov8 years ago | 10 | import { ReactNativeProjectHelper } from "../../common/reactNativeProjectHelper"; |
| 11 | import { FileSystem } from "../../common/node/fileSystem"; | |
| 12 | import {OutputChannelLogger} from "../log/OutputChannelLogger"; | |
94cd5149Artem Egorov8 years ago | 13 | import stripJSONComments = require("strip-json-comments"); |
d7d405aeYuri Skorokhodov7 years ago | 14 | import * as nls from "vscode-nls"; |
| 15 | import { ErrorHelper } from "../../common/error/errorHelper"; | |
| 16 | import { InternalErrorCode } from "../../common/error/internalErrorCode"; | |
| 17 | const localize = nls.loadMessageBundle(); | |
1c32fe84Patricio Beltran9 years ago | 18 | |
94cd5149Artem Egorov8 years ago | 19 | const APP_JSON = "app.json"; |
| 20 | const EXP_JSON = "exp.json"; | |
1c32fe84Patricio Beltran9 years ago | 21 | |
| 22 | const EXPONENT_INDEX = "exponentIndex.js"; | |
94cd5149Artem Egorov8 years ago | 23 | const DEFAULT_EXPONENT_INDEX = "index.js"; |
1c32fe84Patricio Beltran9 years ago | 24 | const DEFAULT_IOS_INDEX = "index.ios.js"; |
| 25 | const DEFAULT_ANDROID_INDEX = "index.android.js"; | |
| 26 | | |
94cd5149Artem Egorov8 years ago | 27 | const DBL_SLASHES = /\\/g; |
1c32fe84Patricio Beltran9 years ago | 28 | |
| 29 | export class ExponentHelper { | |
38edb09eAlexander Sorokin9 years ago | 30 | private workspaceRootPath: string; |
94cd5149Artem Egorov8 years ago | 31 | private projectRootPath: string; |
2956dba4Yuri Skorokhodov7 years ago | 32 | private fs: FileSystem; |
a57e740bPatricio Beltran9 years ago | 33 | private hasInitialized: boolean; |
0a68f8dbArtem Egorov8 years ago | 34 | private logger: OutputChannelLogger = OutputChannelLogger.getMainChannel(); |
1c32fe84Patricio Beltran9 years ago | 35 | |
2956dba4Yuri Skorokhodov7 years ago | 36 | public constructor(workspaceRootPath: string, projectRootPath: string, fs: FileSystem = new FileSystem()) { |
38edb09eAlexander Sorokin9 years ago | 37 | this.workspaceRootPath = workspaceRootPath; |
| 38 | this.projectRootPath = projectRootPath; | |
2956dba4Yuri Skorokhodov7 years ago | 39 | this.fs = fs; |
a57e740bPatricio Beltran9 years ago | 40 | this.hasInitialized = false; |
| 41 | // Constructor is slim by design. This is to add as less computation as possible | |
| 42 | // to the initialization of the extension. If a public method is added, make sure | |
b0af599cJimmy Thomson9 years ago | 43 | // to call this.lazilyInitialize() at the begining of the code to be sure all variables |
a57e740bPatricio Beltran9 years ago | 44 | // are correctly initialized. |
1c32fe84Patricio Beltran9 years ago | 45 | } |
| 46 | | |
d1d77244Jimmy Thomson9 years ago | 47 | public configureExponentEnvironment(): Q.Promise<void> { |
| 48 | this.lazilyInitialize(); | |
d7d405aeYuri Skorokhodov7 years ago | 49 | this.logger.info(localize("MakingSureYourProjectUsesCorrectExponentDependencies", "Making sure your project uses the correct dependencies for Expo. This may take a while...")); |
| 50 | this.logger.logStream(localize("CheckingIfThisIsExpoApp", "Checking if this is Expo app.")); | |
66412fdfRuslan Bikkinin7 years ago | 51 | let isExpo: boolean; |
| 52 | return this.isExpoApp(true) | |
| 53 | .then(result => { | |
| 54 | isExpo = result; | |
| 55 | if (!isExpo) { | |
| 56 | return this.appHasExpoInstalled().then((expoInstalled) => { | |
| 57 | if (!expoInstalled) { | |
| 58 | // Expo requires expo package to be installed inside RN application in order to be able to run it | |
| 59 | // https://github.com/expo/expo-cli/issues/255#issuecomment-453214632 | |
| 60 | this.logger.logStream("\n"); | |
| 61 | this.logger.logStream(localize("ExpoPackageIsNotInstalled", "[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.")); | |
| 62 | this.logger.logStream("\n"); | |
| 63 | } | |
| 64 | }); | |
| 65 | } | |
| 66 | return; | |
| 67 | }).then(() => { | |
0a68f8dbArtem Egorov8 years ago | 68 | this.logger.logStream(".\n"); |
94cd5149Artem Egorov8 years ago | 69 | return this.patchAppJson(isExpo); |
| 70 | }); | |
d1d77244Jimmy Thomson9 years ago | 71 | } |
| 72 | | |
| 73 | /** | |
| 74 | * Returns the current user. If there is none, asks user for username and password and logins to exponent servers. | |
| 75 | */ | |
5c8365a6Artem Egorov8 years ago | 76 | public loginToExponent( |
| 77 | promptForInformation: (message: string, password: boolean) => Q.Promise<string>, | |
| 78 | showMessage: (message: string) => Q.Promise<string> | |
| 79 | ): Q.Promise<XDL.IUser> { | |
d1d77244Jimmy Thomson9 years ago | 80 | this.lazilyInitialize(); |
| 81 | return XDL.currentUser() | |
| 82 | .then((user) => { | |
| 83 | if (!user) { | |
| 84 | let username = ""; | |
d7d405aeYuri Skorokhodov7 years ago | 85 | return showMessage(localize("YouNeedToLoginToExpo", "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.")) |
d1d77244Jimmy Thomson9 years ago | 86 | .then(() => |
d7d405aeYuri Skorokhodov7 years ago | 87 | promptForInformation(localize("ExpoUsername", "Expo username"), false) |
5c8365a6Artem Egorov8 years ago | 88 | ).then((name: string) => { |
d1d77244Jimmy Thomson9 years ago | 89 | username = name; |
d7d405aeYuri Skorokhodov7 years ago | 90 | return promptForInformation(localize("ExpoPassword", "Expo password"), true); |
d1d77244Jimmy Thomson9 years ago | 91 | }) |
5c8365a6Artem Egorov8 years ago | 92 | .then((password: string) => |
d1d77244Jimmy Thomson9 years ago | 93 | XDL.login(username, password)); |
| 94 | } | |
| 95 | return user; | |
| 96 | }) | |
| 97 | .catch(error => { | |
| 98 | return Q.reject<XDL.IUser>(error); | |
| 99 | }); | |
| 100 | } | |
| 101 | | |
94cd5149Artem Egorov8 years ago | 102 | public getExpPackagerOptions(): Q.Promise<ExpConfigPackager> { |
6458f408Nikita Matrosov9 years ago | 103 | this.lazilyInitialize(); |
94cd5149Artem Egorov8 years ago | 104 | return this.getFromExpConfig("packagerOpts") |
| 105 | .then(opts => opts || {}); | |
6458f408Nikita Matrosov9 years ago | 106 | } |
| 107 | | |
66412fdfRuslan Bikkinin7 years ago | 108 | public appHasExpoInstalled(): Q.Promise<boolean> { |
| 109 | return this.getAppPackageInformation() | |
| 110 | .then((packageJson: IPackageInformation) => { | |
| 111 | if (packageJson.dependencies && packageJson.dependencies.expo) { | |
| 112 | this.logger.debug("'expo' package is found in 'dependencies' section of package.json"); | |
| 113 | return true; | |
| 114 | } else if (packageJson.devDependencies && packageJson.devDependencies.expo) { | |
| 115 | this.logger.debug("'expo' package is found in 'devDependencies' section of package.json"); | |
| 116 | return true; | |
| 117 | } | |
| 118 | return false; | |
| 119 | }); | |
| 120 | } | |
| 121 | | |
| 122 | public appHasExpoRNSDKInstalled(): Q.Promise<boolean> { | |
| 123 | return this.getAppPackageInformation() | |
| 124 | .then((packageJson: IPackageInformation) => { | |
| 125 | const reactNativeValue: string | undefined = packageJson.dependencies && packageJson.dependencies["react-native"]; | |
| 126 | if (reactNativeValue) { | |
| 127 | this.logger.debug(`'react-native' package with value '${reactNativeValue}' is found in 'dependencies' section of package.json`); | |
| 128 | if (reactNativeValue.startsWith("https://github.com/expo/react-native/archive/sdk")) { | |
| 129 | return true; | |
| 130 | } | |
| 131 | } | |
| 132 | return false; | |
| 133 | }); | |
| 134 | } | |
| 135 | | |
| 136 | public isExpoApp(showProgress: boolean = false): Q.Promise<boolean> { | |
db6fd42aRuslan Bikkinin7 years ago | 137 | if (showProgress) { |
| 138 | this.logger.logStream("..."); | |
| 139 | } | |
| 140 | | |
66412fdfRuslan Bikkinin7 years ago | 141 | return Q.all([ |
| 142 | this.appHasExpoInstalled(), | |
| 143 | this.appHasExpoRNSDKInstalled(), | |
| 144 | ]).spread((expoInstalled, expoRNSDKInstalled) => { | |
| 145 | if (showProgress) this.logger.logStream("."); | |
| 146 | return expoInstalled && expoRNSDKInstalled; | |
| 147 | }).catch((e) => { | |
| 148 | this.logger.error(e.message, e, e.stack); | |
db6fd42aRuslan Bikkinin7 years ago | 149 | if (showProgress) { |
| 150 | this.logger.logStream("."); | |
| 151 | } | |
| 152 | // Not in a react-native project | |
| 153 | return false; | |
| 154 | }); | |
| 155 | } | |
| 156 | | |
1c32fe84Patricio Beltran9 years ago | 157 | /** |
94cd5149Artem Egorov8 years ago | 158 | * Path to a given file inside the .vscode directory |
1c32fe84Patricio Beltran9 years ago | 159 | */ |
66412fdfRuslan Bikkinin7 years ago | 160 | private dotvscodePath(filename: string, isAbsolute: boolean): string { |
| 161 | let paths = [".vscode", filename]; | |
| 162 | if (isAbsolute) { | |
| 163 | paths = [this.workspaceRootPath].concat(...paths); | |
| 164 | } | |
| 165 | return path.join(...paths); | |
94cd5149Artem Egorov8 years ago | 166 | } |
| 167 | | |
| 168 | private createExpoEntry(name: string): Q.Promise<void> { | |
b0af599cJimmy Thomson9 years ago | 169 | this.lazilyInitialize(); |
94cd5149Artem Egorov8 years ago | 170 | return this.detectEntry() |
| 171 | .then((entryPoint: string) => { | |
| 172 | const content = this.generateFileContent(name, entryPoint); | |
66412fdfRuslan Bikkinin7 years ago | 173 | return this.fs.writeFile(this.dotvscodePath(EXPONENT_INDEX, true), content); |
94cd5149Artem Egorov8 years ago | 174 | }); |
1c32fe84Patricio Beltran9 years ago | 175 | |
94cd5149Artem Egorov8 years ago | 176 | } |
1c32fe84Patricio Beltran9 years ago | 177 | |
94cd5149Artem Egorov8 years ago | 178 | private detectEntry(): Q.Promise<string> { |
| 179 | this.lazilyInitialize(); | |
| 180 | return Q.all([ | |
| 181 | this.fs.exists(this.pathToFileInWorkspace(DEFAULT_EXPONENT_INDEX)), | |
| 182 | this.fs.exists(this.pathToFileInWorkspace(DEFAULT_IOS_INDEX)), | |
| 183 | this.fs.exists(this.pathToFileInWorkspace(DEFAULT_ANDROID_INDEX)), | |
| 184 | ]) | |
2956dba4Yuri Skorokhodov7 years ago | 185 | .spread((expo: boolean, ios: boolean): string => { |
| 186 | return expo ? this.pathToFileInWorkspace(DEFAULT_EXPONENT_INDEX) : | |
| 187 | ios ? this.pathToFileInWorkspace(DEFAULT_IOS_INDEX) : | |
| 188 | this.pathToFileInWorkspace(DEFAULT_ANDROID_INDEX); | |
| 189 | }); | |
94cd5149Artem Egorov8 years ago | 190 | } |
1c32fe84Patricio Beltran9 years ago | 191 | |
94cd5149Artem Egorov8 years ago | 192 | private generateFileContent(name: string, entryPoint: string): string { |
| 193 | return `// This file is automatically generated by VS Code | |
| 194 | // Please do not modify it manually. All changes will be lost. | |
| 195 | var React = require('${this.pathToFileInWorkspace("/node_modules/react")}'); | |
| 196 | var { Component } = React; | |
| 197 | var ReactNative = require('${this.pathToFileInWorkspace("/node_modules/react-native")}'); | |
| 198 | var { AppRegistry } = ReactNative; | |
| 199 | var entryPoint = require('${entryPoint}'); | |
f6b41bbfAlexander Sorokin9 years ago | 200 | AppRegistry.registerRunnable('main', function(appParameters) { |
94cd5149Artem Egorov8 years ago | 201 | AppRegistry.runApplication('${name}', appParameters); |
1ae29ddcJimmy Thomson9 years ago | 202 | });`; |
1c32fe84Patricio Beltran9 years ago | 203 | } |
| 204 | | |
94cd5149Artem Egorov8 years ago | 205 | private patchAppJson(isExpo: boolean = true): Q.Promise<void> { |
| 206 | return this.readAppJson() | |
| 207 | .catch(() => { | |
| 208 | // if app.json doesn't exist but it's ok, we will create it | |
| 209 | return {}; | |
| 210 | }) | |
| 211 | .then((config: AppJson) => { | |
| 212 | let expoConfig = <ExpConfig>(config.expo || {}); | |
| 213 | if (!expoConfig.name || !expoConfig.slug) { | |
b0af599cJimmy Thomson9 years ago | 214 | return this.getPackageName() |
94cd5149Artem Egorov8 years ago | 215 | .then((name: string) => { |
| 216 | expoConfig.slug = expoConfig.slug || config.name || name.replace(" ", "-"); | |
| 217 | expoConfig.name = expoConfig.name || config.name || name; | |
| 218 | config.expo = expoConfig; | |
| 219 | return config; | |
b0af599cJimmy Thomson9 years ago | 220 | }); |
| 221 | } | |
| 222 | | |
94cd5149Artem Egorov8 years ago | 223 | return config; |
| 224 | }) | |
66412fdfRuslan Bikkinin7 years ago | 225 | .then((config: AppJson) => { |
| 226 | if (!config.name) { | |
| 227 | return this.getPackageName() | |
| 228 | .then((name: string) => { | |
| 229 | config.name = name; | |
| 230 | return config; | |
| 231 | }); | |
| 232 | } | |
| 233 | | |
| 234 | return config; | |
| 235 | }) | |
94cd5149Artem Egorov8 years ago | 236 | .then((config: AppJson) => { |
| 237 | if (!config.expo.sdkVersion) { | |
| 238 | return this.exponentSdk(true) | |
| 239 | .then(sdkVersion => { | |
| 240 | config.expo.sdkVersion = sdkVersion; | |
| 241 | return config; | |
| 242 | }); | |
1c32fe84Patricio Beltran9 years ago | 243 | } |
66412fdfRuslan Bikkinin7 years ago | 244 | |
94cd5149Artem Egorov8 years ago | 245 | return config; |
1c32fe84Patricio Beltran9 years ago | 246 | }) |
94cd5149Artem Egorov8 years ago | 247 | .then((config: AppJson) => { |
| 248 | if (!isExpo) { | |
66412fdfRuslan Bikkinin7 years ago | 249 | // entryPoint must be relative |
| 250 | // https://docs.expo.io/versions/latest/workflow/configuration/#entrypoint | |
| 251 | config.expo.entryPoint = this.dotvscodePath(EXPONENT_INDEX, false); | |
94cd5149Artem Egorov8 years ago | 252 | } |
1c32fe84Patricio Beltran9 years ago | 253 | |
94cd5149Artem Egorov8 years ago | 254 | return config; |
| 255 | }) | |
| 256 | .then((config: AppJson) => { | |
5c8365a6Artem Egorov8 years ago | 257 | return config ? this.writeAppJson(config) : config; |
1c32fe84Patricio Beltran9 years ago | 258 | }) |
94cd5149Artem Egorov8 years ago | 259 | .then((config: AppJson) => { |
5c8365a6Artem Egorov8 years ago | 260 | return isExpo ? Q.resolve(void 0) : this.createExpoEntry(config.expo.name); |
1c32fe84Patricio Beltran9 years ago | 261 | }); |
27710197Vladimir Kotikov8 years ago | 262 | } |
1c32fe84Patricio Beltran9 years ago | 263 | |
| 264 | /** | |
| 265 | * Exponent sdk version that maps to the current react-native version | |
| 266 | * If react native version is not supported it returns null. | |
| 267 | */ | |
831f4a85Patricio Beltran9 years ago | 268 | private exponentSdk(showProgress: boolean = false): Q.Promise<string> { |
94cd5149Artem Egorov8 years ago | 269 | if (showProgress) { |
0a68f8dbArtem Egorov8 years ago | 270 | this.logger.logStream("..."); |
1c32fe84Patricio Beltran9 years ago | 271 | } |
94cd5149Artem Egorov8 years ago | 272 | |
2e432a9eArtem Egorov8 years ago | 273 | return ReactNativeProjectHelper.getReactNativeVersion(this.projectRootPath) |
94cd5149Artem Egorov8 years ago | 274 | .then(version => { |
0a68f8dbArtem Egorov8 years ago | 275 | if (showProgress) this.logger.logStream("."); |
94cd5149Artem Egorov8 years ago | 276 | return XDL.mapVersion(version) |
| 277 | .then(sdkVersion => { | |
| 278 | if (!sdkVersion) { | |
| 279 | return XDL.supportedVersions() | |
| 280 | .then((versions) => { | |
d7d405aeYuri Skorokhodov7 years ago | 281 | return Q.reject<string>(ErrorHelper.getInternalError(InternalErrorCode.RNVersionNotSupportedByExponent, versions.join(", "))); |
94cd5149Artem Egorov8 years ago | 282 | }); |
| 283 | } | |
| 284 | return sdkVersion; | |
1c32fe84Patricio Beltran9 years ago | 285 | }); |
| 286 | }); | |
| 287 | } | |
| 288 | | |
94cd5149Artem Egorov8 years ago | 289 | |
1c32fe84Patricio Beltran9 years ago | 290 | /** |
94cd5149Artem Egorov8 years ago | 291 | * Name specified on user's package.json |
1c32fe84Patricio Beltran9 years ago | 292 | */ |
94cd5149Artem Egorov8 years ago | 293 | private getPackageName(): Q.Promise<string> { |
| 294 | return new Package(this.projectRootPath, { fileSystem: this.fs }).name(); | |
| 295 | } | |
| 296 | | |
| 297 | private getExpConfig(): Q.Promise<ExpConfig> { | |
| 298 | return this.readExpJson() | |
| 299 | .catch(err => { | |
| 300 | if (err.code === "ENOENT") { | |
| 301 | return this.readAppJson() | |
| 302 | .then((config: AppJson) => { | |
| 303 | return config.expo || {}; | |
| 304 | }); | |
1c32fe84Patricio Beltran9 years ago | 305 | } |
94cd5149Artem Egorov8 years ago | 306 | |
| 307 | return err; | |
1c32fe84Patricio Beltran9 years ago | 308 | }); |
| 309 | } | |
| 310 | | |
94cd5149Artem Egorov8 years ago | 311 | private getFromExpConfig(key: string): Q.Promise<any> { |
| 312 | return this.getExpConfig() | |
| 313 | .then((config: ExpConfig) => config[key]); | |
1c32fe84Patricio Beltran9 years ago | 314 | } |
| 315 | | |
| 316 | /** | |
94cd5149Artem Egorov8 years ago | 317 | * Returns the specified setting from exp.json if it exists |
1c32fe84Patricio Beltran9 years ago | 318 | */ |
94cd5149Artem Egorov8 years ago | 319 | private readExpJson(): Q.Promise<ExpConfig> { |
| 320 | const expJsonPath = this.pathToFileInWorkspace(EXP_JSON); | |
| 321 | return this.fs.readFile(expJsonPath) | |
| 322 | .then(content => { | |
| 323 | return JSON.parse(stripJSONComments(content)); | |
1c32fe84Patricio Beltran9 years ago | 324 | }); |
| 325 | } | |
| 326 | | |
94cd5149Artem Egorov8 years ago | 327 | private readAppJson(): Q.Promise<AppJson> { |
| 328 | const appJsonPath = this.pathToFileInWorkspace(APP_JSON); | |
| 329 | return this.fs.readFile(appJsonPath) | |
| 330 | .then(content => { | |
| 331 | return JSON.parse(stripJSONComments(content)); | |
1c32fe84Patricio Beltran9 years ago | 332 | }); |
| 333 | } | |
| 334 | | |
94cd5149Artem Egorov8 years ago | 335 | private writeAppJson(config: AppJson): Q.Promise<AppJson> { |
| 336 | const appJsonPath = this.pathToFileInWorkspace(APP_JSON); | |
| 337 | return this.fs.writeFile(appJsonPath, JSON.stringify(config, null, 2)) | |
| 338 | .then(() => config); | |
1c32fe84Patricio Beltran9 years ago | 339 | } |
| 340 | | |
66412fdfRuslan Bikkinin7 years ago | 341 | private getAppPackageInformation(): Q.Promise<IPackageInformation> { |
| 342 | return new Package(this.projectRootPath, { fileSystem: this.fs }).parsePackageInformation(); | |
| 343 | } | |
| 344 | | |
1c32fe84Patricio Beltran9 years ago | 345 | /** |
38edb09eAlexander Sorokin9 years ago | 346 | * Path to a given file from the workspace root |
1c32fe84Patricio Beltran9 years ago | 347 | */ |
| 348 | private pathToFileInWorkspace(filename: string): string { | |
94cd5149Artem Egorov8 years ago | 349 | return path.join(this.projectRootPath, filename).replace(DBL_SLASHES, "/"); |
1c32fe84Patricio Beltran9 years ago | 350 | } |
| 351 | | |
a57e740bPatricio Beltran9 years ago | 352 | /** |
| 353 | * Works as a constructor but only initiliazes when it's actually needed. | |
| 354 | */ | |
b0af599cJimmy Thomson9 years ago | 355 | private lazilyInitialize(): void { |
a57e740bPatricio Beltran9 years ago | 356 | if (!this.hasInitialized) { |
| 357 | this.hasInitialized = true; | |
| 358 | | |
| 359 | XDL.configReactNativeVersionWargnings(); | |
38edb09eAlexander Sorokin9 years ago | 360 | XDL.attachLoggerStream(this.projectRootPath, { |
a57e740bPatricio Beltran9 years ago | 361 | stream: { |
| 362 | write: (chunk: any) => { | |
| 363 | if (chunk.level <= 30) { | |
0a68f8dbArtem Egorov8 years ago | 364 | this.logger.logStream(chunk.msg); |
a57e740bPatricio Beltran9 years ago | 365 | } else if (chunk.level === 40) { |
0a68f8dbArtem Egorov8 years ago | 366 | this.logger.warning(chunk.msg); |
a57e740bPatricio Beltran9 years ago | 367 | } else { |
0a68f8dbArtem Egorov8 years ago | 368 | this.logger.error(chunk.msg); |
a57e740bPatricio Beltran9 years ago | 369 | } |
| 370 | }, | |
| 371 | }, | |
| 372 | type: "raw", | |
| 373 | }); | |
| 374 | } | |
| 375 | } | |
5c8365a6Artem Egorov8 years ago | 376 | } |