diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1a30b6..941040d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ### 🎉 New features -- Add support for launching Expo updates. ([#134](https://github.com/expo/orbit/pull/134), [#137](https://github.com/expo/orbit/pull/137), [#138](https://github.com/expo/orbit/pull/138), [#144](https://github.com/expo/orbit/pull/144), [#148](https://github.com/expo/orbit/pull/148) by [@gabrieldonadel](https://github.com/gabrieldonadel)) +- Add support for launching Expo updates. ([#134](https://github.com/expo/orbit/pull/134), [#137](https://github.com/expo/orbit/pull/137), [#138](https://github.com/expo/orbit/pull/138), [#144](https://github.com/expo/orbit/pull/144), [#148](https://github.com/expo/orbit/pull/148), [#188](https://github.com/expo/orbit/pull/188) by [@gabrieldonadel](https://github.com/gabrieldonadel)) - Add experimental support for Windows and Linux. ([#152](https://github.com/expo/orbit/pull/152), [#157](https://github.com/expo/orbit/pull/157), [#158](https://github.com/expo/orbit/pull/158), [#160](https://github.com/expo/orbit/pull/160), [#161](https://github.com/expo/orbit/pull/161), [#165](https://github.com/expo/orbit/pull/165), [#170](https://github.com/expo/orbit/pull/170), [#171](https://github.com/expo/orbit/pull/171), [#172](https://github.com/expo/orbit/pull/172), [#173](https://github.com/expo/orbit/pull/173), [#174](https://github.com/expo/orbit/pull/174), [#175](https://github.com/expo/orbit/pull/175), [#177](https://github.com/expo/orbit/pull/177), [#178](https://github.com/expo/orbit/pull/178), [#180](https://github.com/expo/orbit/pull/180), [#181](https://github.com/expo/orbit/pull/181), [#182](https://github.com/expo/orbit/pull/182), [#185](https://github.com/expo/orbit/pull/185) by [@gabrieldonadel](https://github.com/gabrieldonadel)) - Migrate Sparkle to Expo Modules. ([#184](https://github.com/expo/orbit/pull/184) by [@gabrieldonadel](https://github.com/gabrieldonadel)) - Automatically handle installing incompatible Android app updates. ([#187](https://github.com/expo/orbit/pull/187) by [@gabrieldonadel](https://github.com/gabrieldonadel)) diff --git a/apps/cli/src/commands/LaunchUpdate.ts b/apps/cli/src/commands/LaunchUpdate.ts index b3de30b2..060db46e 100644 --- a/apps/cli/src/commands/LaunchUpdate.ts +++ b/apps/cli/src/commands/LaunchUpdate.ts @@ -4,45 +4,80 @@ import { graphqlSdk } from '../api/GraphqlClient'; import { AppPlatform, DistributionType } from '../graphql/generated/graphql'; import { downloadBuildAsync } from './DownloadBuild'; import { InternalError } from 'common-types'; +import { ClientError } from 'graphql-request'; type launchUpdateAsyncOptions = { platform: 'android' | 'ios'; deviceId: string; + skipInstall: boolean; }; export async function launchUpdateAsync( updateURL: string, - { platform, deviceId }: launchUpdateAsyncOptions + { platform, deviceId, skipInstall }: launchUpdateAsyncOptions ) { const { manifest } = await ManifestUtils.getManifestAsync(updateURL); const appId = manifest.extra?.eas?.projectId; if (!appId) { throw new Error("Couldn't find EAS projectId in manifest"); } + let appIdentifier: string | undefined; - /** - * Fetch EAS to check if the app uses expo-dev-client - * or if we should launch the update using Expo Go - */ - const { app } = await graphqlSdk.getAppHasDevClientBuilds({ appId }); - const hasDevClientBuilds = Boolean(app.byId.hasDevClientBuilds.edges.length); - const isRuntimeCompatibleWithExpoGo = manifest.runtimeVersion.startsWith('exposdk:'); - - if (!hasDevClientBuilds && isRuntimeCompatibleWithExpoGo) { - const sdkVersion = manifest.runtimeVersion.match(/exposdk:(\d+\.\d+\.\d+)/)?.[1] || ''; - const launchOnExpoGo = - platform === 'android' ? launchUpdateOnExpoGoAndroidAsync : launchUpdateOnExpoGoIosAsync; - return await launchOnExpoGo({ - sdkVersion, - url: getExpoGoUpdateDeeplink(updateURL, manifest), - deviceId, - }); + if (skipInstall) { + if (platform === 'android') { + const device = await Emulator.getRunningDeviceAsync(deviceId); + await Emulator.openURLAsync({ url: getUpdateDeeplink(updateURL, manifest), pid: device.pid }); + } else { + await Simulator.openURLAsync({ + url: getUpdateDeeplink(updateURL, manifest), + udid: deviceId, + }); + } + return; + } + + try { + /** + * Fetch EAS to check if the app uses expo-dev-client + * or if we should launch the update using Expo Go + */ + const { app } = await graphqlSdk.getAppHasDevClientBuilds({ appId }); + const hasDevClientBuilds = Boolean(app.byId.hasDevClientBuilds.edges.length); + const isRuntimeCompatibleWithExpoGo = manifest.runtimeVersion.startsWith('exposdk:'); + + if (!hasDevClientBuilds && isRuntimeCompatibleWithExpoGo) { + const sdkVersion = manifest.runtimeVersion.match(/exposdk:(\d+\.\d+\.\d+)/)?.[1] || ''; + const launchOnExpoGo = + platform === 'android' ? launchUpdateOnExpoGoAndroidAsync : launchUpdateOnExpoGoIosAsync; + return await launchOnExpoGo({ + sdkVersion, + url: getExpoGoUpdateDeeplink(updateURL, manifest), + deviceId, + }); + } + + if ( + app.byId.hasDevClientBuilds.edges[0]?.node.__typename === 'Build' && + app.byId.hasDevClientBuilds.edges[0]?.node?.appIdentifier + ) { + appIdentifier = app.byId.hasDevClientBuilds.edges[0]?.node?.appIdentifier; + } + } catch (error) { + if (error instanceof ClientError) { + if (error.message.includes('Entity not authorized')) { + throw new InternalError( + 'UNAUTHORIZED_ACCOUNT', + `Make sure the logged in account has access to project ${appId}` + ); + } + } + throw error; } if (platform === 'android') { - await launchUpdateOnAndroidAsync(updateURL, manifest, deviceId); + await launchUpdateOnAndroidAsync(updateURL, manifest, deviceId, appIdentifier); } else { - await launchUpdateOnIOSAsync(updateURL, manifest, deviceId); + await launchUpdateOnIOSAsync(updateURL, manifest, deviceId, appIdentifier); } } @@ -80,7 +115,12 @@ async function launchUpdateOnExpoGoIosAsync({ }); } -async function launchUpdateOnAndroidAsync(updateURL: string, manifest: Manifest, deviceId: string) { +async function launchUpdateOnAndroidAsync( + updateURL: string, + manifest: Manifest, + deviceId: string, + appIdentifier?: string +) { const device = await Emulator.getRunningDeviceAsync(deviceId); await downloadAndInstallLatestDevBuildAsync({ @@ -88,11 +128,17 @@ async function launchUpdateOnAndroidAsync(updateURL: string, manifest: Manifest, manifest, platform: AppPlatform.Android, distribution: DistributionType.Internal, + appIdentifier, }); await Emulator.openURLAsync({ url: getUpdateDeeplink(updateURL, manifest), pid: device.pid }); } -async function launchUpdateOnIOSAsync(updateURL: string, manifest: Manifest, deviceId: string) { +async function launchUpdateOnIOSAsync( + updateURL: string, + manifest: Manifest, + deviceId: string, + appIdentifier?: string +) { const isSimulator = await Simulator.isSimulatorAsync(deviceId); if (!isSimulator) { throw new Error('Launching updates on iOS physical is not supported yet'); @@ -103,6 +149,7 @@ async function launchUpdateOnIOSAsync(updateURL: string, manifest: Manifest, dev manifest, platform: AppPlatform.Ios, distribution: DistributionType.Simulator, + appIdentifier, }); await Simulator.openURLAsync({ @@ -140,28 +187,84 @@ async function downloadAndInstallLatestDevBuildAsync({ manifest, platform, distribution, + appIdentifier, }: { deviceId: string; manifest: Manifest; platform: AppPlatform; distribution: DistributionType; + appIdentifier?: string; }) { - const buildArtifactsURL = await getBuildArtifactsURLForUpdateAsync({ + const build = await getBuildArtifactsForUpdateAsync({ manifest, platform, distribution, }); - const buildLocalPath = await downloadBuildAsync(buildArtifactsURL); + + if (build && build.url && !build.expired) { + const buildLocalPath = await downloadBuildAsync(build.url); + + if (platform === AppPlatform.Ios) { + await Simulator.installAppAsync(deviceId, buildLocalPath); + } else { + const device = await Emulator.getRunningDeviceAsync(deviceId); + await Emulator.installAppAsync(device, buildLocalPath); + } + return; + } + + // EAS Build not available, check if the app is installed locally + const bundleId = build?.appIdentifier ?? appIdentifier; + if (!bundleId) { + throw new NoDevBuildsError(manifest.extra?.expoClient?.name, manifest.runtimeVersion); + } if (platform === AppPlatform.Ios) { - await Simulator.installAppAsync(deviceId, buildLocalPath); + const isInstalled = await Simulator.checkIfAppIsInstalled({ + udid: deviceId, + bundleId, + }); + + // check if app is compatible with the current runtime + if (isInstalled) { + const supportsUpdate = await Simulator.checkIfAppSupportsLaunchingUpdate({ + udid: deviceId, + bundleIdentifier: bundleId, + runtimeVersion: manifest.runtimeVersion, + }); + + if (supportsUpdate) { + // App is already installed and compatible with the update runtime + return; + } + } + + throw new NoDevBuildsError(manifest.extra?.expoClient?.name, manifest.runtimeVersion); } else { const device = await Emulator.getRunningDeviceAsync(deviceId); - await Emulator.installAppAsync(device, buildLocalPath); + const isInstalled = await Emulator.checkIfAppIsInstalled({ + pid: device.pid, + bundleId, + }); + + if (isInstalled) { + const supportsUpdate = await Emulator.checkIfAppSupportsLaunchingUpdate({ + pid: device.pid, + bundleId, + runtimeVersion: manifest.runtimeVersion, + }); + + if (supportsUpdate) { + // App is already installed and compatible with the update runtime + return; + } + } + + throw new NoDevBuildsError(manifest.extra?.expoClient?.name, manifest.runtimeVersion); } } -async function getBuildArtifactsURLForUpdateAsync({ +async function getBuildArtifactsForUpdateAsync({ manifest, platform, distribution, @@ -169,26 +272,33 @@ async function getBuildArtifactsURLForUpdateAsync({ manifest: Manifest; platform: AppPlatform; distribution: DistributionType; -}): Promise { +}) { const { app } = await graphqlSdk.getAppBuildForUpdate({ - // TODO(gabrieldonadel): Add runtimeVersion filter + runtimeVersion: manifest.runtimeVersion, appId: manifest.extra?.eas?.projectId ?? '', platform, distribution, }); const build = app?.byId?.buildsPaginated?.edges?.[0]?.node; - if ( - build?.__typename === 'Build' && - build?.expirationDate && - Date.parse(build.expirationDate) > Date.now() && - build.artifacts?.buildUrl - ) { - return build.artifacts.buildUrl; + if (build?.__typename === 'Build' && build.artifacts?.buildUrl) { + return { + url: build.artifacts.buildUrl, + appIdentifier: build.appIdentifier, + expired: Date.now() > Date.parse(build.expirationDate), + }; } - throw new InternalError( - 'NO_DEVELOPMENT_BUILDS_AVAILABLE', - `No Development Builds available for ${manifest.extra?.expoClient?.name} on EAS. Please generate a new Development Build` - ); + return null; +} + +class NoDevBuildsError extends InternalError { + constructor(projectName?: string, runtimeVersion?: string) { + super( + 'NO_DEVELOPMENT_BUILDS_AVAILABLE', + `No Development Builds available for ${ + projectName ?? 'your project ' + } (Runtime version ${runtimeVersion}) on EAS.` + ); + } } diff --git a/apps/cli/src/graphql/builds.gql b/apps/cli/src/graphql/builds.gql index 4f5b420c..b2a8bc14 100644 --- a/apps/cli/src/graphql/builds.gql +++ b/apps/cli/src/graphql/builds.gql @@ -2,6 +2,7 @@ query getAppBuildForUpdate( $appId: String! $platform: AppPlatform! $distribution: DistributionType! + $runtimeVersion: String ) { app { byId(appId: $appId) { @@ -9,13 +10,19 @@ query getAppBuildForUpdate( name buildsPaginated( first: 1 - filter: { platforms: [$platform], distributions: [$distribution], developmentClient: true } + filter: { + platforms: [$platform] + distributions: [$distribution] + developmentClient: true + runtimeVersion: $runtimeVersion + } ) { edges { node { __typename id ... on Build { + appIdentifier runtimeVersion expirationDate artifacts { @@ -37,7 +44,11 @@ query getAppHasDevClientBuilds($appId: String!) { hasDevClientBuilds: buildsPaginated(first: 1, filter: { developmentClient: true }) { edges { node { + __typename id + ... on Build { + appIdentifier + } } } } diff --git a/apps/cli/src/graphql/generated/graphql.ts b/apps/cli/src/graphql/generated/graphql.ts index eaab2bb3..249df180 100644 --- a/apps/cli/src/graphql/generated/graphql.ts +++ b/apps/cli/src/graphql/generated/graphql.ts @@ -147,6 +147,8 @@ export type Account = { userInvitations: Array; /** Actors associated with this account and permissions they hold */ users: Array; + /** Permission info for the viewer on this account */ + viewerUserPermission: UserPermission; /** @deprecated Build packs are no longer supported */ willAutoRenewBuilds?: Maybe; }; @@ -755,6 +757,7 @@ export type AndroidAppCredentials = { androidFcm?: Maybe; app: App; applicationIdentifier?: Maybe; + googleServiceAccountKeyForFcmV1?: Maybe; googleServiceAccountKeyForSubmissions?: Maybe; id: Scalars['ID']['output']; isLegacy: Scalars['Boolean']['output']; @@ -767,6 +770,7 @@ export type AndroidAppCredentialsFilter = { export type AndroidAppCredentialsInput = { fcmId?: InputMaybe; + googleServiceAccountKeyForFcmV1Id?: InputMaybe; googleServiceAccountKeyForSubmissionsId?: InputMaybe; }; @@ -774,10 +778,17 @@ export type AndroidAppCredentialsMutation = { __typename?: 'AndroidAppCredentialsMutation'; /** Create a set of credentials for an Android app */ createAndroidAppCredentials: AndroidAppCredentials; + /** + * Create a GoogleServiceAccountKeyEntity to store credential and + * connect it with an edge from AndroidAppCredential + */ + createFcmV1Credential: AndroidAppCredentials; /** Delete a set of credentials for an Android app */ deleteAndroidAppCredentials: DeleteAndroidAppCredentialsResult; /** Set the FCM push key to be used in an Android app */ setFcm: AndroidAppCredentials; + /** Set the Google Service Account Key to be used for Firebase Cloud Messaging V1 */ + setGoogleServiceAccountKeyForFcmV1: AndroidAppCredentials; /** Set the Google Service Account Key to be used for submitting an Android app */ setGoogleServiceAccountKeyForSubmissions: AndroidAppCredentials; }; @@ -790,6 +801,13 @@ export type AndroidAppCredentialsMutationCreateAndroidAppCredentialsArgs = { }; +export type AndroidAppCredentialsMutationCreateFcmV1CredentialArgs = { + accountId: Scalars['ID']['input']; + androidAppCredentialsId: Scalars['String']['input']; + credential: Scalars['String']['input']; +}; + + export type AndroidAppCredentialsMutationDeleteAndroidAppCredentialsArgs = { id: Scalars['ID']['input']; }; @@ -801,6 +819,12 @@ export type AndroidAppCredentialsMutationSetFcmArgs = { }; +export type AndroidAppCredentialsMutationSetGoogleServiceAccountKeyForFcmV1Args = { + googleServiceAccountKeyId: Scalars['ID']['input']; + id: Scalars['ID']['input']; +}; + + export type AndroidAppCredentialsMutationSetGoogleServiceAccountKeyForSubmissionsArgs = { googleServiceAccountKeyId: Scalars['ID']['input']; id: Scalars['ID']['input']; @@ -1047,6 +1071,7 @@ export type App = Project & { id: Scalars['ID']['output']; /** App query field for querying EAS Insights about this app */ insights: AppInsights; + internalDistributionBuildPrivacy: AppInternalDistributionBuildPrivacy; /** iOS app credentials for the project */ iosAppCredentials: Array; isDeleting: Scalars['Boolean']['output']; @@ -1372,6 +1397,7 @@ export type AppChannelsConnection = { export type AppDataInput = { id: Scalars['ID']['input']; + internalDistributionBuildPrivacy?: InputMaybe; privacy?: InputMaybe; }; @@ -1418,6 +1444,11 @@ export type AppInsightsUniqueUsersByPlatformOverTimeArgs = { timespan: InsightsTimespan; }; +export enum AppInternalDistributionBuildPrivacy { + Private = 'PRIVATE', + Public = 'PUBLIC' +} + export type AppMutation = { __typename?: 'AppMutation'; /** Create an unpublished app */ @@ -2084,6 +2115,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & { actor?: Maybe; app: App; appBuildVersion?: Maybe; + appIdentifier?: Maybe; appVersion?: Maybe; artifacts?: Maybe; buildMode?: Maybe; @@ -2113,6 +2145,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & { /** @deprecated User type is deprecated */ initiatingUser?: Maybe; iosEnterpriseProvisioning?: Maybe; + isForIosSimulator: Scalars['Boolean']['output']; isGitWorkingTreeDirty?: Maybe; isWaived: Scalars['Boolean']['output']; logFiles: Array; @@ -2125,6 +2158,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & { platform: AppPlatform; priority: BuildPriority; project: Project; + projectMetadataFileUrl?: Maybe; projectRootDirectory?: Maybe; provisioningStartedAt?: Maybe; /** Queue position is 1-indexed */ @@ -2278,6 +2312,7 @@ export type BuildFilter = { platform?: InputMaybe; runtimeVersion?: InputMaybe; sdkVersion?: InputMaybe; + simulator?: InputMaybe; status?: InputMaybe; }; @@ -2287,6 +2322,8 @@ export type BuildFilterInput = { distributions?: InputMaybe>; platforms?: InputMaybe>; releaseChannel?: InputMaybe; + runtimeVersion?: InputMaybe; + simulator?: InputMaybe; }; export enum BuildIosEnterpriseProvisioning { @@ -2404,6 +2441,7 @@ export type BuildMetadataInput = { runtimeVersion?: InputMaybe; sdkVersion?: InputMaybe; selectedImage?: InputMaybe; + simulator?: InputMaybe; trackingContext?: InputMaybe; username?: InputMaybe; workflow?: InputMaybe; @@ -2584,6 +2622,7 @@ export type BuildPublicData = { artifacts: PublicArtifacts; distribution?: Maybe; id: Scalars['ID']['output']; + isForIosSimulator: Scalars['Boolean']['output']; platform: AppPlatform; project: ProjectPublicData; status: BuildStatus; @@ -2787,6 +2826,7 @@ export type CreateGitHubBuildTriggerInput = { platform: AppPlatform; /** A branch or tag name, or a wildcard pattern where the code change originates from. For example, `main` or `release/*`. */ sourcePattern: Scalars['String']['input']; + submitProfile?: InputMaybe; /** A branch name or a wildcard pattern that the pull request targets. For example, `main` or `release/*`. */ targetPattern?: InputMaybe; type: GitHubBuildTriggerType; @@ -3367,6 +3407,7 @@ export type GitHubBuildTrigger = { lastRunStatus?: Maybe; platform: AppPlatform; sourcePattern: Scalars['String']['output']; + submitProfile?: Maybe; targetPattern?: Maybe; type: GitHubBuildTriggerType; updatedAt: Scalars['DateTime']['output']; @@ -3412,6 +3453,7 @@ export enum GitHubBuildTriggerType { export type GitHubRepository = { __typename?: 'GitHubRepository'; app: App; + createdAt: Scalars['DateTime']['output']; githubAppInstallation: GitHubAppInstallation; githubRepositoryIdentifier: Scalars['Int']['output']; githubRepositoryUrl?: Maybe; @@ -3930,11 +3972,13 @@ export type LineDataset = { }; export enum MailchimpAudience { - ExpoDevelopers = 'EXPO_DEVELOPERS' + ExpoDevelopers = 'EXPO_DEVELOPERS', + ExpoDeveloperOnboarding = 'EXPO_DEVELOPER_ONBOARDING' } export enum MailchimpTag { DevClientUsers = 'DEV_CLIENT_USERS', + DidSubscribeToEasAtLeastOnce = 'DID_SUBSCRIBE_TO_EAS_AT_LEAST_ONCE', EasMasterList = 'EAS_MASTER_LIST', NewsletterSignupList = 'NEWSLETTER_SIGNUP_LIST' } @@ -4102,9 +4146,11 @@ export type Notification = { export enum NotificationEvent { BuildComplete = 'BUILD_COMPLETE', + BuildErrored = 'BUILD_ERRORED', BuildLimitThresholdExceeded = 'BUILD_LIMIT_THRESHOLD_EXCEEDED', BuildPlanCreditThresholdExceeded = 'BUILD_PLAN_CREDIT_THRESHOLD_EXCEEDED', SubmissionComplete = 'SUBMISSION_COMPLETE', + SubmissionErrored = 'SUBMISSION_ERRORED', Test = 'TEST' } @@ -4249,6 +4295,7 @@ export type Project = { export type ProjectArchiveSourceInput = { bucketKey?: InputMaybe; gitRef?: InputMaybe; + metadataLocation?: InputMaybe; repositoryUrl?: InputMaybe; type: ProjectArchiveSourceType; url?: InputMaybe; @@ -4998,6 +5045,8 @@ export type Submission = ActivityTimelineProjectActivity & { id: Scalars['ID']['output']; initiatingActor?: Maybe; iosConfig?: Maybe; + logFiles: Array; + /** @deprecated Use logFiles instead */ logsUrl?: Maybe; /** Retry time starts after completedAt */ maxRetryTimeMinutes: Scalars['Int']['output']; @@ -5330,6 +5379,7 @@ export type UpdateGitHubBuildTriggerInput = { isActive: Scalars['Boolean']['input']; platform: AppPlatform; sourcePattern: Scalars['String']['input']; + submitProfile?: InputMaybe; targetPattern?: InputMaybe; type: GitHubBuildTriggerType; }; @@ -5398,6 +5448,7 @@ export type UploadSessionCreateUploadSessionArgs = { }; export enum UploadSessionType { + EasBuildGcsProjectMetadata = 'EAS_BUILD_GCS_PROJECT_METADATA', EasBuildGcsProjectSources = 'EAS_BUILD_GCS_PROJECT_SOURCES', /** @deprecated Use EAS_BUILD_GCS_PROJECT_SOURCES instead. */ EasBuildProjectSources = 'EAS_BUILD_PROJECT_SOURCES', @@ -5997,34 +6048,36 @@ export type GetAppBuildForUpdateQueryVariables = Exact<{ appId: Scalars['String']['input']; platform: AppPlatform; distribution: DistributionType; + runtimeVersion?: InputMaybe; }>; -export type GetAppBuildForUpdateQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, name: string, buildsPaginated: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename: 'Build', runtimeVersion?: string | null, expirationDate?: any | null, id: string, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null } | null } | { __typename: 'BuildJob', id: string } }> } } } }; +export type GetAppBuildForUpdateQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, name: string, buildsPaginated: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename: 'Build', appIdentifier?: string | null, runtimeVersion?: string | null, expirationDate?: any | null, id: string, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null } | null } | { __typename: 'BuildJob', id: string } }> } } } }; export type GetAppHasDevClientBuildsQueryVariables = Exact<{ appId: Scalars['String']['input']; }>; -export type GetAppHasDevClientBuildsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, name: string, hasDevClientBuilds: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename?: 'Build', id: string } | { __typename?: 'BuildJob', id: string } }> } } } }; +export type GetAppHasDevClientBuildsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, name: string, hasDevClientBuilds: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename: 'Build', appIdentifier?: string | null, id: string } | { __typename: 'BuildJob', id: string } }> } } } }; export const GetAppBuildForUpdateDocument = gql` - query getAppBuildForUpdate($appId: String!, $platform: AppPlatform!, $distribution: DistributionType!) { + query getAppBuildForUpdate($appId: String!, $platform: AppPlatform!, $distribution: DistributionType!, $runtimeVersion: String) { app { byId(appId: $appId) { id name buildsPaginated( first: 1 - filter: {platforms: [$platform], distributions: [$distribution], developmentClient: true} + filter: {platforms: [$platform], distributions: [$distribution], developmentClient: true, runtimeVersion: $runtimeVersion} ) { edges { node { __typename id ... on Build { + appIdentifier runtimeVersion expirationDate artifacts { @@ -6047,7 +6100,11 @@ export const GetAppHasDevClientBuildsDocument = gql` hasDevClientBuilds: buildsPaginated(first: 1, filter: {developmentClient: true}) { edges { node { + __typename id + ... on Build { + appIdentifier + } } } } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 839cb50a..3612ab9a 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -55,6 +55,7 @@ program .argument('', 'Update URL') .requiredOption('-p, --platform ', 'Selected platform') .requiredOption('--device-id ', 'UDID or name of the device') + .option('--skip-install', 'Skip app installation') .action(async (...args) => { const { launchUpdateAsync } = await import('./commands/LaunchUpdate'); returnLoggerMiddleware(launchUpdateAsync)(...args); diff --git a/apps/menu-bar/src/commands/launchUpdateAsync.ts b/apps/menu-bar/src/commands/launchUpdateAsync.ts index 83fc20d7..9487f1eb 100644 --- a/apps/menu-bar/src/commands/launchUpdateAsync.ts +++ b/apps/menu-bar/src/commands/launchUpdateAsync.ts @@ -5,26 +5,28 @@ type LaunchUpdateAsyncOptions = { platform: 'android' | 'ios'; deviceId: string; url: string; + noInstall?: boolean; }; type LaunchUpdateCallback = (status: MenuBarStatus, progress: number) => void; export async function launchUpdateAsync( - { url, platform, deviceId }: LaunchUpdateAsyncOptions, + { url, platform, deviceId, noInstall }: LaunchUpdateAsyncOptions, callback: LaunchUpdateCallback ) { - await MenuBarModule.runCli( - 'launch-update', - [url, '-p', platform, '--device-id', deviceId], - (output) => { - if (output.includes('Downloading app')) { - callback(MenuBarStatus.DOWNLOADING, extractDownloadProgress(output)); - } else if (output.includes('Installing your app')) { - callback(MenuBarStatus.INSTALLING_APP, 0); - } else if (output.includes('Opening url')) { - callback(MenuBarStatus.OPENING_UPDATE, 0); - } - // Add other conditions for different status updates as needed + const args = [url, '-p', platform, '--device-id', deviceId]; + if (noInstall) { + args.push('--skip-install'); + } + + await MenuBarModule.runCli('launch-update', args, (output) => { + if (output.includes('Downloading app')) { + callback(MenuBarStatus.DOWNLOADING, extractDownloadProgress(output)); + } else if (output.includes('Installing your app')) { + callback(MenuBarStatus.INSTALLING_APP, 0); + } else if (output.includes('Opening url')) { + callback(MenuBarStatus.OPENING_UPDATE, 0); } - ); + // Add other conditions for different status updates as needed + }); } diff --git a/apps/menu-bar/src/popover/Core.tsx b/apps/menu-bar/src/popover/Core.tsx index ef7b721c..c3b493bf 100644 --- a/apps/menu-bar/src/popover/Core.tsx +++ b/apps/menu-bar/src/popover/Core.tsx @@ -31,6 +31,8 @@ import { SelectedDevicesIds, getSelectedDevicesIds, saveSelectedDevicesIds, + sessionSecretStorageKey, + storage, } from '../modules/Storage'; import { useListDevices } from '../providers/DevicesProvider'; import { getDeviceId, getDeviceOS, isVirtualDevice } from '../utils/device'; @@ -201,6 +203,13 @@ function Core(props: Props) { const handleUpdateUrl = useCallback( async (url: string) => { + if (!storage.getString(sessionSecretStorageKey)) { + Alert.alert( + 'You need to be logged in to launch updates.', + 'Log in through the Settings window and try again.' + ); + return; + } /** * Supports any update manifest url as long as the * platform is specified in the query params. @@ -224,6 +233,7 @@ function Core(props: Props) { try { setStatus(MenuBarStatus.BOOTING_DEVICE); await ensureDeviceIsRunning(device); + setStatus(MenuBarStatus.OPENING_UPDATE); await launchUpdateAsync( { url, @@ -238,8 +248,38 @@ function Core(props: Props) { } ); } catch (error) { - if (error instanceof InternalError || error instanceof Error) { - Alert.alert('Something went wrong', error.message); + if (error instanceof Error) { + if (error instanceof InternalError && error.code === 'NO_DEVELOPMENT_BUILDS_AVAILABLE') { + Alert.alert( + 'Unable to find a compatible development build', + `${error.message} Either create a new development build with EAS Build or, if the app is already installed on the target device and uses the correct runtime version, you can launch the update using a deep link.`, + [ + { text: 'OK', onPress: () => {} }, + { + text: 'Launch with deep link', + onPress: async () => { + setStatus(MenuBarStatus.OPENING_UPDATE); + await launchUpdateAsync( + { + url, + deviceId: getDeviceId(device), + platform: getDeviceOS(device), + noInstall: true, + }, + (status) => { + setStatus(status); + } + ); + setTimeout(() => { + setStatus(MenuBarStatus.LISTENING); + }, 2000); + }, + }, + ] + ); + } else { + Alert.alert('Something went wrong', error.message); + } } console.log(`error: ${JSON.stringify(error)}`); } finally { diff --git a/packages/common-types/src/InternalError.ts b/packages/common-types/src/InternalError.ts index 20352a3b..c38537eb 100644 --- a/packages/common-types/src/InternalError.ts +++ b/packages/common-types/src/InternalError.ts @@ -32,7 +32,8 @@ export type InternalErrorCode = | 'XCODE_LICENSE_NOT_ACCEPTED' | 'XCODE_NOT_INSTALLED' | 'SIMCTL_NOT_AVAILABLE' - | 'NO_DEVELOPMENT_BUILDS_AVAILABLE'; + | 'NO_DEVELOPMENT_BUILDS_AVAILABLE' + | 'UNAUTHORIZED_ACCOUNT'; export type MultipleAppsInTarballErrorDetails = { apps: Array<{ diff --git a/packages/eas-shared/src/run/android/emulator.ts b/packages/eas-shared/src/run/android/emulator.ts index b2c89e73..adfc4e45 100644 --- a/packages/eas-shared/src/run/android/emulator.ts +++ b/packages/eas-shared/src/run/android/emulator.ts @@ -7,12 +7,15 @@ import path from 'path'; import { execFileSync } from 'child_process'; import semver from 'semver'; import { AndroidConnectedDevice, AndroidEmulator } from 'common-types/build/devices'; +import fs from 'fs-extra'; import * as Versions from '../../versions'; import Log from '../../log'; import { adbAsync, isEmulatorBootedAsync, waitForEmulatorToBeBootedAsync } from './adb'; import { getAndroidSdkRootAsync } from './sdk'; import { downloadApkAsync } from '../../downloadApkAsync'; +import { getTmpDirectory } from '../../paths'; +import { tarExtractAsync } from '../../download'; const BEGINNING_OF_ADB_ERROR_MESSAGE = 'error: '; const INSTALL_WARNING_TIMEOUT = 60 * 1000; @@ -184,6 +187,66 @@ export async function checkIfAppIsInstalled({ return false; } +export async function checkIfAppSupportsLaunchingUpdate({ + pid, + bundleId, + runtimeVersion, +}: { + pid: string; + bundleId: string; + runtimeVersion: string; +}): Promise { + try { + const noBackupContent = await getAdbOutputAsync([ + '-s', + pid, + 'shell', + `run-as ${bundleId} ls no_backup`, + ]); + + // Check if app includes dev-launcher + if (!noBackupContent.includes('expo-dev-launcher-installation-id.txt')) { + return false; + } + + const apkPathOutput = await getAdbOutputAsync(['-s', pid, 'shell', 'pm', 'path', bundleId]); + // copy the base apk + const baseApkPath = path.join(_apksCacheDirectory(), `${bundleId}.apk`); + await adbAsync( + '-s', + pid, + 'pull', + apkPathOutput.substring('package:'.length).trim(), + baseApkPath + ); + + const extractedApkFolder = path.join(_apksCacheDirectory(), `${bundleId}-${runtimeVersion}`); + fs.mkdirpSync(extractedApkFolder); + await tarExtractAsync(baseApkPath, extractedApkFolder); + + const config = JSON.parse( + await fs.readFile(path.join(extractedApkFolder, 'assets', 'app.config'), 'utf8') + ); + + if ( + typeof config.runtimeVersion === 'object' && + config.runtimeVersion.policy === 'sdkVersion' + ) { + return `exposdk:${config.sdkVersion}` === runtimeVersion; + } + return config.runtimeVersion === runtimeVersion; + } catch (error) { + console.log('error', error); + return false; + } +} + +function _apksCacheDirectory() { + const dir = path.join(getTmpDirectory(), 'apks'); + fs.mkdirpSync(dir); + return dir; +} + export async function getAdbOutputAsync(args: string[]): Promise { try { const result = await adbAsync(...args); diff --git a/packages/eas-shared/src/run/ios/simulator.ts b/packages/eas-shared/src/run/ios/simulator.ts index 8286697b..defae005 100644 --- a/packages/eas-shared/src/run/ios/simulator.ts +++ b/packages/eas-shared/src/run/ios/simulator.ts @@ -293,6 +293,39 @@ async function getClientForSDK(sdkVersionString?: string) { }; } +export async function checkIfAppSupportsLaunchingUpdate({ + udid, + bundleIdentifier, + runtimeVersion, +}: { + udid: string; + bundleIdentifier: string; + runtimeVersion: string; +}): Promise { + try { + const localPath = await getContainerPathAsync({ + udid, + bundleIdentifier, + }); + if (!localPath) { + return false; + } + + const devLauncherPath = path.join(localPath, 'EXDevLauncher.bundle'); + if (!(await fs.pathExists(devLauncherPath))) { + return false; + } + + const expoInfoPlistPath = path.join(localPath, 'Expo.plist'); + const { EXUpdatesRuntimeVersion }: { EXUpdatesRuntimeVersion: string } = + await parseBinaryPlistAsync(expoInfoPlistPath); + + return EXUpdatesRuntimeVersion === runtimeVersion; + } catch (error) { + return false; + } +} + export async function expoSDKSupportedVersionsOnSimulatorAsync( udid: string ): Promise {