Skip to content

Commit

Permalink
[menu-bar] Add support for launching updates
Browse files Browse the repository at this point in the history
  • Loading branch information
gabrieldonadel committed Jan 16, 2024
1 parent 6a7bbcb commit 0b804e8
Show file tree
Hide file tree
Showing 5 changed files with 197 additions and 42 deletions.
55 changes: 52 additions & 3 deletions apps/cli/src/commands/LaunchUpdate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Emulator, Simulator, AppleDevice } from 'eas-shared';
import { ManifestUtils, Manifest } from 'eas-shared';
import { graphqlSdk } from '../api/GraphqlClient';
import { AppPlatform, DistributionType } from '../graphql/generated/graphql';
import { downloadBuildAsync } from './DownloadBuild';
import { installAndLaunchAppAsync } from './InstallAndLaunchApp';

type launchUpdateAsyncOptions = {
platform: 'android' | 'ios';
Expand Down Expand Up @@ -31,7 +35,8 @@ async function launchUpdateOnAndroidAsync(updateURL: string, manifest: Manifest,
if (bundleId) {
const isAppInstalled = await Emulator.checkIfAppIsInstalled({ pid: emulator.pid, bundleId });
if (!isAppInstalled) {
// Find latest dev build on EAS
// check if runtimeVersion is compatible with Expo Go: e.g. "runtimeVersion":"exposdk:50.0.0"
// else Find latest dev build on EAS
}
} else {
const version = manifest.extra?.expoClient?.sdkVersion;
Expand Down Expand Up @@ -62,6 +67,17 @@ async function launchUpdateOnIOSSimulatorAsync(
const isAppInstalled = await Simulator.checkIfAppIsInstalled({ udid: deviceId, bundleId });
if (!isAppInstalled) {
// Find latest dev build on EAS
const buildArtifactsURL = await getBuildArtifactsURLForUpdateAsync({
manifest,
platform: AppPlatform.Ios,
distribution: DistributionType.Simulator,
});
if (buildArtifactsURL) {
const buildLocalPath = await downloadBuildAsync(buildArtifactsURL);
await installAndLaunchAppAsync({ appPath: buildLocalPath, deviceId });
} else {
throw new Error(`No build artifacts found for ${manifest.id}`);
}
}
} else {
const version = manifest.extra?.expoClient?.sdkVersion;
Expand Down Expand Up @@ -95,14 +111,47 @@ async function launchUpdateOnIOSDeviceAsync(
}

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;
scheme;

if (scheme) {
return `${scheme}://expo-development-client/?url=${updateURL}`;
return `${scheme}://expo-development-client/?url=${updateIdURL}`;
}

return updateIdURL.replace('https://', 'exp://');
}

async function getBuildArtifactsURLForUpdateAsync({
manifest,
platform,
distribution,
}: {
manifest: Manifest;
platform: AppPlatform;
distribution: DistributionType;
}): Promise<string | null> {
const { app } = await graphqlSdk.getAppBuildForUpdate({
appId: manifest.extra?.eas?.projectId ?? '',
// runtimeVersion: manifest.runtimeVersion,
platform,
distribution,
});

const build = app?.byId?.buildsPaginated?.edges?.[0]?.node;
if (
build.__typename === 'Build' &&
build.expirationDate &&
new Date(build.expirationDate) > new Date() &&
build.artifacts?.buildUrl
) {
return build.artifacts.buildUrl;
}

return updateURL.replace('https://', 'exp://');
return null;
}
15 changes: 15 additions & 0 deletions apps/menu-bar/src/commands/launchUpdateAsync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import MenuBarModule from '../modules/MenuBarModule';

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

export const launchUpdateAsync = async ({ url, platform, deviceId }: LaunchUpdateAsyncOptions) => {
await MenuBarModule.runCli(
'launch-update',
[url, '-p', platform, '--device-id', deviceId],
console.log
);
};
135 changes: 96 additions & 39 deletions apps/menu-bar/src/popover/Core.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { bootDeviceAsync } from '../commands/bootDeviceAsync';
import { downloadBuildAsync } from '../commands/downloadBuildAsync';
import { installAndLaunchAppAsync } from '../commands/installAndLaunchAppAsync';
import { launchSnackAsync } from '../commands/launchSnackAsync';
import { launchUpdateAsync } from '../commands/launchUpdateAsync';
import { Spacer, View } from '../components';
import DeviceItem, { DEVICE_ITEM_HEIGHT } from '../components/DeviceItem';
import { useDeepLinking } from '../hooks/useDeepLinking';
Expand All @@ -32,7 +33,12 @@ import {
import { useListDevices } from '../providers/DevicesProvider';
import { getDeviceId, getDeviceOS, isVirtualDevice } from '../utils/device';
import { MenuBarStatus } from '../utils/helpers';
import { getPlatformFromURI, handleAuthUrl } from '../utils/parseUrl';
import {
URLType,
getPlatformFromURI,
handleAuthUrl,
identifyAndParseDeeplinkURL,
} from '../utils/parseUrl';

type Props = {
isDevWindow: boolean;
Expand Down Expand Up @@ -125,19 +131,60 @@ function Core(props: Props) {
[emulatorWithoutAudio]
);

// @TODO: create another hook
const handleSnackUrl = useCallback(
const getDeviceByPlatform = useCallback(
(platform: 'android' | 'ios') => {
const selectedDevicesId = selectedDevicesIds[platform];
if (selectedDevicesId && devicesPerPlatform[platform].devices.has(selectedDevicesId)) {
return devicesPerPlatform[platform].devices.get(selectedDevicesId);
}

const devices = devicesPerPlatform[platform].devices.values();

for (const device of devices) {
if (isVirtualDevice(device) && device.state === 'Booted') {
setSelectedDevicesIds((prev) => ({ ...prev, [platform]: getDeviceId(device) }));
return device;
}
}

const [firstDevice] = devicesPerPlatform[platform].devices.values();
if (!firstDevice) {
return;
}

setSelectedDevicesIds((prev) => ({ ...prev, [platform]: getDeviceId(firstDevice) }));
return firstDevice;
},
[devicesPerPlatform, selectedDevicesIds]
);

const handleUpdateUrl = useCallback(
async (url: string) => {
const device = getAvailableDeviceForSnack();
/**
* Supports any update manifest url as long as the
* platform is specified in the query params.
*/
const platform = new URL(url).searchParams.get('platform');
if (platform !== 'android' && platform !== 'ios') {
Alert.alert(
`Update URLs must include the "platform" query parameter with a value of either 'android' or 'ios'.`
);
return;
}

const device = getDeviceByPlatform(platform);
if (!device) {
Alert.alert(
`You don't have any ${platform} devices available to open this update, please make your environment is configured correctly and try again.`
);
return;
}

try {
setStatus(MenuBarStatus.BOOTING_DEVICE);
await ensureDeviceIsRunning(device);
setStatus(MenuBarStatus.OPENING_SNACK_PROJECT);
await launchSnackAsync({
setStatus(MenuBarStatus.OPENING_UPDATE);
await launchUpdateAsync({
url,
deviceId: getDeviceId(device),
platform: getDeviceOS(device),
Expand All @@ -153,34 +200,37 @@ function Core(props: Props) {
}, 2000);
}
},
[ensureDeviceIsRunning, getAvailableDeviceForSnack]
[ensureDeviceIsRunning, getDeviceByPlatform]
);

const getDeviceByPlatform = useCallback(
(platform: 'android' | 'ios') => {
const selectedDevicesId = selectedDevicesIds[platform];
if (selectedDevicesId && devicesPerPlatform[platform].devices.has(selectedDevicesId)) {
return devicesPerPlatform[platform].devices.get(selectedDevicesId);
const handleSnackUrl = useCallback(
async (url: string) => {
const device = getAvailableDeviceForSnack();
if (!device) {
return;
}

const devices = devicesPerPlatform[platform].devices.values();

for (const device of devices) {
if (isVirtualDevice(device) && device.state === 'Booted') {
setSelectedDevicesIds((prev) => ({ ...prev, [platform]: getDeviceId(device) }));
return device;
try {
setStatus(MenuBarStatus.BOOTING_DEVICE);
await ensureDeviceIsRunning(device);
setStatus(MenuBarStatus.OPENING_SNACK_PROJECT);
await launchSnackAsync({
url,
deviceId: getDeviceId(device),
platform: getDeviceOS(device),
});
} catch (error) {
if (error instanceof InternalError) {
Alert.alert('Something went wrong', error.message);
}
console.log(`error: ${JSON.stringify(error)}`);
} finally {
setTimeout(() => {
setStatus(MenuBarStatus.LISTENING);
}, 2000);
}

const [firstDevice] = devicesPerPlatform[platform].devices.values();
if (!firstDevice) {
return;
}

setSelectedDevicesIds((prev) => ({ ...prev, [platform]: getDeviceId(firstDevice) }));
return firstDevice;
},
[devicesPerPlatform, selectedDevicesIds]
[ensureDeviceIsRunning, getAvailableDeviceForSnack]
);

const installAppFromURI = useCallback(
Expand Down Expand Up @@ -260,22 +310,29 @@ function Core(props: Props) {

useDeepLinking(
useCallback(
({ url }) => {
({ url: deeplinkUrl }) => {
if (!props.isDevWindow) {
const urlWithoutProtocol = url.substring(url.indexOf('://') + 3);
const isSnackUrl = url.includes('exp.host/');
const isAuthUrl = urlWithoutProtocol.startsWith('auth?');

if (isAuthUrl) {
handleAuthUrl(url);
} else if (isSnackUrl) {
handleSnackUrl(`exp://${urlWithoutProtocol}`);
} else {
installAppFromURI(`https://${urlWithoutProtocol}`);
const { urlType, url } = identifyAndParseDeeplinkURL(deeplinkUrl);

switch (urlType) {
case URLType.AUTH:
handleAuthUrl(url);
break;
case URLType.SNACK:
handleSnackUrl(url);
break;
case URLType.EXPO_UPDATE:
handleUpdateUrl(url);
break;
case URLType.EXPO_BUILD:
case URLType.UNKNOWN:
default:
installAppFromURI(url);
break;
}
}
},
[props.isDevWindow, installAppFromURI, handleSnackUrl]
[props.isDevWindow, handleSnackUrl, handleUpdateUrl, installAppFromURI]
)
);

Expand Down
1 change: 1 addition & 0 deletions apps/menu-bar/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ export enum MenuBarStatus {
INSTALLING_APP,
INSTALLING_SNACK,
OPENING_SNACK_PROJECT,
OPENING_UPDATE,
}
33 changes: 33 additions & 0 deletions apps/menu-bar/src/utils/parseUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,36 @@ export function handleAuthUrl(url: string) {

saveSessionSecret(sessionSecret);
}

export function identifyAndParseDeeplinkURL(deeplinkURL: string): {
urlType: URLType;
url: string;
} {
const urlWithoutProtocol = deeplinkURL.replace(/^[^:]+:\/\//, '');

if (urlWithoutProtocol.startsWith('auth?')) {
return { urlType: URLType.AUTH, url: deeplinkURL };
}
if (urlWithoutProtocol.startsWith('update/')) {
return {
urlType: URLType.EXPO_UPDATE,
url: `https://${urlWithoutProtocol.replace('update/', '')}`,
};
}
if (urlWithoutProtocol.startsWith('expo.dev/artifacts')) {
return { urlType: URLType.EXPO_BUILD, url: `https://${urlWithoutProtocol}` };
}
if (urlWithoutProtocol.includes('exp.host/')) {
return { urlType: URLType.SNACK, url: `exp://${urlWithoutProtocol}` };
}

return { urlType: URLType.UNKNOWN, url: `https://${urlWithoutProtocol}` };
}

export enum URLType {
AUTH,
EXPO_UPDATE,
EXPO_BUILD,
SNACK,
UNKNOWN,
}

0 comments on commit 0b804e8

Please sign in to comment.