diff --git a/CHANGELOG.md b/CHANGELOG.md index 59a079ef84..291586b657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ NativeScript CLI Changelog ================ +6.0.3 (2019, August 05) +== +* [Fixed #4914](https://github.com/NativeScript/nativescript-cli/issues/4914): livesync not working with command tns test android +* [Fixed #4746](https://github.com/NativeScript/nativescript-cli/issues/4746): Unable to work with `karma-webpack@4.0.2` on test command +* [Fixed #4586](https://github.com/NativeScript/nativescript-cli/issues/4586): publish ios fails because of hsa2 + 6.0.2 (2019, July 22) == * [Fixed #4885](https://github.com/NativeScript/nativescript-cli/issues/4885): `migrate` and `update` commands are failing where everything is up-to-date diff --git a/config/test-dependencies.json b/config/test-dependencies.json index 879c04a950..a1557d3bfe 100644 --- a/config/test-dependencies.json +++ b/config/test-dependencies.json @@ -7,7 +7,8 @@ }, { "name": "karma-webpack", - "excludedPeerDependencies": ["webpack"] + "excludedPeerDependencies": ["webpack"], + "version": "3.0.5" }, { "name": "mocha", diff --git a/docs/man_pages/project/creation/create.md b/docs/man_pages/project/creation/create.md index 1f920a84c7..1391f6a90e 100644 --- a/docs/man_pages/project/creation/create.md +++ b/docs/man_pages/project/creation/create.md @@ -43,16 +43,16 @@ Below you can see a list of the recommended NativeScript starting templates and Template | Command ---------|---------- `JavaScript - Hello World`, `--js`, `--javascript` | tns create --template tns-template-hello-world -`JavaScript - SideDrawer` | tns create tns-template-drawer-navigation -`JavaScript - Tabs` | tns create tns-template-tab-navigation -`TypeScript - Hello World`, `--ts`, `--tsc`, `--typescript` | tns create tns-template-hello-world-ts -`TypeScript - SideDrawer` | tns create tns-template-drawer-navigation-ts -`TypeScript - Tabs` | tns create tns-template-tab-navigation-ts -`Angular - Hello World`, `--ng`, `--angular` | tns create tns-template-hello-world-ng -`Angular - SideDrawer` | tns create tns-template-drawer-navigation-ng -`Angular - Tabs` | tns create tns-template-tab-navigation-ng -`Vue.js - Blank`, `--vue`, `--vuejs` | tns create tns-template-blank-vue -`Vue.js - SideDrawer`, | tns create tns-template-drawer-navigation-vue +`JavaScript - SideDrawer` | tns create --template tns-template-drawer-navigation +`JavaScript - Tabs` | tns create --template tns-template-tab-navigation +`TypeScript - Hello World`, `--ts`, `--tsc`, `--typescript` | tns create --template tns-template-hello-world-ts +`TypeScript - SideDrawer` | tns create --template tns-template-drawer-navigation-ts +`TypeScript - Tabs` | tns create --template tns-template-tab-navigation-ts +`Angular - Hello World`, `--ng`, `--angular` | tns create --template tns-template-hello-world-ng +`Angular - SideDrawer` | tns create --template tns-template-drawer-navigation-ng +`Angular - Tabs` | tns create --template tns-template-tab-navigation-ng +`Vue.js - Blank`, `--vue`, `--vuejs` | tns create --template tns-template-blank-vue +`Vue.js - SideDrawer`, | tns create --template tns-template-drawer-navigation-vue ### Related Commands diff --git a/docs/man_pages/publishing/apple-login.md b/docs/man_pages/publishing/apple-login.md new file mode 100644 index 0000000000..4e14cb6046 --- /dev/null +++ b/docs/man_pages/publishing/apple-login.md @@ -0,0 +1,35 @@ +<% if (isJekyll) { %>--- +title: tns apple-login +position: 5 +---<% } %> + +# tns apple-login + +### Description + +Uses the provided Apple credentials to obtain Apple session which can be used when publishing to Apple AppStore. + +### Commands + +Usage | Synopsis +---|--- +General | `$ tns apple-login [] []` + +### Arguments + +* `` and `` are your credentials for logging into iTunes Connect. + +<% if(isHtml) { %>s + +### Related Commands + +Command | Description +----------|---------- +[appstore](appstore.html) | Lists applications registered in iTunes Connect. +[appstore upload](appstore-upload.html) | Uploads project to iTunes Connect. +[build](../project/testing/build.html) | Builds the project for the selected target platform and produces an application package that you can manually deploy on device or in the native emulator. +[build ios](../project/testing/build-ios.html) | Builds the project for iOS and produces an APP or IPA that you can manually deploy in the iOS Simulator or on device, respectively. +[deploy](../project/testing/deploy.html) | Builds and deploys the project to a connected physical or virtual device. +[run](../project/testing/run.html) | Runs your project on a connected device or in the native emulator for the selected platform. +[run ios](../project/testing/run-ios.html) | Runs your project on a connected iOS device or in the iOS Simulator, if configured. +<% } %> \ No newline at end of file diff --git a/docs/man_pages/publishing/appstore-upload.md b/docs/man_pages/publishing/appstore-upload.md index 60dbe1f214..67df8607ee 100644 --- a/docs/man_pages/publishing/appstore-upload.md +++ b/docs/man_pages/publishing/appstore-upload.md @@ -8,6 +8,7 @@ position: 1 ### Description Uploads project to iTunes Connect. The command either issues a production build and uploads it to iTunes Connect, or uses an already built package to upload. +The user will be prompted interactively for verification code when two-factor authentication enabled account is used. As on non-interactive console (CI), you will not be prompt for verification code. In this case, you need to generate a login session for your apple's account in advance using `tns apple-login` command. The generated value must be provided via the `--appleSessionBase64` option and is only valid for up to a month. Meaning you'll need to create a new session every month. <% if(isConsole && (isLinux || isWindows)) { %>WARNING: You can run this command only on macOS systems. To view the complete help for this command, run `$ tns help appstore upload`<% } %> <% if((isConsole && isMacOS) || isHtml) { %> @@ -22,6 +23,8 @@ Upload package | `$ tns appstore upload [ []] --ipa WARNING: You can run this command only on macOS systems. To view the complete help for this command, run `$ tns help publish ios`<% } %> @@ -24,7 +25,9 @@ Upload package | `$ tns publish ios [ []] --ipa ` and `` are your credentials for logging into iTunes Connect. diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 00cbc57b25..704c759f47 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -100,6 +100,7 @@ $injector.requireCommand("dev-generate-help", "./commands/generate-help"); $injector.requireCommand("appstore|*list", "./commands/appstore-list"); $injector.requireCommand("appstore|upload", "./commands/appstore-upload"); $injector.requireCommand("publish|ios", "./commands/appstore-upload"); +$injector.requireCommand("apple-login", "./commands/apple-login"); $injector.require("itmsTransporterService", "./services/itmstransporter-service"); $injector.requireCommand("setup|*", "./commands/setup"); diff --git a/lib/commands/apple-login.ts b/lib/commands/apple-login.ts new file mode 100644 index 0000000000..be820e487f --- /dev/null +++ b/lib/commands/apple-login.ts @@ -0,0 +1,34 @@ +import { StringCommandParameter } from "../common/command-params"; + +export class AppleLogin implements ICommand { + public allowedParameters: ICommandParameter[] = [new StringCommandParameter(this.$injector), new StringCommandParameter(this.$injector)]; + + constructor( + private $applePortalSessionService: IApplePortalSessionService, + private $errors: IErrors, + private $injector: IInjector, + private $logger: ILogger, + private $prompter: IPrompter + ) { } + + public async execute(args: string[]): Promise { + let username = args[0]; + if (!username) { + username = await this.$prompter.getString("Apple ID", { allowEmpty: false }); + } + + let password = args[1]; + if (!password) { + password = await this.$prompter.getPassword("Apple ID password"); + } + + const user = await this.$applePortalSessionService.createUserSession({ username, password }); + if (!user.areCredentialsValid) { + this.$errors.fail(`Invalid username and password combination. Used '${username}' as the username.`); + } + + const output = Buffer.from(user.userSessionCookie).toString("base64"); + this.$logger.info(output); + } +} +$injector.registerCommand("apple-login", AppleLogin); diff --git a/lib/commands/appstore-list.ts b/lib/commands/appstore-list.ts index 6ed4569530..1633015cfc 100644 --- a/lib/commands/appstore-list.ts +++ b/lib/commands/appstore-list.ts @@ -6,12 +6,14 @@ export class ListiOSApps implements ICommand { constructor(private $injector: IInjector, private $applePortalApplicationService: IApplePortalApplicationService, + private $applePortalSessionService: IApplePortalSessionService, private $logger: ILogger, private $projectData: IProjectData, private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, private $platformValidationService: IPlatformValidationService, private $errors: IErrors, - private $prompter: IPrompter) { + private $prompter: IPrompter, + private $options: IOptions) { this.$projectData.initializeProjectData(); } @@ -31,7 +33,14 @@ export class ListiOSApps implements ICommand { password = await this.$prompter.getPassword("Apple ID password"); } - const applications = await this.$applePortalApplicationService.getApplications({ username, password }); + const user = await this.$applePortalSessionService.createUserSession({ username, password }, { + sessionBase64: this.$options.appleSessionBase64, + }); + if (!user.areCredentialsValid) { + this.$errors.fail(`Invalid username and password combination. Used '${username}' as the username.`); + } + + const applications = await this.$applePortalApplicationService.getApplications(user); if (!applications || !applications.length) { this.$logger.info("Seems you don't have any applications yet."); diff --git a/lib/commands/appstore-upload.ts b/lib/commands/appstore-upload.ts index 0dab27877e..60642b2352 100644 --- a/lib/commands/appstore-upload.ts +++ b/lib/commands/appstore-upload.ts @@ -8,6 +8,7 @@ export class PublishIOS implements ICommand { new StringCommandParameter(this.$injector), new StringCommandParameter(this.$injector)]; constructor( + private $applePortalSessionService: IApplePortalSessionService, private $injector: IInjector, private $itmsTransporterService: IITMSTransporterService, private $logger: ILogger, @@ -38,6 +39,16 @@ export class PublishIOS implements ICommand { password = await this.$prompter.getPassword("Apple ID password"); } + const user = await this.$applePortalSessionService.createUserSession({ username, password }, { + applicationSpecificPassword: this.$options.appleApplicationSpecificPassword, + sessionBase64: this.$options.appleSessionBase64, + requireInteractiveConsole: true, + requireApplicationSpecificPassword: true + }); + if (!user.areCredentialsValid) { + this.$errors.fail(`Invalid username and password combination. Used '${username}' as the username.`); + } + if (!mobileProvisionIdentifier && !ipaFilePath) { this.$logger.warn("No mobile provision identifier set. A default mobile provision will be used. You can set one in app/App_Resources/iOS/build.xcconfig"); } @@ -69,8 +80,9 @@ export class PublishIOS implements ICommand { } await this.$itmsTransporterService.upload({ - username, - password, + credentials: { username, password }, + user, + applicationSpecificPassword: this.$options.appleApplicationSpecificPassword, ipaFilePath, shouldExtractIpa: !!this.$options.ipa, verboseLogging: this.$logger.getLevel() === "TRACE" diff --git a/lib/commands/test.ts b/lib/commands/test.ts index a238ee38a6..a094facd19 100644 --- a/lib/commands/test.ts +++ b/lib/commands/test.ts @@ -1,5 +1,9 @@ abstract class TestCommandBase { public allowedParameters: ICommandParameter[] = []; + public dashedOptions = { + hmr: { type: OptionType.Boolean, default: false, hasSensitiveValue: false }, + }; + protected abstract platform: string; protected abstract $projectData: IProjectData; protected abstract $testExecutionService: ITestExecutionService; @@ -50,6 +54,13 @@ abstract class TestCommandBase { async canExecute(args: string[]): Promise { if (!this.$options.force) { + if (this.$options.hmr) { + // With HMR we are not restarting after LiveSync which is causing a 30 seconds app start on Android + // because the Runtime does not watch for the `/data/local/tmp-livesync-in-progress` file deletion. + // The App is closing itself after each test execution and the bug will be reproducible on each LiveSync. + this.$errors.fail("The `--hmr` option is not supported for this command."); + } + await this.$migrateController.validate({ projectDir: this.$projectData.projectDir, platforms: [this.platform] }); } diff --git a/lib/common/constants.ts b/lib/common/constants.ts index 74cd8be258..3dc5e375bb 100644 --- a/lib/common/constants.ts +++ b/lib/common/constants.ts @@ -95,6 +95,7 @@ export class HttpStatusCodes { static NOT_MODIFIED = 304; static PAYMENT_REQUIRED = 402; static PROXY_AUTHENTICATION_REQUIRED = 407; + static CONFLICTING_RESOURCE = 409; } export const HttpProtocolToPort: IDictionary = { diff --git a/lib/common/http-client.ts b/lib/common/http-client.ts index 5c970b7eea..fe2dd0b105 100644 --- a/lib/common/http-client.ts +++ b/lib/common/http-client.ts @@ -255,7 +255,9 @@ private defaultUserAgent: string; this.$logger.error(`You can run ${EOL}\t${clientNameLowerCase} proxy set .${EOL}In order to supply ${clientNameLowerCase} with the credentials needed.`); return "Your proxy requires authentication."; } else if (statusCode === HttpStatusCodes.PAYMENT_REQUIRED) { - return util.format("Your subscription has expired."); + return "Your subscription has expired."; + } else if (statusCode === HttpStatusCodes.CONFLICTING_RESOURCE) { + return "The request conflicts with the current state of the server."; } else { this.$logger.trace("Request was unsuccessful. Server returned: ", body); try { diff --git a/lib/controllers/run-controller.ts b/lib/controllers/run-controller.ts index dd15b5a9e3..8b29bdfe0d 100644 --- a/lib/controllers/run-controller.ts +++ b/lib/controllers/run-controller.ts @@ -2,6 +2,7 @@ import { HmrConstants, DeviceDiscoveryEventNames } from "../common/constants"; import { PREPARE_READY_EVENT_NAME, TrackActionNames, DEBUGGER_DETACHED_EVENT_NAME, RunOnDeviceEvents, USER_INTERACTION_NEEDED_EVENT_NAME } from "../constants"; import { cache, performanceLog } from "../common/decorators"; import { EventEmitter } from "events"; +import * as util from "util"; export class RunController extends EventEmitter implements IRunController { private prepareReadyEventHandler: any = null; @@ -270,7 +271,7 @@ export class RunController extends EventEmitter implements IRunController { } private async syncInitialDataOnDevices(projectData: IProjectData, liveSyncInfo: ILiveSyncInfo, deviceDescriptors: ILiveSyncDeviceDescriptor[]): Promise { - const rebuiltInformation: IDictionary<{ packageFilePath: string, platform: string, isEmulator: boolean }> = { }; + const rebuiltInformation: IDictionary<{ packageFilePath: string, platform: string, isEmulator: boolean }> = {}; const deviceAction = async (device: Mobile.IDevice) => { const deviceDescriptor = _.find(deviceDescriptors, dd => dd.identifier === device.deviceInfo.identifier); @@ -333,7 +334,7 @@ export class RunController extends EventEmitter implements IRunController { error: err, }); - await this.stop({ projectDir: projectData.projectDir, deviceIdentifiers: [device.deviceInfo.identifier], stopOptions: { shouldAwaitAllActions: false }}); + await this.stop({ projectDir: projectData.projectDir, deviceIdentifiers: [device.deviceInfo.identifier], stopOptions: { shouldAwaitAllActions: false } }); } }; @@ -341,7 +342,8 @@ export class RunController extends EventEmitter implements IRunController { } private async syncChangedDataOnDevices(data: IFilesChangeEventData, projectData: IProjectData, liveSyncInfo: ILiveSyncInfo): Promise { - const rebuiltInformation: IDictionary<{ packageFilePath: string, platform: string, isEmulator: boolean }> = { }; + const successfullySyncedMessageFormat = `Successfully synced application %s on device %s.`; + const rebuiltInformation: IDictionary<{ packageFilePath: string, platform: string, isEmulator: boolean }> = {}; const deviceAction = async (device: Mobile.IDevice) => { const deviceDescriptors = this.$liveSyncProcessDataService.getDeviceDescriptors(projectData.projectDir); @@ -380,28 +382,47 @@ export class RunController extends EventEmitter implements IRunController { await this.$deviceInstallAppService.installOnDevice(device, deviceDescriptor.buildData, rebuiltInformation[platformData.platformNameLowerCase].packageFilePath); await platformLiveSyncService.syncAfterInstall(device, watchInfo); await this.refreshApplication(projectData, { deviceAppData, modifiedFilesData: [], isFullSync: false, useHotModuleReload: liveSyncInfo.useHotModuleReload }, data, deviceDescriptor); + this.$logger.info(util.format(successfullySyncedMessageFormat, deviceAppData.appIdentifier, device.deviceInfo.identifier)); } else { const isInHMRMode = liveSyncInfo.useHotModuleReload && data.hmrData && data.hmrData.hash; if (isInHMRMode) { this.$hmrStatusService.watchHmrStatus(device.deviceInfo.identifier, data.hmrData.hash); } - let liveSyncResultInfo = await platformLiveSyncService.liveSyncWatchAction(device, watchInfo); - await this.refreshApplication(projectData, liveSyncResultInfo, data, deviceDescriptor); - - if (!liveSyncResultInfo.didRecover && isInHMRMode) { - const status = await this.$hmrStatusService.getHmrStatus(device.deviceInfo.identifier, data.hmrData.hash); - if (status === HmrConstants.HMR_ERROR_STATUS) { - watchInfo.filesToSync = data.hmrData.fallbackFiles; - liveSyncResultInfo = await platformLiveSyncService.liveSyncWatchAction(device, watchInfo); - // We want to force a restart of the application. - liveSyncResultInfo.isFullSync = true; - await this.refreshApplication(projectData, liveSyncResultInfo, data, deviceDescriptor); + const watchAction = async (): Promise => { + let liveSyncResultInfo = await platformLiveSyncService.liveSyncWatchAction(device, watchInfo); + await this.refreshApplication(projectData, liveSyncResultInfo, data, deviceDescriptor); + + if (!liveSyncResultInfo.didRecover && isInHMRMode) { + const status = await this.$hmrStatusService.getHmrStatus(device.deviceInfo.identifier, data.hmrData.hash); + if (status === HmrConstants.HMR_ERROR_STATUS) { + watchInfo.filesToSync = data.hmrData.fallbackFiles; + liveSyncResultInfo = await platformLiveSyncService.liveSyncWatchAction(device, watchInfo); + // We want to force a restart of the application. + liveSyncResultInfo.isFullSync = true; + await this.refreshApplication(projectData, liveSyncResultInfo, data, deviceDescriptor); + } + } + + this.$logger.info(util.format(successfullySyncedMessageFormat, deviceAppData.appIdentifier, device.deviceInfo.identifier)); + }; + + if (liveSyncInfo.useHotModuleReload) { + try { + this.$logger.trace("Try executing watch action without any preparation of files."); + await watchAction(); + this.$logger.trace("Successfully executed watch action without any preparation of files."); + return; + } catch (err) { + this.$logger.trace(`Error while trying to execute fast sync. Now we'll check the state of the app and we'll try to resurrect from the error. The error is: ${err}`); } } + + await this.$deviceInstallAppService.installOnDeviceIfNeeded(device, deviceDescriptor.buildData); + watchInfo.connectTimeout = null; + await watchAction(); } - this.$logger.info(`Successfully synced application ${deviceAppData.appIdentifier} on device ${device.deviceInfo.identifier}.`); } catch (err) { this.$logger.warn(`Unable to apply changes for device: ${device.deviceInfo.identifier}. Error is: ${err && err.message}.`); diff --git a/lib/declarations.d.ts b/lib/declarations.d.ts index 7f7da155ab..d0e91d2507 100644 --- a/lib/declarations.d.ts +++ b/lib/declarations.d.ts @@ -568,6 +568,8 @@ interface IOptions extends IRelease, IDeviceIdentifier, IJustLaunch, IAvd, IAvai analyticsLogFile: string; performance: Object; cleanupLogFile: string; + appleApplicationSpecificPassword: string; + appleSessionBase64: string; } interface IEnvOptions { @@ -609,7 +611,13 @@ interface IAndroidResourcesMigrationService { /** * Describes properties needed for uploading a package to iTunes Connect */ -interface IITMSData extends ICredentials { +interface IITMSData { + credentials: ICredentials; + + user: IApplePortalUserDetail; + + applicationSpecificPassword: string; + /** * Path to a .ipa file which will be uploaded. * @type {string} diff --git a/lib/options.ts b/lib/options.ts index 8893b85e4f..9bb960f91a 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -144,7 +144,9 @@ export class Options { hooks: { type: OptionType.Boolean, default: true, hasSensitiveValue: false }, link: { type: OptionType.Boolean, default: false, hasSensitiveValue: false }, aab: { type: OptionType.Boolean, hasSensitiveValue: false }, - performance: { type: OptionType.Object, hasSensitiveValue: true } + performance: { type: OptionType.Object, hasSensitiveValue: true }, + appleApplicationSpecificPassword: { type: OptionType.String, hasSensitiveValue: true }, + appleSessionBase64: { type: OptionType.String, hasSensitiveValue: true }, }; } diff --git a/lib/services/apple-portal/apple-portal-application-service.ts b/lib/services/apple-portal/apple-portal-application-service.ts index c3a55f7662..f39fceb1df 100644 --- a/lib/services/apple-portal/apple-portal-application-service.ts +++ b/lib/services/apple-portal/apple-portal-application-service.ts @@ -5,10 +5,9 @@ export class ApplePortalApplicationService implements IApplePortalApplicationSer private $httpClient: Server.IHttpClient ) { } - public async getApplications(credentials: ICredentials): Promise { + public async getApplications(user: IApplePortalUserDetail): Promise { let result: IApplePortalApplicationSummary[] = []; - const user = await this.$applePortalSessionService.createUserSession(credentials); for (const account of user.associatedAccounts) { const contentProviderId = account.contentProvider.contentProviderId; const dsId = user.sessionToken.dsId; @@ -36,10 +35,10 @@ export class ApplePortalApplicationService implements IApplePortalApplicationSer return JSON.parse(response.body).data; } - public async getApplicationByBundleId(credentials: ICredentials, bundleId: string): Promise { - const applications = await this.getApplications(credentials); + public async getApplicationByBundleId(user: IApplePortalUserDetail, bundleId: string): Promise { + const applications = await this.getApplications(user); if (!applications || !applications.length) { - this.$errors.fail(`Cannot find any registered applications for Apple ID ${credentials.username} in iTunes Connect.`); + this.$errors.fail(`Cannot find any registered applications for Apple ID ${user.userName} in iTunes Connect.`); } const application = _.find(applications, app => app.bundleId === bundleId); diff --git a/lib/services/apple-portal/apple-portal-cookie-service.ts b/lib/services/apple-portal/apple-portal-cookie-service.ts index 241652cd82..f58d6701ba 100644 --- a/lib/services/apple-portal/apple-portal-cookie-service.ts +++ b/lib/services/apple-portal/apple-portal-cookie-service.ts @@ -1,6 +1,6 @@ export class ApplePortalCookieService implements IApplePortalCookieService { private userSessionCookies: IStringDictionary = {}; - private validUserSessionCookieNames = ["myacinfo", "dqsid", "itctx", "itcdq", "acn01"]; + private validUserSessionCookieNames = ["myacinfo", "dqsid", "itctx", "itcdq", "acn01", "DES"]; private validWebSessionCookieNames = ["wosid", "woinst", "itctx"]; public getWebSessionCookie(cookiesData: string[]): string { @@ -29,7 +29,7 @@ export class ApplePortalCookieService implements IApplePortalCookieService { for (const cookie of parts) { const trimmedCookie = cookie.trim(); const [cookieKey, cookieValue] = trimmedCookie.split("="); - if (_.includes(validCookieNames, cookieKey)) { + if (_.includes(validCookieNames, cookieKey) || _.some(validCookieNames, validCookieName => cookieKey.startsWith(validCookieName))) { result[cookieKey] = { key: cookieKey, value: cookieValue, cookie: trimmedCookie }; } } diff --git a/lib/services/apple-portal/apple-portal-session-service.ts b/lib/services/apple-portal/apple-portal-session-service.ts index 83adf60272..14a09d5a59 100644 --- a/lib/services/apple-portal/apple-portal-session-service.ts +++ b/lib/services/apple-portal/apple-portal-session-service.ts @@ -1,3 +1,5 @@ +import { isInteractive } from "../../common/helpers"; + export class ApplePortalSessionService implements IApplePortalSessionService { private loginConfigEndpoint = "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com"; private defaultLoginConfig = { @@ -7,42 +9,33 @@ export class ApplePortalSessionService implements IApplePortalSessionService { constructor( private $applePortalCookieService: IApplePortalCookieService, + private $errors: IErrors, private $httpClient: Server.IHttpClient, - private $logger: ILogger + private $logger: ILogger, + private $prompter: IPrompter ) { } - public async createUserSession(credentials: ICredentials): Promise { - const loginConfig = await this.getLoginConfig(); - const loginUrl = `${loginConfig.authServiceUrl}/auth/signin`; - const loginResponse = await this.$httpClient.httpRequest({ - url: loginUrl, - method: "POST", - body: JSON.stringify({ - accountName: credentials.username, - password: credentials.password, - rememberMe: true - }), - headers: { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - 'X-Apple-Widget-Key': loginConfig.authServiceKey, - 'Accept': 'application/json, text/javascript' - } - }); - - this.$applePortalCookieService.updateUserSessionCookie(loginResponse.headers["set-cookie"]); + public async createUserSession(credentials: ICredentials, opts?: IAppleCreateUserSessionOptions): Promise { + const loginResult = await this.login(credentials, opts); - const sessionResponse = await this.$httpClient.httpRequest({ - url: "https://appstoreconnect.apple.com/olympus/v1/session", - method: "GET", - headers: { - 'Cookie': this.$applePortalCookieService.getUserSessionCookie() + if (!opts || !opts.sessionBase64) { + if (loginResult.isTwoFactorAuthenticationEnabled) { + const authServiceKey = (await this.getLoginConfig()).authServiceKey; + await this.handleTwoFactorAuthentication(loginResult.scnt, loginResult.xAppleIdSessionId, authServiceKey); } - }); - this.$applePortalCookieService.updateUserSessionCookie(sessionResponse.headers["set-cookie"]); + const sessionResponse = await this.$httpClient.httpRequest({ + url: "https://appstoreconnect.apple.com/olympus/v1/session", + method: "GET", + headers: { + 'Cookie': this.$applePortalCookieService.getUserSessionCookie() + } + }); - const userDetailResponse = await this.$httpClient.httpRequest({ + this.$applePortalCookieService.updateUserSessionCookie(sessionResponse.headers["set-cookie"]); + } + + const userDetailsResponse = await this.$httpClient.httpRequest({ url: "https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/user/detail", method: "GET", headers: { @@ -51,9 +44,12 @@ export class ApplePortalSessionService implements IApplePortalSessionService { } }); - this.$applePortalCookieService.updateUserSessionCookie(userDetailResponse.headers["set-cookie"]); + this.$applePortalCookieService.updateUserSessionCookie(userDetailsResponse.headers["set-cookie"]); + + const userdDetails = JSON.parse(userDetailsResponse.body).data; + const result = { ...userdDetails, ...loginResult, userSessionCookie: this.$applePortalCookieService.getUserSessionCookie() }; - return JSON.parse(userDetailResponse.body).data; + return result; } public async createWebSession(contentProviderId: number, dsId: string): Promise { @@ -79,6 +75,73 @@ export class ApplePortalSessionService implements IApplePortalSessionService { return webSessionCookie; } + private async login(credentials: ICredentials, opts?: IAppleCreateUserSessionOptions): Promise { + const result = { + scnt: null, + xAppleIdSessionId: null, + isTwoFactorAuthenticationEnabled: false, + areCredentialsValid: true + }; + + if (opts && opts.sessionBase64) { + const decodedSession = Buffer.from(opts.sessionBase64, "base64").toString("utf8"); + + this.$applePortalCookieService.updateUserSessionCookie([decodedSession]); + + result.isTwoFactorAuthenticationEnabled = decodedSession.indexOf("DES") > -1; + } else { + try { + await this.loginCore(credentials); + } catch (err) { + const statusCode = err && err.response && err.response.statusCode; + result.areCredentialsValid = statusCode !== 401 && statusCode !== 403; + result.isTwoFactorAuthenticationEnabled = statusCode === 409; + + if (result.isTwoFactorAuthenticationEnabled && opts && opts.requireApplicationSpecificPassword && !opts.applicationSpecificPassword) { + this.$errors.fail(`Your account has two-factor authentication enabled but --appleApplicationSpecificPassword option is not provided. +To generate an application-specific password, please go to https://appleid.apple.com/account/manage. +This password will be used for the iTunes Transporter, which is used to upload your application.`); + } + + if (result.isTwoFactorAuthenticationEnabled && opts && opts.requireInteractiveConsole && !isInteractive()) { + this.$errors.fail(`Your account has two-factor authentication enabled, but your console is not interactive. +For more details how to set up your environment, please execute "tns publish ios --help".`); + } + + const headers = (err && err.response && err.response.headers) || {}; + result.scnt = headers.scnt; + result.xAppleIdSessionId = headers['x-apple-id-session-id']; + } + } + + return result; + } + + private async loginCore(credentials: ICredentials): Promise { + const loginConfig = await this.getLoginConfig(); + const loginUrl = `${loginConfig.authServiceUrl}/auth/signin`; + const headers = { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-Apple-Widget-Key': loginConfig.authServiceKey, + 'Accept': 'application/json, text/javascript' + }; + const body = JSON.stringify({ + accountName: credentials.username, + password: credentials.password, + rememberMe: true + }); + + const loginResponse = await this.$httpClient.httpRequest({ + url: loginUrl, + method: "POST", + body, + headers + }); + + this.$applePortalCookieService.updateUserSessionCookie(loginResponse.headers["set-cookie"]); + } + private async getLoginConfig(): Promise<{authServiceUrl: string, authServiceKey: string}> { let config = null; @@ -91,5 +154,46 @@ export class ApplePortalSessionService implements IApplePortalSessionService { return config || this.defaultLoginConfig; } + + private async handleTwoFactorAuthentication(scnt: string, xAppleIdSessionId: string, authServiceKey: string): Promise { + const headers = { + 'scnt': scnt, + 'X-Apple-Id-Session-Id': xAppleIdSessionId, + 'X-Apple-Widget-Key': authServiceKey, + 'Accept': 'application/json' + }; + const authResponse = await this.$httpClient.httpRequest({ + url: "https://idmsa.apple.com/appleauth/auth", + method: "GET", + headers + }); + + const data = JSON.parse(authResponse.body); + if (data.trustedPhoneNumbers && data.trustedPhoneNumbers.length) { + const parsedAuthResponse = JSON.parse(authResponse.body); + const token = await this.$prompter.getString(`Please enter the ${parsedAuthResponse.securityCode.length} digit code`, { allowEmpty: false }); + + await this.$httpClient.httpRequest({ + url: `https://idmsa.apple.com/appleauth/auth/verify/trusteddevice/securitycode`, + method: "POST", + body: JSON.stringify({ + securityCode: { + code: token.toString() + } + }), + headers: { ...headers, 'Content-Type': "application/json" } + }); + + const authTrustResponse = await this.$httpClient.httpRequest({ + url: "https://idmsa.apple.com/appleauth/auth/2sv/trust", + method: "GET", + headers + }); + + this.$applePortalCookieService.updateUserSessionCookie(authTrustResponse.headers["set-cookie"]); + } else { + this.$errors.fail(`Although response from Apple indicated activated Two-step Verification or Two-factor Authentication, NativeScript CLI don't know how to handle this response: ${data}`); + } + } } $injector.register("applePortalSessionService", ApplePortalSessionService); diff --git a/lib/services/apple-portal/definitions.d.ts b/lib/services/apple-portal/definitions.d.ts index fdaf16968e..74f6dea05d 100644 --- a/lib/services/apple-portal/definitions.d.ts +++ b/lib/services/apple-portal/definitions.d.ts @@ -1,6 +1,6 @@ interface IApplePortalSessionService { - createUserSession(credentials: ICredentials): Promise; createWebSession(contentProviderId: number, dsId: string): Promise; + createUserSession(credentials: ICredentials, opts?: IAppleCreateUserSessionOptions): Promise; } interface IApplePortalCookieService { @@ -10,12 +10,26 @@ interface IApplePortalCookieService { } interface IApplePortalApplicationService { - getApplications(credentials: ICredentials): Promise + getApplications(user: IApplePortalUserDetail): Promise getApplicationsByProvider(contentProviderId: number, dsId: string): Promise; - getApplicationByBundleId(credentials: ICredentials, bundleId: string): Promise; + getApplicationByBundleId(user: IApplePortalUserDetail, bundleId: string): Promise; } -interface IApplePortalUserDetail { +interface IAppleCreateUserSessionOptions { + applicationSpecificPassword?: string; + sessionBase64: string; + requireInteractiveConsole?: boolean; + requireApplicationSpecificPassword?: boolean; +} + +interface IAppleLoginResult { + scnt: string; + xAppleIdSessionId: string; + isTwoFactorAuthenticationEnabled: boolean; + areCredentialsValid: boolean; +} + +interface IApplePortalUserDetail extends IAppleLoginResult { associatedAccounts: IApplePortalAssociatedAccountData[]; sessionToken: { dsId: string; @@ -29,8 +43,10 @@ interface IApplePortalUserDetail { userName: string; userId: string; contentProvider: string; + authServiceKey: string; visibility: boolean; DYCVisibility: boolean; + userSessionCookie: string; } interface IApplePortalAssociatedAccountData { diff --git a/lib/services/itmstransporter-service.ts b/lib/services/itmstransporter-service.ts index 8da333c6e8..5d769f9d42 100644 --- a/lib/services/itmstransporter-service.ts +++ b/lib/services/itmstransporter-service.ts @@ -28,7 +28,7 @@ export class ITMSTransporterService implements IITMSTransporterService { const ipaFileLocation = path.join(innerDirectory, ipaFileName); const loggingLevel = data.verboseLogging ? ITMSConstants.VerboseLoggingLevels.Verbose : ITMSConstants.VerboseLoggingLevels.Informational; const bundleId = await this.getBundleIdentifier(data); - const application = await this.$applePortalApplicationService.getApplicationByBundleId(data, bundleId); + const application = await this.$applePortalApplicationService.getApplicationByBundleId(data.user, bundleId); this.$fs.createDirectory(innerDirectory); @@ -40,7 +40,16 @@ export class ITMSTransporterService implements IITMSTransporterService { this.$fs.writeFile(path.join(innerDirectory, ITMSConstants.ApplicationMetadataFile), metadata); - await this.$childProcess.spawnFromEvent(itmsTransporterPath, ["-m", "upload", "-f", itmsDirectory, "-u", quoteString(data.username), "-p", quoteString(data.password), "-v", loggingLevel], "close", { stdio: "inherit" }); + const password = data.user.isTwoFactorAuthenticationEnabled ? data.applicationSpecificPassword : data.credentials.password; + await this.$childProcess.spawnFromEvent(itmsTransporterPath, + [ + "-m", "upload", + "-f", itmsDirectory, + "-u", quoteString(data.credentials.username), + "-p", quoteString(password), + "-v", loggingLevel + ], + "close", { stdio: "inherit" }); } private async getBundleIdentifier(data: IITMSData): Promise { diff --git a/lib/services/webpack/webpack-compiler-service.ts b/lib/services/webpack/webpack-compiler-service.ts index 6a6fbcb94a..29f6c4c7f8 100644 --- a/lib/services/webpack/webpack-compiler-service.ts +++ b/lib/services/webpack/webpack-compiler-service.ts @@ -221,33 +221,25 @@ export class WebpackCompilerService extends EventEmitter implements IWebpackComp return args; } - private getUpdatedEmittedFiles(emittedFiles: string[], chunkFiles: string[]) { - let fallbackFiles: string[] = []; + private getUpdatedEmittedFiles(allEmittedFiles: string[], chunkFiles: string[]) { + const hotHash = this.getCurrentHotUpdateHash(allEmittedFiles); + const emittedHotUpdateFiles = _.difference(allEmittedFiles, chunkFiles); + + return { emittedFiles: emittedHotUpdateFiles, fallbackFiles: chunkFiles, hash: hotHash }; + } + + private getCurrentHotUpdateHash(emittedFiles: string[]) { let hotHash; - let result = emittedFiles.slice(); const hotUpdateScripts = emittedFiles.filter(x => x.endsWith('.hot-update.js')); - if (chunkFiles && chunkFiles.length) { - result = result.filter(file => chunkFiles.indexOf(file) === -1); + if (hotUpdateScripts && hotUpdateScripts.length) { + // the hash is the same for each hot update in the current compilation + const hotUpdateName = hotUpdateScripts[0]; + const matcher = /^(.+)\.(.+)\.hot-update/gm; + const matches = matcher.exec(hotUpdateName); + hotHash = matches[2]; } - hotUpdateScripts.forEach(hotUpdateScript => { - const { name, hash } = this.parseHotUpdateChunkName(hotUpdateScript); - hotHash = hash; - // remove bundle/vendor.js files if there's a bundle.XXX.hot-update.js or vendor.XXX.hot-update.js - result = result.filter(file => file !== `${name}.js`); - }); - // if applying of hot update fails, we must fallback to the full files - fallbackFiles = emittedFiles.filter(file => hotUpdateScripts.indexOf(file) === -1); - - return { emittedFiles: result, fallbackFiles, hash: hotHash }; - } - private parseHotUpdateChunkName(name: string) { - const matcher = /^(.+)\.(.+)\.hot-update/gm; - const matches = matcher.exec(name); - return { - name: matches[1] || "", - hash: matches[2] || "", - }; + return hotHash || ""; } private async stopWebpackForPlatform(platform: string) { diff --git a/test/tns-appstore-upload.ts b/test/tns-appstore-upload.ts index 053b784c1b..9c5fa90520 100644 --- a/test/tns-appstore-upload.ts +++ b/test/tns-appstore-upload.ts @@ -71,6 +71,13 @@ class AppStore { return this.iOSPlatformData; } }, + "applePortalSessionService": { + createUserSession: () => { + return { + areCredentialsValid: true + }; + } + } } }); @@ -120,8 +127,8 @@ class AppStore { this.itmsTransporterService.upload = (options: IITMSData) => { this.itmsTransporterServiceUploadCalls++; chai.assert.equal(options.ipaFilePath, "/Users/person/git/MyProject/platforms/ios/archive/MyProject.ipa"); - chai.assert.equal(options.username, AppStore.itunesconnect.user); - chai.assert.equal(options.password, AppStore.itunesconnect.pass); + chai.assert.equal(options.credentials.username, AppStore.itunesconnect.user); + chai.assert.equal(options.credentials.password, AppStore.itunesconnect.pass); chai.assert.equal(options.verboseLogging, false); return Promise.resolve(); };