Skip to content

Commit

Permalink
Add Expo Go support (#57)
Browse files Browse the repository at this point in the history
This PR adds support for running Expo Go apps in React Native IDE. Most
of its logic related with ensuring presence and versioning of Expo Go
relies on the code from @expo library.

Key features:
1. Recognition of Expo Go usage is accomplished by executing a
JavaScript script that utilizes`resolveOptionsAsync` function from
@expo.
2. Downloading Expo Go .apk and .app file is also handled by function
from @expo. We execute JS script that uses `downloadExpoGoAsync`.
3. Launching the app is performed with the deep link with expo scheme.
This functionality was implemented before, but with this PR, we also add
the choice of using `expo-go` as the deep link parameter

---------

Co-authored-by: Krzysztof Magiera <[email protected]>
  • Loading branch information
franciszekjob and kmagiera committed Apr 2, 2024
1 parent 7147f2f commit 961ae52
Show file tree
Hide file tree
Showing 103 changed files with 30,243 additions and 90 deletions.
21 changes: 21 additions & 0 deletions packages/vscode-extension/lib/expo_go_download.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const { requireFromAppDir, appRoot } = require("./metro_helpers");
const { getConfig } = requireFromAppDir("@expo/config/build/Config.js");
const { downloadExpoGoAsync } = requireFromAppDir("@expo/cli/build/src/utils/downloadExpoGoAsync");

async function main() {
let platform = process.argv[2]; // 'Android' or 'iOS'

if (platform !== "Android" && platform !== "iOS") {
throw new Error("Platform not selected.");
}
const { exp } = getConfig(appRoot);
const sdkVersion = exp.sdkVersion;

// expo accepts either 'ios' or 'android'
// in RN IDE we use 'Android' or 'iOS', so we need apply toLowerCase
platform = platform.toLowerCase();
const filepath = await downloadExpoGoAsync(platform, { sdkVersion });
console.log(filepath);
}

main();
14 changes: 14 additions & 0 deletions packages/vscode-extension/lib/expo_go_project_tester.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const { requireFromAppDir, appRoot } = require("./metro_helpers");
const { resolveOptionsAsync } = requireFromAppDir("@expo/cli/build/src/start/resolveOptions");

// This is a test script to ensure that the `expo-go-project` package can be imported and used in a project.
// It is expected to fail either due to missing imports or because of devClient flag is set which indicates that the project
// is not a Expo Go project.
async function main() {
const { devClient } = await resolveOptionsAsync(appRoot, {});
if (devClient) {
throw new Error("Dev client is enabled");
}
}

main();
1 change: 1 addition & 0 deletions packages/vscode-extension/lib/metro_helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ function metroServerReadyHandler(originalOnReadyHandler) {
}

module.exports = {
appRoot,
adaptMetroConfig,
requireFromAppDir,
metroServerReadyHandler,
Expand Down
36 changes: 36 additions & 0 deletions packages/vscode-extension/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
"@types/node-fetch": "^2.6.9",
"@types/plist": "^3.0.5",
"@types/react": "^18.2.45",
"@types/tar": "^6.1.11",
"@types/uuid": "^9.0.7",
"@types/vscode": "^1.46.0",
"@types/ws": "^8.5.10",
Expand Down
28 changes: 26 additions & 2 deletions packages/vscode-extension/src/builders/BuildManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import { calculateMD5 } from "../utilities/common";
import { Platform } from "../common/DeviceManager";
import { extensionContext, getAppRootFolder } from "../utilities/extensionContext";
import { exec } from "../utilities/subprocess";
import { Disposable, LogOutputChannel, OutputChannel, window } from "vscode";
import { Disposable, OutputChannel, window } from "vscode";
import { downloadExpoGo, isExpoGoProject } from "./expoGo";

const ANDROID_BUILD_CACHE_KEY = "android_build_cache";
const IOS_BUILD_CACHE_KEY = "ios_build_cache";

export const EXPO_GO_BUNDLE_ID = "host.exp.Exponent";
export const EXPO_GO_PACKAGE_NAME = "host.exp.exponent";

export type IOSBuildResult = {
platform: Platform.IOS;
appPath: string;
Expand Down Expand Up @@ -132,6 +136,15 @@ export class BuildManager {
cancelToken: CancelToken,
progressListener: (newProgress: number) => void
) {
if (await isExpoGoProject()) {
const apkPath = await downloadExpoGo(Platform.Android, cancelToken);
return {
platform: Platform.Android,
apkPath,
packageName: EXPO_GO_PACKAGE_NAME,
} as AndroidBuildResult;
}

const newFingerprint = await generateWorkspaceFingerprint();
if (!forceCleanBuild) {
const buildResult = await this.loadAndroidCachedBuild(newFingerprint);
Expand All @@ -154,7 +167,10 @@ export class BuildManager {
this.buildOutputChannel!,
progressListener
);
const buildResult: AndroidBuildResult = { ...build, platform: Platform.Android };
const buildResult: AndroidBuildResult = {
...build,
platform: Platform.Android,
};

// store build info in the cache
const newBuildHash = (await calculateMD5(build.apkPath)).digest("hex");
Expand Down Expand Up @@ -195,6 +211,14 @@ export class BuildManager {
cancelToken: CancelToken,
progressListener: (newProgress: number) => void
) {
if (await isExpoGoProject()) {
const appPath = await downloadExpoGo(Platform.IOS, cancelToken);
return {
platform: Platform.IOS,
appPath,
bundleID: EXPO_GO_BUNDLE_ID,
} as IOSBuildResult;
}
const newFingerprint = await generateWorkspaceFingerprint();
if (!forceCleanBuild) {
const buildResult = await this.loadIOSCachedBuild(newFingerprint);
Expand Down
94 changes: 94 additions & 0 deletions packages/vscode-extension/src/builders/expoGo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import path from "path";
import { extensionContext, getAppRootFolder } from "../utilities/extensionContext";
import http from "http";
import fs from "fs";
import { exec } from "../utilities/subprocess";
import { Platform } from "../common/DeviceManager";
import { CancelToken } from "./BuildManager";

type ExpoDeeplinkChoice = "expo-go" | "expo-dev-client";

function fileExists(filePath: string, ...additionalPaths: string[]) {
return fs.existsSync(path.join(filePath, ...additionalPaths));
}

export async function isExpoGoProject(): Promise<boolean> {
// There is no straightforward way to tell apart different react native project
// setups. i.e. expo-go, expo-dev-client, bare react native, etc.
// Here, we are using a heuristic to determine if the project is expo-go based
// on the following factors:
// 1) The project has app.json or app.config.js
// 2) The project doesn't have an android or ios folder
// 3) The expo_go_project_tester.js script runs successfully – the script uses expo-cli
// internals to resolve project config and tells expo-go and dev-client apart.
const appRoot = getAppRootFolder();

if (!fileExists(appRoot, "app.json") && !fileExists(appRoot, "app.config.js")) {
// app.json or app.config.js is required for expo-go projects
return false;
}

if (fileExists(appRoot, "android") || fileExists(appRoot, "ios")) {
// expo-go projects don't have android or ios folders
return false;
}

const expoGoProjectTesterScript = path.join(
extensionContext.extensionPath,
"lib",
"expo_go_project_tester.js"
);
try {
const result = await exec(`node`, [expoGoProjectTesterScript], {
cwd: getAppRootFolder(),
allowNonZeroExit: true,
});
return result.exitCode === 0;
} catch (e) {
return false;
}
}

export function fetchExpoLaunchDeeplink(
metroPort: number,
platformString: string,
choice: ExpoDeeplinkChoice
) {
return new Promise<string | void>((resolve, reject) => {
const req = http.request(
new URL(
`http://localhost:${metroPort}/_expo/link?platform=${platformString}&choice=${choice}`
),
(res) => {
if (res.statusCode === 307) {
// we want to retrieve redirect location
resolve(res.headers.location);
} else {
resolve();
}
res.resume();
}
);
req.on("error", (e) => {
// we still want to resolve on error, because the URL may not exists, in which
// case it serves as a mechanism for detecting non expo-dev-client setups
resolve();
});
req.end();
});
}

export async function downloadExpoGo(platform: Platform, cancelToken: CancelToken) {
const downloadScript = path.join(extensionContext.extensionPath, "lib", "expo_go_download.js");
const { stdout } = await cancelToken.adapt(
exec(`node`, [downloadScript, platform], {
cwd: getAppRootFolder(),
})
);

// While expo downloads the file, it prints '- Fetching Expo Go' and at the last line it prints the path to the downloaded file
// we want to wait until the file is downloaded before we return the path
const lines = stdout.split("\n");
const filepath = lines[lines.length - 1];
return filepath;
}
1 change: 0 additions & 1 deletion packages/vscode-extension/src/common/Project.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { LogOutputChannel } from "vscode";
import { DeviceInfo } from "./DeviceManager";

export type DeviceSettings = {
Expand Down
6 changes: 6 additions & 0 deletions packages/vscode-extension/src/dependency/DependencyChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { command } from "../utilities/subprocess";
import path from "path";
import { getIosSourceDir } from "../builders/buildIOS";
import { getAppRootFolder } from "../utilities/extensionContext";
import { isExpoGoProject } from "../builders/expoGo";

export class DependencyChecker implements Disposable {
private disposables: Disposable[] = [];
Expand Down Expand Up @@ -155,6 +156,11 @@ export async function checkIfCLIInstalled(cmd: string, options: Record<string, u
}

export async function checkIosDependenciesInstalled() {
if (await isExpoGoProject()) {
// for Expo Go projects, we never return an error here because Pods are never needed
return true;
}

const iosDirPath = getIosSourceDir(getAppRootFolder());

Logger.debug(`Check pods in ${iosDirPath} ${getAppRootFolder()}`);
Expand Down
48 changes: 25 additions & 23 deletions packages/vscode-extension/src/devices/AndroidEmulatorDevice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import { getAppCachesDir, getCpuArchitecture } from "../utilities/common";
import { ANDROID_HOME } from "../utilities/android";
import { ChildProcess, exec, lineReader } from "../utilities/subprocess";
import { v4 as uuidv4 } from "uuid";
import { BuildResult } from "../builders/BuildManager";
import { AndroidBuildResult, BuildResult, EXPO_GO_PACKAGE_NAME } from "../builders/BuildManager";
import { AndroidSystemImageInfo, DeviceInfo, Platform } from "../common/DeviceManager";
import { Logger } from "../Logger";
import { DeviceSettings } from "../common/Project";
import { getAndroidSystemImages } from "../utilities/sdkmanager";
import { fetchExpoDevClientLaunchDeeplink } from "./IosSimulatorDevice";
import { fetchExpoLaunchDeeplink } from "../builders/expoGo";

export const EMULATOR_BINARY = path.join(ANDROID_HOME, "emulator", "emulator");
const ADB_PATH = path.join(ANDROID_HOME, "platform-tools", "adb");
Expand Down Expand Up @@ -148,12 +148,22 @@ export class AndroidEmulatorDevice extends DeviceBase {
);
}

async launchWithExpoDevClientDeeplink(
metroPort: number,
devtoolsPort: number,
expoDevClientDeeplink: string
) {
// For Expo dev-client setup, we use deeplink to launch the app. Since Expo's manifest is configured to
async launchWithBuild(build: AndroidBuildResult) {
await exec(ADB_PATH, [
"-s",
this.serial!,
"shell",
"monkey",
"-p",
build.packageName,
"-c",
"android.intent.category.LAUNCHER",
"1",
]);
}

async launchWithExpoDeeplink(metroPort: number, devtoolsPort: number, expoDeeplink: string) {
// For Expo dev-client and expo go setup, we use deeplink to launch the app. Since Expo's manifest is configured to
// return localhost:PORT as the destination, we need to setup adb reverse for metro port first.
await exec(ADB_PATH, ["-s", this.serial!, "reverse", `tcp:${metroPort}`, `tcp:${metroPort}`]);
await exec(ADB_PATH, [
Expand All @@ -173,30 +183,22 @@ export class AndroidEmulatorDevice extends DeviceBase {
"-a",
"android.intent.action.VIEW",
"-d",
expoDevClientDeeplink + "&disableOnboarding=1", // disable onboarding dialog via deeplink query param,
expoDeeplink + "&disableOnboarding=1", // disable onboarding dialog via deeplink query param,
]);
}

async launchApp(build: BuildResult, metroPort: number, devtoolsPort: number) {
if (build.platform !== Platform.Android) {
throw new Error("Invalid platform");
}
const expoDevClientDeeplink = await fetchExpoDevClientLaunchDeeplink(metroPort, "android");
if (expoDevClientDeeplink) {
this.launchWithExpoDevClientDeeplink(metroPort, devtoolsPort, expoDevClientDeeplink);
const deepLinkChoice =
build.packageName === EXPO_GO_PACKAGE_NAME ? "expo-go" : "expo-dev-client";
const expoDeeplink = await fetchExpoLaunchDeeplink(metroPort, "android", deepLinkChoice);
if (expoDeeplink) {
this.launchWithExpoDeeplink(metroPort, devtoolsPort, expoDeeplink);
} else {
await this.configureMetroPort(build.packageName, metroPort);
await exec(ADB_PATH, [
"-s",
this.serial!,
"shell",
"monkey",
"-p",
build.packageName,
"-c",
"android.intent.category.LAUNCHER",
"1",
]);
await this.launchWithBuild(build);
}
}

Expand Down
1 change: 0 additions & 1 deletion packages/vscode-extension/src/devices/DeviceBase.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Disposable } from "vscode";
import { Preview } from "./preview";
import { BuildResult } from "../builders/BuildManager";
import { DeviceInfo } from "../common/DeviceManager";
import { DeviceSettings } from "../common/Project";

export abstract class DeviceBase implements Disposable {
Expand Down
Loading

0 comments on commit 961ae52

Please sign in to comment.