diff --git a/packages/eas-cli/src/build/android/build.ts b/packages/eas-cli/src/build/android/build.ts index ee4fec6070..dfa2db6c51 100644 --- a/packages/eas-cli/src/build/android/build.ts +++ b/packages/eas-cli/src/build/android/build.ts @@ -39,10 +39,13 @@ export async function createAndroidContextAsync( ): Promise { const { buildProfile } = ctx; - if (buildProfile.distribution === 'internal' && buildProfile.gradleCommand?.match(/bundle/)) { + if ( + (buildProfile.distribution === 'internal' || buildProfile.distribution === 'development') && + buildProfile.gradleCommand?.match(/bundle/) + ) { Log.addNewLineIfNone(); Log.warn( - `You're building your Android app for internal distribution. However, we've detected that the Gradle command you defined (${chalk.underline( + `You're building your Android app for internal or development distribution. However, we've detected that the Gradle command you defined (${chalk.underline( buildProfile.gradleCommand )}) includes string 'bundle'. This means that it will most likely produce an AAB and you will not be able to install it on your Android devices straight from the Expo website.` diff --git a/packages/eas-cli/src/build/android/prepareJob.ts b/packages/eas-cli/src/build/android/prepareJob.ts index 5dbfdb3732..fe00bb293a 100644 --- a/packages/eas-cli/src/build/android/prepareJob.ts +++ b/packages/eas-cli/src/build/android/prepareJob.ts @@ -48,7 +48,11 @@ export async function prepareJobAsync( : {}; let buildType = buildProfile.buildType; - if (!buildType && !buildProfile.gradleCommand && buildProfile.distribution === 'internal') { + if ( + !buildType && + !buildProfile.gradleCommand && + (buildProfile.distribution === 'internal' || buildProfile.distribution === 'development') + ) { buildType = Android.BuildType.APK; } diff --git a/packages/eas-cli/src/build/graphql.ts b/packages/eas-cli/src/build/graphql.ts index 99ae1c596f..5af131666f 100644 --- a/packages/eas-cli/src/build/graphql.ts +++ b/packages/eas-cli/src/build/graphql.ts @@ -82,7 +82,11 @@ function transformCredentialsSource( } function transformDistribution(distribution: Metadata['distribution']): DistributionType { - if (distribution === 'internal') { + //TODO: remove when change is added upstream to eas-build-job + //@ts-ignore + if (distribution === 'development') { + return DistributionType.Development; + } else if (distribution === 'internal') { return DistributionType.Internal; } else if (distribution === 'simulator') { return DistributionType.Simulator; diff --git a/packages/eas-cli/src/build/metadata.ts b/packages/eas-cli/src/build/metadata.ts index c81e15c8ea..66a864adb7 100644 --- a/packages/eas-cli/src/build/metadata.ts +++ b/packages/eas-cli/src/build/metadata.ts @@ -34,6 +34,8 @@ export async function collectMetadataAsync( fingerprintSource: runtimeMetadata?.fingerprintSource, reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir), ...channelObject, + //TODO: needs to be updated in @expo/eas-build-job + //@ts-expect-error distribution, appName: ctx.exp.name, appIdentifier: resolveAppIdentifier(ctx), diff --git a/packages/eas-cli/src/build/types.ts b/packages/eas-cli/src/build/types.ts index a8a8b526cd..bcf432e6c1 100644 --- a/packages/eas-cli/src/build/types.ts +++ b/packages/eas-cli/src/build/types.ts @@ -15,6 +15,7 @@ export enum BuildStatus { } export enum BuildDistributionType { + DEVELOPMENT = 'development', STORE = 'store', INTERNAL = 'internal', /** @deprecated Use simulator flag instead */ diff --git a/packages/eas-cli/src/build/utils/printBuildInfo.ts b/packages/eas-cli/src/build/utils/printBuildInfo.ts index 515b32ff9c..f8afc87508 100644 --- a/packages/eas-cli/src/build/utils/printBuildInfo.ts +++ b/packages/eas-cli/src/build/utils/printBuildInfo.ts @@ -92,7 +92,10 @@ function printBuildResult(build: BuildFragment): void { return; } - if (build.distribution === DistributionType.Internal) { + if ( + build.distribution === DistributionType.Internal || + build.distribution === DistributionType.Development + ) { Log.addNewLineIfNone(); const logsUrl = getBuildLogsUrl(build); // It's tricky to install the .apk file directly on Android so let's fallback diff --git a/packages/eas-cli/src/commands/build/resign.ts b/packages/eas-cli/src/commands/build/resign.ts index 8fea1d6790..40423607cf 100644 --- a/packages/eas-cli/src/commands/build/resign.ts +++ b/packages/eas-cli/src/commands/build/resign.ts @@ -297,7 +297,7 @@ export default class BuildResign extends EasCommand { json: false, }, filter: { - distribution: DistributionType.Internal, + distribution: DistributionType.Internal || DistributionType.Development, platform: toAppPlatform(platform), status: BuildStatus.Finished, buildProfile, @@ -316,8 +316,11 @@ export default class BuildResign extends EasCommand { ): Promise { if (maybeBuildId) { const build = await BuildQuery.byIdAsync(graphqlClient, maybeBuildId); - if (build.distribution !== DistributionType.Internal) { - throw new Error('This is not an internal distribution build.'); + if ( + build.distribution !== DistributionType.Internal && + build.distribution !== DistributionType.Development + ) { + throw new Error('This is not an internal or development distribution build.'); } if (build.status !== BuildStatus.Finished) { throw new Error('Only builds that finished successfully can be re-signed.'); diff --git a/packages/eas-cli/src/credentials/ios/IosCredentialsProvider.ts b/packages/eas-cli/src/credentials/ios/IosCredentialsProvider.ts index ed1807ad1c..4287b41563 100644 --- a/packages/eas-cli/src/credentials/ios/IosCredentialsProvider.ts +++ b/packages/eas-cli/src/credentials/ios/IosCredentialsProvider.ts @@ -10,7 +10,11 @@ import { getAppFromContextAsync } from './actions/BuildCredentialsUtils'; import { SetUpBuildCredentials } from './actions/SetUpBuildCredentials'; import { SetUpPushKey } from './actions/SetUpPushKey'; import { App, IosCredentials, Target } from './types'; -import { isAdHocProfile, isEnterpriseUniversalProfile } from './utils/provisioningProfile'; +import { + isAdHocProfile, + isDevelopmentProfile, + isEnterpriseUniversalProfile, +} from './utils/provisioningProfile'; import { CommonIosAppCredentialsFragment } from '../../graphql/generated'; import Log from '../../log'; import { findApplicationTarget } from '../../project/ios/target'; @@ -36,10 +40,7 @@ enum PushNotificationSetupOption { export default class IosCredentialsProvider { public readonly platform = Platform.IOS; - constructor( - private readonly ctx: CredentialsContext, - private readonly options: Options - ) {} + constructor(private readonly ctx: CredentialsContext, private readonly options: Options) {} public async getCredentialsAsync( src: CredentialsSource.LOCAL | CredentialsSource.REMOTE @@ -153,6 +154,18 @@ export default class IosCredentialsProvider { private assertProvisioningProfileType(provisioningProfile: string, targetName?: string): void { const isAdHoc = isAdHocProfile(provisioningProfile); const isEnterprise = isEnterpriseUniversalProfile(provisioningProfile); + const isDevelopment = isDevelopmentProfile(provisioningProfile); + + if (this.options.distribution === 'development') { + if (!isDevelopment) { + throw new Error( + `You must use a development provisioning profile${ + targetName ? ` (target '${targetName})'` : '' + } for development distribution.` + ); + } + } + if (this.options.distribution === 'internal') { if (this.options.enterpriseProvisioning === 'universal' && !isEnterprise) { throw new Error( diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpDevelopmentProvisioningProfile.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpDevelopmentProvisioningProfile.ts new file mode 100644 index 0000000000..606c415e89 --- /dev/null +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpDevelopmentProvisioningProfile.ts @@ -0,0 +1,347 @@ +import { ProfileType } from '@expo/apple-utils'; +import assert from 'assert'; +import chalk from 'chalk'; +import nullthrows from 'nullthrows'; + +import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action'; +import { + AppleDeviceFragment, + AppleDistributionCertificateFragment, + AppleProvisioningProfileFragment, + AppleTeamFragment, + IosAppBuildCredentialsFragment, + IosDistributionType, +} from '../../../graphql/generated'; +import Log from '../../../log'; +import { getApplePlatformFromTarget } from '../../../project/ios/target'; +import { confirmAsync, pressAnyKeyToContinueAsync, promptAsync } from '../../../prompts'; +import differenceBy from '../../../utils/expodash/differenceBy'; +import { CredentialsContext } from '../../context'; +import { MissingCredentialsNonInteractiveError } from '../../errors'; +import { AppLookupParams } from '../api/graphql/types/AppLookupParams'; +import { ApplePlatform } from '../appstore/constants'; +import { Target } from '../types'; +import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile'; +import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils'; +import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from './BuildCredentialsUtils'; +import { chooseDevicesAsync, formatDeviceLabel } from './DeviceUtils'; +import { SetUpDistributionCertificate } from './SetUpDistributionCertificate'; + +enum ReuseAction { + Yes, + ShowDevices, + No, +} + +interface Options { + app: AppLookupParams; + target: Target; +} + +export class SetUpDevelopmentProvisioningProfile { + constructor(private options: Options) {} + + async runAsync(ctx: CredentialsContext): Promise { + const { app } = this.options; + const distCert = await new SetUpDistributionCertificate( + app, + IosDistributionType.Development + ).runAsync(ctx); + + const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx); + + if (ctx.nonInteractive) { + if (areBuildCredentialsSetup) { + return nullthrows( + await getBuildCredentialsAsync(ctx, app, IosDistributionType.Development) + ); + } else { + throw new MissingCredentialsNonInteractiveError( + 'Provisioning profile is not configured correctly. Run this command again in interactive mode.' + ); + } + } + + const currentBuildCredentials = await getBuildCredentialsAsync( + ctx, + app, + IosDistributionType.Development + ); + if (areBuildCredentialsSetup) { + const buildCredentials = nullthrows(currentBuildCredentials); + if (await this.shouldUseExistingProfileAsync(ctx, buildCredentials)) { + return buildCredentials; + } + } + + return await this.runWithDistributionCertificateAsync(ctx, distCert); + } + + async runWithDistributionCertificateAsync( + ctx: CredentialsContext, + distCert: AppleDistributionCertificateFragment + ): Promise { + const { app, target } = this.options; + + const currentBuildCredentials = await getBuildCredentialsAsync( + ctx, + app, + IosDistributionType.Development + ); + + // 1. Resolve Apple Team + let appleTeam: AppleTeamFragment | null = + distCert.appleTeam ?? currentBuildCredentials?.provisioningProfile?.appleTeam ?? null; + if (!appleTeam) { + await ctx.appStore.ensureAuthenticatedAsync(); + appleTeam = await resolveAppleTeamIfAuthenticatedAsync(ctx, app); + } + assert(appleTeam, 'Apple Team must be defined here'); + + // 2. Fetch devices registered on EAS servers + let registeredAppleDevices = await ctx.ios.getDevicesForAppleTeamAsync( + ctx.graphqlClient, + app, + appleTeam + ); + if (registeredAppleDevices.length === 0) { + const shouldRegisterDevices = await confirmAsync({ + message: `You don't have any registered devices yet. Would you like to register them now?`, + initial: true, + }); + + if (shouldRegisterDevices) { + registeredAppleDevices = await this.registerDevicesAsync(ctx, appleTeam); + } else { + throw new Error(`Run 'eas device:create' to register your devices first`); + } + } + + // 3. Choose devices for internal distribution + const provisionedDeviceIdentifiers = ( + currentBuildCredentials?.provisioningProfile?.appleDevices ?? [] + ).map(i => i.identifier); + const chosenDevices = await chooseDevicesAsync( + registeredAppleDevices, + provisionedDeviceIdentifiers + ); + + // 4. Reuse or create the profile on Apple Developer Portal + const applePlatform = await getApplePlatformFromTarget(target); + const profileType = + applePlatform === ApplePlatform.TV_OS + ? ProfileType.TVOS_APP_DEVELOPMENT + : ProfileType.IOS_APP_DEVELOPMENT; + const provisioningProfileStoreInfo = + await ctx.appStore.createOrReuseDevelopmentProvisioningProfileAsync( + chosenDevices.map(({ identifier }) => identifier), + app.bundleIdentifier, + distCert.serialNumber, + profileType + ); + + // 5. Create or update the profile on servers + const appleAppIdentifier = await ctx.ios.createOrGetExistingAppleAppIdentifierAsync( + ctx.graphqlClient, + app, + appleTeam + ); + let appleProvisioningProfile: AppleProvisioningProfileFragment | null = null; + if (currentBuildCredentials?.provisioningProfile) { + if ( + currentBuildCredentials.provisioningProfile.developerPortalIdentifier !== + provisioningProfileStoreInfo.provisioningProfileId + ) { + await ctx.ios.deleteProvisioningProfilesAsync(ctx.graphqlClient, [ + currentBuildCredentials.provisioningProfile.id, + ]); + appleProvisioningProfile = await ctx.ios.createProvisioningProfileAsync( + ctx.graphqlClient, + app, + appleAppIdentifier, + { + appleProvisioningProfile: provisioningProfileStoreInfo.provisioningProfile, + developerPortalIdentifier: provisioningProfileStoreInfo.provisioningProfileId, + } + ); + } else { + appleProvisioningProfile = currentBuildCredentials.provisioningProfile; + } + } else { + appleProvisioningProfile = await ctx.ios.createProvisioningProfileAsync( + ctx.graphqlClient, + app, + appleAppIdentifier, + { + appleProvisioningProfile: provisioningProfileStoreInfo.provisioningProfile, + developerPortalIdentifier: provisioningProfileStoreInfo.provisioningProfileId, + } + ); + } + + // 6. Compare selected devices with the ones actually provisioned + const diffList = differenceBy( + chosenDevices, + appleProvisioningProfile.appleDevices, + 'identifier' + ); + if (diffList && diffList.length > 0) { + Log.warn(`Failed to provision ${diffList.length} of the selected devices:`); + for (const missingDevice of diffList) { + Log.warn(`- ${formatDeviceLabel(missingDevice)}`); + } + Log.log( + 'Most commonly devices fail to to be provisioned while they are still being processed by Apple, which can take up to 24-72 hours. Check your Apple Developer Portal page at https://developer.apple.com/account/resources/devices/list, the devices in "Processing" status cannot be provisioned yet' + ); + } + + // 7. Create (or update) app build credentials + assert(appleProvisioningProfile); + return await assignBuildCredentialsAsync( + ctx, + app, + IosDistributionType.Development, + distCert, + appleProvisioningProfile, + appleTeam + ); + } + + private async areBuildCredentialsSetupAsync(ctx: CredentialsContext): Promise { + const { app, target } = this.options; + const buildCredentials = await getBuildCredentialsAsync( + ctx, + app, + IosDistributionType.Development + ); + return await validateProvisioningProfileAsync(ctx, target, app, buildCredentials); + } + + private async shouldUseExistingProfileAsync( + ctx: CredentialsContext, + buildCredentials: IosAppBuildCredentialsFragment + ): Promise { + const { app } = this.options; + const provisioningProfile = nullthrows(buildCredentials.provisioningProfile); + + const appleTeam = nullthrows(provisioningProfile.appleTeam); + const registeredAppleDevices = await ctx.ios.getDevicesForAppleTeamAsync( + ctx.graphqlClient, + app, + appleTeam + ); + + const provisionedDevices = provisioningProfile.appleDevices; + + const allRegisteredDevicesAreProvisioned = doUDIDsMatch( + registeredAppleDevices.map(({ identifier }) => identifier), + provisionedDevices.map(({ identifier }) => identifier) + ); + + if (allRegisteredDevicesAreProvisioned) { + const reuseAction = await this.promptForReuseActionAsync(); + if (reuseAction === ReuseAction.Yes) { + return true; + } else if (reuseAction === ReuseAction.No) { + return false; + } else { + Log.newLine(); + Log.log('Devices registered in the Provisioning Profile:'); + for (const device of provisionedDevices) { + Log.log(`- ${formatDeviceLabel(device)}`); + } + Log.newLine(); + return ( + (await this.promptForReuseActionAsync({ showShowDevicesOption: false })) === + ReuseAction.Yes + ); + } + } else { + const missingDevices = differenceBy(registeredAppleDevices, provisionedDevices, 'identifier'); + Log.warn(`The provisioning profile is missing the following devices:`); + for (const missingDevice of missingDevices) { + Log.warn(`- ${formatDeviceLabel(missingDevice)}`); + } + return !(await confirmAsync({ + message: `Would you like to choose the devices to provision again?`, + initial: true, + })); + } + } + + private async promptForReuseActionAsync({ + showShowDevicesOption = true, + } = {}): Promise { + const { selected } = await promptAsync({ + type: 'select', + name: 'selected', + message: `${ + showShowDevicesOption + ? 'All your registered devices are present in the Provisioning Profile. ' + : '' + }Would you like to reuse the profile?`, + choices: [ + { title: 'Yes', value: ReuseAction.Yes }, + ...(showShowDevicesOption + ? [ + { + title: 'Show devices and ask me again', + value: ReuseAction.ShowDevices, + }, + ] + : []), + { + title: 'No, let me choose devices again', + value: ReuseAction.No, + }, + ], + }); + return selected; + } + + private async registerDevicesAsync( + ctx: CredentialsContext, + appleTeam: AppleTeamFragment + ): Promise { + const { app } = this.options; + const action = new DeviceCreateAction(ctx.graphqlClient, ctx.appStore, app.account, appleTeam); + const method = await action.runAsync(); + + while (true) { + if (method === RegistrationMethod.WEBSITE) { + Log.newLine(); + Log.log(chalk.bold("Press any key if you've already finished device registration.")); + await pressAnyKeyToContinueAsync(); + } + Log.newLine(); + + const devices = await ctx.ios.getDevicesForAppleTeamAsync(ctx.graphqlClient, app, appleTeam, { + useCache: false, + }); + if (devices.length === 0) { + Log.warn('There are still no registered devices.'); + // if the user used the input method there should be some devices available + if (method === RegistrationMethod.INPUT) { + throw new Error('Input registration method has failed'); + } + } else { + return devices; + } + } + } +} + +export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean { + const setA = new Set(udidsA); + const setB = new Set(udidsB); + + if (setA.size !== setB.size) { + return false; + } + for (const a of setA) { + if (!setB.has(a)) { + return false; + } + } + return true; +} diff --git a/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts b/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts index 81a377511a..df1be368f0 100644 --- a/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts +++ b/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts @@ -42,6 +42,7 @@ import { useExistingProvisioningProfileAsync, } from './provisioningProfile'; import { createOrReuseAdhocProvisioningProfileAsync } from './provisioningProfileAdhoc'; +import { createOrReuseDevelopmentProvisioningProfileAsync } from './provisioningProfileDevelopment'; import { createPushKeyAsync, listPushKeysAsync, revokePushKeyAsync } from './pushKey'; import { hasAscEnvVars } from './resolveCredentials'; import { Analytics } from '../../../analytics/AnalyticsManager'; @@ -183,6 +184,22 @@ export default class AppStoreApi { ); } + public async createOrReuseDevelopmentProvisioningProfileAsync( + udids: string[], + bundleIdentifier: string, + distCertSerialNumber: string, + profileType: ProfileType + ): Promise { + const ctx = await this.ensureAuthenticatedAsync(); + return await createOrReuseDevelopmentProvisioningProfileAsync( + ctx, + udids, + bundleIdentifier, + distCertSerialNumber, + profileType + ); + } + public async listAscApiKeysAsync(): Promise { const userCtx = await this.ensureUserAuthenticatedAsync(); return await listAscApiKeysAsync(userCtx); diff --git a/packages/eas-cli/src/credentials/ios/appstore/provisioningProfileDevelopment.ts b/packages/eas-cli/src/credentials/ios/appstore/provisioningProfileDevelopment.ts new file mode 100644 index 0000000000..fe1ae48aa9 --- /dev/null +++ b/packages/eas-cli/src/credentials/ios/appstore/provisioningProfileDevelopment.ts @@ -0,0 +1,280 @@ +import { Device, Profile, ProfileState, ProfileType, RequestContext } from '@expo/apple-utils'; + +import { ora } from '../../../ora'; +import { isAppStoreConnectTokenOnlyContext } from '../utils/authType'; +import { ProvisioningProfile } from './Credentials.types'; +import { getRequestContext } from './authenticate'; +import { AuthCtx } from './authenticateTypes'; +import { getBundleIdForIdentifierAsync, getProfilesForBundleIdAsync } from './bundleId'; +import { getDistributionCertificateAsync } from './distributionCertificate'; + +interface ProfileResults { + didUpdate?: boolean; + didCreate?: boolean; + profileName?: string; + provisioningProfileId: string; + provisioningProfile: any; +} + +function uniqueItems(items: T[]): T[] { + const set = new Set(items); + return [...set]; +} + +async function registerMissingDevicesAsync( + context: RequestContext, + udids: string[] +): Promise { + const allDevices = await Device.getAsync(context); + const alreadyAdded = allDevices.filter(device => udids.includes(device.attributes.udid)); + const alreadyAddedUdids = alreadyAdded.map(i => i.attributes.udid); + + await Promise.all( + udids.map(async udid => { + if (!alreadyAddedUdids.includes(udid)) { + const device = await Device.createAsync(context, { + name: 'iOS Device (added by Expo)', + udid, + }); + alreadyAdded.push(device); + } + }) + ); + + return alreadyAdded; +} + +async function findProfileAsync( + context: RequestContext, + { + bundleId, + certSerialNumber, + profileType, + }: { bundleId: string; certSerialNumber: string; profileType: ProfileType } +): Promise<{ + profile: Profile | null; + didUpdate: boolean; +}> { + const expoProfiles = (await getProfilesForBundleIdAsync(context, bundleId)).filter(profile => { + return ( + profile.attributes.profileType === profileType && + profile.attributes.name.startsWith('*[expo]') && + profile.attributes.profileState !== ProfileState.EXPIRED + ); + }); + + const expoProfilesWithCertificate: Profile[] = []; + // find profiles associated with our development cert + for (const profile of expoProfiles) { + const certificates = await profile.getCertificatesAsync(); + if (certificates.some(cert => cert.attributes.serialNumber === certSerialNumber)) { + expoProfilesWithCertificate.push(profile); + } + } + + if (expoProfilesWithCertificate) { + // there is an expo managed profile with our desired certificate + // return the profile that will be valid for the longest duration + return { + profile: + expoProfilesWithCertificate.sort(sortByExpiration)[expoProfilesWithCertificate.length - 1], + didUpdate: false, + }; + } else if (expoProfiles) { + // there is an expo managed profile, but it doesn't have our desired certificate + // append the certificate and update the profile + const distributionCertificate = await getDistributionCertificateAsync( + context, + certSerialNumber + ); + if (!distributionCertificate) { + throw new Error(`Certificate for serial number "${certSerialNumber}" does not exist`); + } + const profile = expoProfiles.sort(sortByExpiration)[expoProfiles.length - 1]; + profile.attributes.certificates = [distributionCertificate]; + + return { + profile: isAppStoreConnectTokenOnlyContext(profile.context) + ? // Experimentally regenerate the provisioning profile using App Store Connect API. + await profile.regenerateManuallyAsync() + : // This method does not support App Store Connect API. + await profile.regenerateAsync(), + didUpdate: true, + }; + } + + // there is no valid provisioning profile available + return { profile: null, didUpdate: false }; +} + +function sortByExpiration(a: Profile, b: Profile): number { + return ( + new Date(a.attributes.expirationDate).getTime() - + new Date(b.attributes.expirationDate).getTime() + ); +} + +async function findProfileByIdAsync( + context: RequestContext, + profileId: string, + bundleId: string +): Promise { + let profiles = await getProfilesForBundleIdAsync(context, bundleId); + profiles = profiles.filter( + profile => profile.attributes.profileType === ProfileType.IOS_APP_DEVELOPMENT + ); + return profiles.find(profile => profile.id === profileId) ?? null; +} + +async function manageDevelopmentProfilesAsync( + context: RequestContext, + { + udids, + bundleId, + certSerialNumber, + profileId, + profileType, + }: { + udids: string[]; + bundleId: string; + certSerialNumber: string; + profileId?: string; + profileType: ProfileType; + } +): Promise { + // We register all missing devices on the Apple Developer Portal. They are identified by UDIDs. + const devices = await registerMissingDevicesAsync(context, udids); + + let existingProfile: Profile | null; + let didUpdate = false; + + if (profileId) { + existingProfile = await findProfileByIdAsync(context, profileId, bundleId); + // Fail if we cannot find the profile that was specifically requested + if (!existingProfile) { + throw new Error( + `Could not find profile with profile id "${profileId}" for bundle id "${bundleId}"` + ); + } + } else { + // If no profile id is passed, try to find a suitable provisioning profile for the App ID. + const results = await findProfileAsync(context, { bundleId, certSerialNumber, profileType }); + existingProfile = results.profile; + didUpdate = results.didUpdate; + } + + if (existingProfile) { + // We need to verify whether the existing profile includes all user's devices. + let deviceUdidsInProfile = + existingProfile?.attributes?.devices?.map?.(i => i.attributes.udid) ?? []; + deviceUdidsInProfile = uniqueItems(deviceUdidsInProfile.filter(Boolean)); + const allDeviceUdids = uniqueItems(udids); + const hasEqualUdids = + deviceUdidsInProfile.length === allDeviceUdids.length && + deviceUdidsInProfile.every(udid => allDeviceUdids.includes(udid)); + if (hasEqualUdids && existingProfile.isValid()) { + const result: ProfileResults = { + profileName: existingProfile?.attributes?.name, + provisioningProfileId: existingProfile?.id, + provisioningProfile: existingProfile?.attributes.profileContent, + }; + if (didUpdate) { + result.didUpdate = true; + } + + return result; + } + // We need to add new devices to the list and create a new provisioning profile. + existingProfile.attributes.devices = devices; + + if (isAppStoreConnectTokenOnlyContext(existingProfile.context)) { + // Experimentally regenerate the provisioning profile using App Store Connect API. + await existingProfile.regenerateManuallyAsync(); + } else { + // This method does not support App Store Connect API. + await existingProfile.regenerateAsync(); + } + + const updatedProfile = ( + await findProfileAsync(context, { bundleId, certSerialNumber, profileType }) + ).profile; + if (!updatedProfile) { + throw new Error( + `Failed to locate updated profile for bundle identifier "${bundleId}" and serial number "${certSerialNumber}"` + ); + } + return { + didUpdate: true, + profileName: updatedProfile.attributes.name, + provisioningProfileId: updatedProfile.id, + provisioningProfile: updatedProfile.attributes.profileContent, + }; + } + + // No existing profile... + + // We need to find user's distribution certificate to make a provisioning profile for it. + const distributionCertificate = await getDistributionCertificateAsync(context, certSerialNumber); + + if (!distributionCertificate) { + // If the distribution certificate doesn't exist, the user must have deleted it, we can't do anything here :( + throw new Error( + `No distribution certificate for serial number "${certSerialNumber}" is available to make a provisioning profile against` + ); + } + const bundleIdItem = await getBundleIdForIdentifierAsync(context, bundleId); + // If the provisioning profile for the App ID doesn't exist, we just need to create a new one! + const newProfile = await Profile.createAsync(context, { + bundleId: bundleIdItem.id, + // apple drops [ if its the first char (!!), + name: `*[expo] ${bundleId} Development ${Date.now()}`, + certificates: [distributionCertificate.id], + devices: devices.map(device => device.id), + profileType, + }); + + return { + didUpdate: true, + didCreate: true, + profileName: newProfile.attributes.name, + provisioningProfileId: newProfile.id, + provisioningProfile: newProfile.attributes.profileContent, + }; +} + +export async function createOrReuseDevelopmentProvisioningProfileAsync( + authCtx: AuthCtx, + udids: string[], + bundleIdentifier: string, + distCertSerialNumber: string, + profileType: ProfileType +): Promise { + const spinner = ora(`Handling Apple development provisioning profiles`).start(); + try { + const context = getRequestContext(authCtx); + const { didUpdate, didCreate, profileName, ...developmentProvisioningProfile } = + await manageDevelopmentProfilesAsync(context, { + udids, + bundleId: bundleIdentifier, + certSerialNumber: distCertSerialNumber, + profileType, + }); + + if (didCreate) { + spinner.succeed(`Created new profile: ${profileName}`); + } else if (didUpdate) { + spinner.succeed(`Updated existing profile: ${profileName}`); + } else { + spinner.succeed(`Used existing profile: ${profileName}`); + } + + return { + ...developmentProvisioningProfile, + teamId: authCtx.team.id, + teamName: authCtx.team.name, + }; + } catch (error) { + spinner.fail(`Failed to handle Apple profiles`); + throw error; + } +} diff --git a/packages/eas-cli/src/credentials/ios/utils/provisioningProfile.ts b/packages/eas-cli/src/credentials/ios/utils/provisioningProfile.ts index 74507389f4..bcbd45572f 100644 --- a/packages/eas-cli/src/credentials/ios/utils/provisioningProfile.ts +++ b/packages/eas-cli/src/credentials/ios/utils/provisioningProfile.ts @@ -26,6 +26,20 @@ export function isAdHocProfile(dataBase64: string): boolean { return Array.isArray(provisionedDevices); } +export function isDevelopmentProfile(dataBase64: string): boolean { + const profilePlist = parse(dataBase64); + + // Usually, aps-environment is set to 'development' for development profiles and 'production' for any others. + // https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment#discussion + //@ts-ignore + const apsEnvironment = profilePlist['Entitlements']['aps-environment'] as string | undefined; + + const provisionedDevices = profilePlist['ProvisionedDevices'] as string[] | undefined; + + // We can assume that the profile is development if it has provisioned devices and has aps-environment set to 'development'. + return apsEnvironment === 'development' && Array.isArray(provisionedDevices); +} + export function isEnterpriseUniversalProfile(dataBase64: string): boolean { const profilePlist = parse(dataBase64); return !!profilePlist['ProvisionsAllDevices']; diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 8bde025367..a921d97fa6 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -3785,8 +3785,9 @@ export type DiscordUserMutationDeleteDiscordUserArgs = { export enum DistributionType { Internal = 'INTERNAL', + Development = "DEVELOPMENT", Simulator = 'SIMULATOR', - Store = 'STORE' + Store = 'STORE', } export enum EasBuildBillingResourceClass { diff --git a/packages/eas-json/schema/eas.schema.json b/packages/eas-json/schema/eas.schema.json index 3e69db0406..ff416f6be9 100644 --- a/packages/eas-json/schema/eas.schema.json +++ b/packages/eas-json/schema/eas.schema.json @@ -117,9 +117,13 @@ "markdownDescription": "The channel name identifies which EAS Update channel a build belongs to. [Learn more](https://docs.expo.dev/eas-update/how-eas-update-works/)" }, "distribution": { - "enum": ["internal", "store"], + "enum": [ + "development", + "internal", + "store" + ], "description": "The method of distributing your app. Learn more: https://docs.expo.dev/build/internal-distribution/", - "markdownDescription": "The method of distributing your app.\n\n- `internal` - with this option you'll be able to share your build URLs with anyone, and they will be able to install the builds to their devices straight from the Expo website. When using `internal`, make sure the build produces an APK or IPA file. Otherwise, the shareable URL will be useless. [Learn more](https://docs.expo.dev/build/internal-distribution/)\n- `store` - produces builds for store uploads, your build URLs won't be shareable.", + "markdownDescription": "The method of distributing your app.\n\n- `internal` - with this option you'll be able to share your build URLs with anyone, and they will be able to install the builds to their devices straight from the Expo website. When using `internal`, make sure the build produces an APK or IPA file. Otherwise, the shareable URL will be useless. [Learn more](https://docs.expo.dev/build/internal-distribution/)\n`development` - with this option you'll be able to create builds intended for development-only purposes. Common situations include e.g. the development of Tap to Pay on iPhone capability on iOS until Apple grants you wider permissions in regards on what provisioning profiles can support that capability. Similarly to the `internal` distribution method, it is preferable that the build produces an IPA or APK so that it can be shared with the development members. t\n- `store` - produces builds for store uploads, your build URLs won't be shareable.", "markdownEnumDescriptions": [ "With this option you'll be able to share your build URLs with anyone, and they will be able to install the builds to their devices straight from the Expo website. When using `internal`, make sure the build produces an APK or IPA file. Otherwise, the shareable URL will be useless. [Learn more](https://docs.expo.dev/build/internal-distribution/)", "Produces builds for store uploads, your build URLs won't be shareable." diff --git a/packages/eas-json/src/build/schema.ts b/packages/eas-json/src/build/schema.ts index 5021077a23..0f70b2d309 100644 --- a/packages/eas-json/src/build/schema.ts +++ b/packages/eas-json/src/build/schema.ts @@ -45,7 +45,7 @@ const CommonBuildProfileSchema = Joi.object({ // credentials credentialsSource: Joi.string().valid('local', 'remote').default('remote'), - distribution: Joi.string().valid('store', 'internal').default('store'), + distribution: Joi.string().valid('store', 'internal', 'development').default('store'), // updates releaseChannel: Joi.string().regex(/^[a-z\d][a-z\d._-]*$/), diff --git a/packages/eas-json/src/build/types.ts b/packages/eas-json/src/build/types.ts index 6ab4b133f6..63963c7335 100644 --- a/packages/eas-json/src/build/types.ts +++ b/packages/eas-json/src/build/types.ts @@ -20,7 +20,7 @@ export enum ResourceClass { M_LARGE = 'm-large', } -export type DistributionType = 'store' | 'internal'; +export type DistributionType = 'store' | 'internal' | 'development'; export type IosEnterpriseProvisioning = 'adhoc' | 'universal';