Skip to content

Commit

Permalink
[menu-bar][cli] Add basic support for launching Expo updates (#134)
Browse files Browse the repository at this point in the history
* [eas-shared] Add checkIfAppIsInstalled function

* [eas-shared] Add @expo/multipart-body-parser and expo-manifests

* [eas-shared] Add manifest parser

* [cli] Add launch-update command

* [eas-shared] Remove expo go workaround from openURLAsync

* [menu-bar] Add support for launching updates

* Add getRunningAndroidDevice helper function

* Add isSimulatorAsync helper function

* Adjust launching logic

* Add changelog entry

* Apply suggestions from code review

Co-authored-by: Alan Hughes <30924086+alanjhughes@users.noreply.github.com>

* [menu-bar] Improve status feedback while opening an update

---------

Co-authored-by: Alan Hughes <30924086+alanjhughes@users.noreply.github.com>
  • Loading branch information
gabrieldonadel and alanjhughes authored Jan 19, 2024
1 parent 38afc2b commit f154095
Show file tree
Hide file tree
Showing 23 changed files with 614 additions and 97 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

### 🎉 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) by [@gabrieldonadel](https://github.com/gabrieldonadel))

### 🐛 Bug fixes

### 💡 Others
Expand Down
10 changes: 2 additions & 8 deletions apps/cli/src/commands/InstallAndLaunchApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ export async function installAndLaunchAppAsync(options: InstallAndLaunchAppAsync
}

async function installAndLaunchIOSAppAsync(appPath: string, deviceId: string) {
if (
(await Simulator.getAvailableIosSimulatorsListAsync()).find(({ udid }) => udid === deviceId)
) {
if (await Simulator.isSimulatorAsync(deviceId)) {
const bundleIdentifier = await Simulator.getAppBundleIdentifierAsync(appPath);
await Simulator.installAppAsync(deviceId, appPath);
await Simulator.launchAppAsync(deviceId, bundleIdentifier);
Expand All @@ -40,11 +38,7 @@ async function installAndLaunchIOSAppAsync(appPath: string, deviceId: string) {
}

async function installAndLaunchAndroidAppAsync(appPath: string, deviceId: string) {
const runningDevices = await Emulator.getRunningDevicesAsync();
const device = runningDevices.find(({ name }) => name === deviceId);
if (!device) {
throw new Error(`Device or Emulator ${deviceId} is not running`);
}
const device = await Emulator.getRunningDeviceAsync(deviceId);

await Emulator.installAppAsync(device, appPath);
const { packageName, activityName } = await Emulator.getAptParametersAsync(appPath);
Expand Down
14 changes: 4 additions & 10 deletions apps/cli/src/commands/LaunchSnack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,14 @@ export async function launchSnackAsync(
}

async function launchSnackOnAndroidAsync(snackURL: string, deviceId: string, version?: string) {
const runningEmulators = await Emulator.getRunningDevicesAsync();
const emulator = runningEmulators.find(({ name }) => name === deviceId);
if (!emulator?.pid) {
throw new Error(`No running emulator with name ${deviceId}`);
}
const device = await Emulator.getRunningDeviceAsync(deviceId);

await Emulator.ensureExpoClientInstalledAsync(emulator.pid, version);
await Emulator.openURLAsync({ url: snackURL, pid: emulator.pid });
await Emulator.ensureExpoClientInstalledAsync(device.pid, version);
await Emulator.openURLAsync({ url: snackURL, pid: device.pid });
}

async function launchSnackOnIOSAsync(snackURL: string, deviceId: string, version?: string) {
if (
(await Simulator.getAvailableIosSimulatorsListAsync()).find(({ udid }) => udid === deviceId)
) {
if (await Simulator.isSimulatorAsync(deviceId)) {
await Simulator.ensureExpoClientInstalledAsync(deviceId, version);
await Simulator.openURLAsync({
url: snackURL,
Expand Down
192 changes: 192 additions & 0 deletions apps/cli/src/commands/LaunchUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { Emulator, Simulator, ManifestUtils, Manifest } from 'eas-shared';

import { graphqlSdk } from '../api/GraphqlClient';
import { AppPlatform, DistributionType } from '../graphql/generated/graphql';
import { downloadBuildAsync } from './DownloadBuild';

type launchUpdateAsyncOptions = {
platform: 'android' | 'ios';
deviceId: string;
};

export async function launchUpdateAsync(
updateURL: string,
{ platform, deviceId }: 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");
}

/**
* 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 (platform === 'android') {
await launchUpdateOnAndroidAsync(updateURL, manifest, deviceId);
} else {
await launchUpdateOnIOSAsync(updateURL, manifest, deviceId);
}
}

type LaunchUpdateOnExpoGoOptions = {
deviceId: string;
url: string;
sdkVersion: string;
};

async function launchUpdateOnExpoGoAndroidAsync({
sdkVersion,
deviceId,
url,
}: LaunchUpdateOnExpoGoOptions) {
const device = await Emulator.getRunningDeviceAsync(deviceId);

await Emulator.ensureExpoClientInstalledAsync(device.pid, sdkVersion);
await Emulator.openURLAsync({ url, pid: device.pid });
}

async function launchUpdateOnExpoGoIosAsync({
sdkVersion,
deviceId,
url,
}: LaunchUpdateOnExpoGoOptions) {
const isSimulator = await Simulator.isSimulatorAsync(deviceId);
if (!isSimulator) {
throw new Error('Launching updates on iOS physical devices is not supported yet');
}

await Simulator.ensureExpoClientInstalledAsync(deviceId, sdkVersion);
await Simulator.openURLAsync({
url,
udid: deviceId,
});
}

async function launchUpdateOnAndroidAsync(updateURL: string, manifest: Manifest, deviceId: string) {
const device = await Emulator.getRunningDeviceAsync(deviceId);

await downloadAndInstallLatestDevBuildAsync({
deviceId,
manifest,
platform: AppPlatform.Android,
distribution: DistributionType.Internal,
});
await Emulator.openURLAsync({ url: getUpdateDeeplink(updateURL, manifest), pid: device.pid });
}

async function launchUpdateOnIOSAsync(updateURL: string, manifest: Manifest, deviceId: string) {
const isSimulator = await Simulator.isSimulatorAsync(deviceId);
if (!isSimulator) {
throw new Error('Launching updates on iOS physical is not supported yet');
}

await downloadAndInstallLatestDevBuildAsync({
deviceId,
manifest,
platform: AppPlatform.Ios,
distribution: DistributionType.Simulator,
});

await Simulator.openURLAsync({
url: getUpdateDeeplink(updateURL, manifest),
udid: deviceId,
});
}

function getExpoGoUpdateDeeplink(updateURL: string, manifest: Manifest) {
if (updateURL.startsWith('https://u.expo.dev')) {
return `exp://u.expo.dev/update/${manifest.id}`;
}
return updateURL.replace('https://', 'exp://');
}

function getUpdateDeeplink(updateURL: string, manifest: Manifest) {
const updateIdURL = updateURL.startsWith('https://u.expo.dev')
? `https://u.expo.dev/update/${manifest.id}`
: updateURL;

const scheme = Array.isArray(manifest?.extra?.expoClient?.scheme)
? manifest?.extra?.expoClient?.scheme[0]
: manifest?.extra?.expoClient?.scheme;
const slug = manifest?.extra?.expoClient?.slug;

if (!scheme && !slug) {
throw new Error('Unable to resolve schema from manifest');
}

return `${scheme || `exp+${slug}`}://expo-development-client/?url=${updateIdURL}`;
}

async function downloadAndInstallLatestDevBuildAsync({
deviceId,
manifest,
platform,
distribution,
}: {
deviceId: string;
manifest: Manifest;
platform: AppPlatform;
distribution: DistributionType;
}) {
const buildArtifactsURL = await getBuildArtifactsURLForUpdateAsync({
manifest,
platform,
distribution,
});
const buildLocalPath = await downloadBuildAsync(buildArtifactsURL);

if (platform === AppPlatform.Ios) {
await Simulator.installAppAsync(deviceId, buildLocalPath);
} else {
const device = await Emulator.getRunningDeviceAsync(deviceId);
await Emulator.installAppAsync(device, buildLocalPath);
}
}

async function getBuildArtifactsURLForUpdateAsync({
manifest,
platform,
distribution,
}: {
manifest: Manifest;
platform: AppPlatform;
distribution: DistributionType;
}): Promise<string> {
const { app } = await graphqlSdk.getAppBuildForUpdate({
// TODO(gabrieldonadel): Add runtimeVersion filter
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;
}

throw new Error(
`No Development Builds available for ${manifest.extra?.expoClient?.name}. Please generate a new build`
);
}
18 changes: 17 additions & 1 deletion apps/cli/src/graphql/builds.gql
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ query getAppBuildForUpdate(
name
buildsPaginated(
first: 1
filter: { platforms: [$platform], distributions: [$distribution] }
filter: { platforms: [$platform], distributions: [$distribution], developmentClient: true }
) {
edges {
node {
Expand All @@ -28,3 +28,19 @@ query getAppBuildForUpdate(
}
}
}

query getAppHasDevClientBuilds($appId: String!) {
app {
byId(appId: $appId) {
id
name
hasDevClientBuilds: buildsPaginated(first: 1, filter: { developmentClient: true }) {
edges {
node {
id
}
}
}
}
}
}
31 changes: 29 additions & 2 deletions apps/cli/src/graphql/generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6002,6 +6002,13 @@ export type GetAppBuildForUpdateQueryVariables = Exact<{

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 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 const GetAppBuildForUpdateDocument = gql`
query getAppBuildForUpdate($appId: String!, $platform: AppPlatform!, $distribution: DistributionType!) {
Expand All @@ -6011,12 +6018,12 @@ export const GetAppBuildForUpdateDocument = gql`
name
buildsPaginated(
first: 1
filter: {platforms: [$platform], distributions: [$distribution]}
filter: {platforms: [$platform], distributions: [$distribution], developmentClient: true}
) {
edges {
node {
id
__typename
id
... on Build {
runtimeVersion
expirationDate
Expand All @@ -6031,6 +6038,23 @@ export const GetAppBuildForUpdateDocument = gql`
}
}
`;
export const GetAppHasDevClientBuildsDocument = gql`
query getAppHasDevClientBuilds($appId: String!) {
app {
byId(appId: $appId) {
id
name
hasDevClientBuilds: buildsPaginated(first: 1, filter: {developmentClient: true}) {
edges {
node {
id
}
}
}
}
}
}
`;

export type SdkFunctionWrapper = <T>(action: (requestHeaders?:Record<string, string>) => Promise<T>, operationName: string, operationType?: string, variables?: any) => Promise<T>;

Expand All @@ -6041,6 +6065,9 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper =
return {
getAppBuildForUpdate(variables: GetAppBuildForUpdateQueryVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise<GetAppBuildForUpdateQuery> {
return withWrapper((wrappedRequestHeaders) => client.request<GetAppBuildForUpdateQuery>(GetAppBuildForUpdateDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'getAppBuildForUpdate', 'query', variables);
},
getAppHasDevClientBuilds(variables: GetAppHasDevClientBuildsQueryVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise<GetAppHasDevClientBuildsQuery> {
return withWrapper((wrappedRequestHeaders) => client.request<GetAppHasDevClientBuildsQuery>(GetAppHasDevClientBuildsDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'getAppHasDevClientBuilds', 'query', variables);
}
};
}
Expand Down
8 changes: 8 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { bootDeviceAsync } from './commands/BootDevice';
import { installAndLaunchAppAsync } from './commands/InstallAndLaunchApp';
import { launchSnackAsync } from './commands/LaunchSnack';
import { checkToolsAsync } from './commands/CheckTools';
import { launchUpdateAsync } from './commands/LaunchUpdate';
import { returnLoggerMiddleware } from './utils';

const program = new Command();
Expand Down Expand Up @@ -49,6 +50,13 @@ program
.option('-p, --platform <string>', 'Selected platform')
.action(returnLoggerMiddleware(checkToolsAsync));

program
.command('launch-update')
.argument('<string>', 'Update URL')
.requiredOption('-p, --platform <string>', 'Selected platform')
.requiredOption('--device-id <string>', 'UDID or name of the device')
.action(returnLoggerMiddleware(launchUpdateAsync));

if (process.argv.length < 3) {
program.help();
}
Expand Down
15 changes: 1 addition & 14 deletions apps/menu-bar/src/commands/downloadBuildAsync.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import MenuBarModule from '../modules/MenuBarModule';
import { extractDownloadProgress } from '../utils/helpers';

export async function downloadBuildAsync(
url: string,
Expand All @@ -8,17 +9,3 @@ export async function downloadBuildAsync(
progressCallback(extractDownloadProgress(status));
});
}

function extractDownloadProgress(string: string) {
const regex = /(\d+(?:\.\d+)?) MB \/ (\d+(?:\.\d+)?) MB/;
const matches = string.match(regex);

if (matches && matches.length === 3) {
const currentSize = parseFloat(matches[1]);
const totalSize = parseFloat(matches[2]);
const progress = (currentSize / totalSize) * 100;
return progress;
}

return 0;
}
Loading

0 comments on commit f154095

Please sign in to comment.