Skip to content

Commit

Permalink
Integrate with EAS
Browse files Browse the repository at this point in the history
  • Loading branch information
jakub-gonet committed Sep 5, 2024
1 parent cbd8a47 commit 06e349c
Show file tree
Hide file tree
Showing 11 changed files with 286 additions and 98 deletions.
3 changes: 2 additions & 1 deletion packages/vscode-extension/package-lock.json

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

26 changes: 10 additions & 16 deletions packages/vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
"side-panel",
"secondary-side-panel"
],
"description": "Controlls location of the IDE panel. Due to vscode API limitations, when secondary side panel is selected, you need to manually move the IDE panel to the secondary side panel. Changing this option closes and reopens the IDE."
"description": "Controls location of the IDE panel. Due to vscode API limitations, when secondary side panel is selected, you need to manually move the IDE panel to the secondary side panel. Changing this option closes and reopens the IDE."
},
"ReactNativeIDE.showDeviceFrame": {
"type": "boolean",
Expand Down Expand Up @@ -194,7 +194,7 @@
"properties": {
"appRoot": {
"type": "string",
"description": "Location of the React Native application root folder relative to the workspace. This is used for monorepo type setups when the workspace root is not the root of the React Native project. The IDE extension tries to locate the React Native application root automatically, but in case it failes to do so (i.e. there are multiple applications defined in the workspace), you can use this setting to override the location."
"description": "Location of the React Native application root folder relative to the workspace. This is used for monorepo type setups when the workspace root is not the root of the React Native project. The IDE extension tries to locate the React Native application root automatically, but in case it fails to do so (i.e. there are multiple applications defined in the workspace), you can use this setting to override the location."
},
"isExpo": {
"type": "boolean",
Expand All @@ -210,23 +210,17 @@
},
"buildScript": {
"type": "object",
"description": "Script used to build app or fetch app from known location. Executed as part of building process. Receives 'ios' or 'android' as first argument and should return a path to built app.",
"description": "Scripts used to build Android or iOS app or fetch them from known location. Executed as part of building process. Should print a JSON result from `eas build` command (invoked with --json --non-interactive flags and profile used for development) or a filesystem path to the built app as the last line of the standard output.",
"properties": {
"name": {
"ios": {
"type": "string",
"description": "A path to building script."
"description": "Script used to build iOS app."
},
"args": {
"type": "array",
"items": {
"type": "string"
},
"description": "Arguments passed to the build script."
"android": {
"type": "string",
"description": "Script used to build Android app."
}
},
"required": [
"name"
]
}
},
"ios": {
"description": "Provides a way to customize Xcode builds for iOS",
Expand All @@ -253,7 +247,7 @@
}
},
"preview": {
"description": "Custommize the behavior of device preview",
"description": "Customize the behavior of device preview",
"type": "object",
"properties": {
"waitForAppLaunch": {
Expand Down
5 changes: 3 additions & 2 deletions packages/vscode-extension/src/builders/BuildManager.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import fs from "fs";
import { Disposable, OutputChannel, window } from "vscode";

import { Logger } from "../Logger";
import { generateWorkspaceFingerprint } from "../utilities/fingerprint";
import { AndroidBuildResult, buildAndroid } from "./buildAndroid";
import { IOSBuildResult, buildIos } from "./buildIOS";
import fs from "fs";
import { calculateMD5 } from "../utilities/common";
import { DeviceInfo, DevicePlatform } from "../common/DeviceManager";
import { extensionContext, getAppRootFolder } from "../utilities/extensionContext";
import { Disposable, OutputChannel, window } from "vscode";
import { DependencyManager } from "../dependency/DependencyManager";
import { CancelToken } from "./cancelToken";

Expand Down
30 changes: 12 additions & 18 deletions packages/vscode-extension/src/builders/buildAndroid.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import path from "path";
import fs from "fs";
import semver from "semver";
import { OutputChannel } from "vscode";

import { getNativeABI } from "../utilities/common";
import { ANDROID_HOME, JAVA_HOME } from "../utilities/android";
import { Logger } from "../Logger";
import { exec, lineReader } from "../utilities/subprocess";
import semver from "semver";
import { CancelToken } from "./cancelToken";
import path from "path";
import fs from "fs";
import { OutputChannel } from "vscode";
import { extensionContext } from "../utilities/extensionContext";
import { BuildAndroidProgressProcessor } from "./BuildAndroidProgressProcessor";
import { getLaunchConfiguration } from "../utilities/launchConfiguration";
import { EXPO_GO_PACKAGE_NAME, downloadExpoGo, isExpoGoProject } from "./expoGo";
import { DevicePlatform } from "../common/DeviceManager";
import { getReactNativeVersion } from "../utilities/reactNative";
import { runExternalBuild } from "./customBuild";

export type AndroidBuildResult = {
platform: DevicePlatform.Android;
Expand Down Expand Up @@ -78,20 +80,12 @@ export async function buildAndroid(
): Promise<AndroidBuildResult> {
const { buildScript, env, android } = getLaunchConfiguration();

if (buildScript) {
const buildProcess = cancelToken.adapt(
exec(buildScript.name, ["android", ...(buildScript.args ?? [])])
if (buildScript?.android) {
const apkPath = await runExternalBuild(
cancelToken,
DevicePlatform.Android,
buildScript.android
);
let apkPath: string | undefined;
lineReader(buildProcess).onLineRead((line) => {
apkPath = line.trim();
});

await buildProcess;

if (!apkPath || fs.existsSync(apkPath)) {
throw new Error("Build script didn't output any app path");
}

return {
apkPath,
Expand Down Expand Up @@ -150,7 +144,7 @@ export async function buildAndroid(
});

await buildProcess;
Logger.debug("Android build sucessful");
Logger.debug("Android build successful");
const apkInfo = await getAndroidBuildPaths(appRootFolder, cancelToken, productFlavor, buildType);
return { ...apkInfo, platform: DevicePlatform.Android };
}
27 changes: 10 additions & 17 deletions packages/vscode-extension/src/builders/buildIOS.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { OutputChannel } from "vscode";
import path from "path";

import { exec, lineReader } from "../utilities/subprocess";
import { Logger } from "../Logger";
import path from "path";
import fs from "fs";
import { CancelToken } from "./cancelToken";
import { BuildIOSProgressProcessor } from "./BuildIOSProgressProcessor";
import { getLaunchConfiguration } from "../utilities/launchConfiguration";
Expand All @@ -15,6 +15,7 @@ import {
import { IOSDeviceInfo, DevicePlatform } from "../common/DeviceManager";
import { EXPO_GO_BUNDLE_ID, downloadExpoGo, isExpoGoProject } from "./expoGo";
import { findXcodeProject, findXcodeScheme, IOSProjectInfo } from "../utilities/xcode";
import { runExternalBuild } from "./customBuild";

export type IOSBuildResult = {
platform: DevicePlatform.IOS;
Expand Down Expand Up @@ -86,22 +87,14 @@ export async function buildIos(
): Promise<IOSBuildResult> {
const { buildScript, ios: buildOptions } = getLaunchConfiguration();

if (buildScript) {
const buildProcess = cancelToken.adapt(
exec(buildScript.name, ["ios", ...(buildScript.args ?? [])])
);
let appPath: string | undefined;
lineReader(buildProcess).onLineRead((line) => {
appPath = line.trim();
});

await buildProcess;

if (!appPath || fs.existsSync(appPath)) {
throw new Error("Build script didn't output any existing app path");
}
if (buildScript?.ios) {
const appPath = await runExternalBuild(cancelToken, DevicePlatform.IOS, buildScript.ios);

return { appPath, bundleID: await getBundleID(appPath), platform: DevicePlatform.IOS };
return {
appPath,
bundleID: await getBundleID(appPath),
platform: DevicePlatform.IOS,
};
}

if (await isExpoGoProject()) {
Expand Down
185 changes: 185 additions & 0 deletions packages/vscode-extension/src/builders/customBuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import path from "path";
import fs from "fs";
import os from "os";
import fetch from "node-fetch";
import { mkdtemp } from "fs/promises";
import { finished } from "stream/promises";

import { Logger } from "../Logger";
import { command, lineReader } from "../utilities/subprocess";
import { CancelToken } from "./cancelToken";
import { DevicePlatform } from "../common/DeviceManager";
import { getAppRootFolder } from "../utilities/extensionContext";

type Timestamp = string; // e.g. "2024-09-04T09:44:07.001Z"
type UUID = string;
type Version = string; // e.g. "50.0.0"

type EASBuild = {
id: string;
status: string; // "FINISHED" for build ones
platform: "ANDROID" | "IOS";
artifacts: {
buildUrl: string;
applicationArchiveUrl: string;
};
initiatingActor: {
id: UUID;
displayName: string;
};
project: {
id: UUID;
name: string;
slug: string;
ownerAccount: {
id: UUID;
name: string;
};
};
distribution: string; // e.g. "INTERNAL";
buildProfile: string; // e.g. "development"
sdkVersion: Version;
appVersion: Version;
appBuildVersion: string;
gitCommitHash: string;
gitCommitMessage: string;
priority: string; // e.g. "NORMAL_PLUS"
createdAt: Timestamp;
updatedAt: Timestamp;
completedAt: Timestamp;
expirationDate: Timestamp;
isForIosSimulator: false;
};

export async function runExternalBuild(
cancelToken: CancelToken,
platform: DevicePlatform,
externalCommand: string
): Promise<string> {
const { stdout, lastLine: binaryPath } = await runExternalScript(cancelToken, externalCommand);

const easBinaryPath = await downloadAppFromEas(stdout, platform);
const isEasBuild = easBinaryPath !== undefined;
if (isEasBuild) {
return easBinaryPath;
}

if (binaryPath && !fs.existsSync(binaryPath)) {
throw Error(
`External script: ${externalCommand} failed to output any existing app path, got: ${binaryPath}`
);
}

return binaryPath;
}

async function runExternalScript(cancelToken: CancelToken, externalCommand: string) {
const process = cancelToken.adapt(command(externalCommand, { cwd: getAppRootFolder() }));
Logger.info(`Running external script: ${externalCommand}`);

let lastLine: string | undefined;
const scriptName = getScriptName(externalCommand);
lineReader(process, true).onLineRead((line) => {
Logger.info(`External script: ${scriptName} (${process.pid})`, line);
lastLine = line.trim();
});

let stdout: string;
try {
const output = await process;
stdout = output.stdout;
} catch (error) {
throw Error(`External script: ${externalCommand} failed, error: ${error}`);
}

if (!lastLine) {
throw Error(`External script: ${externalCommand} didn't print any output`);
}

return { stdout, lastLine };
}

function getScriptName(fullCommand: string) {
const escapedSpacesAwareRegex = /(\\.|[^ ])+/g;
const externalCommandName = fullCommand.match(escapedSpacesAwareRegex)?.[0];
return externalCommandName ? path.basename(externalCommandName) : fullCommand;
}

async function downloadAppFromEas(processOutput: string, platform: DevicePlatform) {
const artifacts = parseEasBuildOutput(processOutput);
if (!artifacts) {
return undefined;
}

const easPlatformEnum = platform === DevicePlatform.Android ? "ANDROID" : "IOS";
const { binaryUrl } = artifacts.find((buildInfo) => buildInfo.platform === easPlatformEnum) ?? {};
if (!binaryUrl) {
Logger.warn(`Failed to find binary URL from EAS for platform ${platform}, ignoring`);
return undefined;
}

const tmpDirectory = await mkdtemp(path.join(os.tmpdir(), "rn-ide-external-build-"));
const appBinaryPath = await downloadBinary(binaryUrl, tmpDirectory);
if (!appBinaryPath) {
Logger.warn(`Failed to download binary from ${binaryUrl}, ignoring`);
}

return appBinaryPath;
}

function parseEasBuildOutput(stdout: string) {
let buildInfo: EASBuild[];
try {
buildInfo = JSON.parse(stdout);
assertEasBuildOutput(buildInfo);
} catch (_e) {
// Not an EAS build output, ignore
return undefined;
}
return buildInfo.map(({ platform, artifacts }) => {
return { platform, binaryUrl: artifacts.applicationArchiveUrl };
});
}

function assertEasBuildOutput(buildInfo: any): asserts buildInfo is EASBuild[] {
if (!Array.isArray(buildInfo)) {
throw new Error("Not an EAS build output");
}

for (const { platform, artifacts } of buildInfo) {
if (!platform || !artifacts) {
throw new Error("Not an EAS build output");
}
}
}

async function downloadBinary(url: string, directory: string) {
// URL should be in format "https://expo.dev/artifacts/eas/ID.apk", where ID
// is unique identifier.
const filename = url.split("/").pop();
const hasInvalidFormat = !filename;
if (hasInvalidFormat) {
return undefined;
}

let body: NodeJS.ReadableStream;
let ok: boolean;
try {
const result = await fetch(url);
body = result.body;
ok = result.ok;
} catch (_e) {
// Network error
return undefined;
}

if (ok) {
const destination = path.resolve(directory, filename);
const fileStream = fs.createWriteStream(destination, { flags: "wx" });
await finished(body.pipe(fileStream));

return destination.toString();
} else {
return undefined;
}
}
4 changes: 2 additions & 2 deletions packages/vscode-extension/src/common/LaunchConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ export type LaunchConfigurationOptions = {
appRoot?: string;
metroConfigPath?: string;
buildScript?: {
name: string;
args?: string[];
ios?: string;
android?: string;
};
env?: Record<string, string>;
ios?: {
Expand Down
Loading

0 comments on commit 06e349c

Please sign in to comment.