From 6aeee3a18e55a59b513ef3a07ee2485992bb06e8 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Mon, 19 Aug 2024 12:16:30 +0200 Subject: [PATCH 01/21] Allow for using custom build script --- packages/vscode-extension/package.json | 20 +++++++++++++ .../src/builders/buildAndroid.ts | 30 ++++++++++++++++--- .../vscode-extension/src/builders/buildIOS.ts | 19 +++++++++++- .../src/common/LaunchConfig.ts | 4 +++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 9b19a3c00..1fa6e4a1f 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -208,6 +208,26 @@ "type": "string", "description": "Location of Metro config relative to the workspace. This is used for using custom configs for e.g. development." }, + "buildScript": { + "type": "object", + "description": "Script used to build app or fetch app from known location. Executed as part of building process. Should return a path to built app.", + "properties": { + "name": { + "type": "string", + "description": "A path to building script." + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Arguments passed to the build script." + } + }, + "required": [ + "name" + ] + }, "ios": { "description": "Provides a way to customize Xcode builds for iOS", "type": "object", diff --git a/packages/vscode-extension/src/builders/buildAndroid.ts b/packages/vscode-extension/src/builders/buildAndroid.ts index 76798bbba..2fa517a88 100644 --- a/packages/vscode-extension/src/builders/buildAndroid.ts +++ b/packages/vscode-extension/src/builders/buildAndroid.ts @@ -76,14 +76,36 @@ export async function buildAndroid( outputChannel: OutputChannel, progressListener: (newProgress: number) => void ): Promise { + const { buildScript, env, android } = getLaunchConfiguration(); + + if (buildScript) { + const buildProcess = cancelToken.adapt(exec(buildScript.name, buildScript.args)); + 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, + packageName: await extractPackageName(apkPath, cancelToken), + platform: DevicePlatform.Android, + }; + } + if (await isExpoGoProject()) { const apkPath = await downloadExpoGo(DevicePlatform.Android, cancelToken); return { apkPath, packageName: EXPO_GO_PACKAGE_NAME, platform: DevicePlatform.Android }; } + const androidSourceDir = getAndroidSourceDir(appRootFolder); - const buildOptions = getLaunchConfiguration(); - const productFlavor = buildOptions.android?.productFlavor || ""; - const buildType = buildOptions.android?.buildType || "debug"; + const productFlavor = android?.productFlavor || ""; + const buildType = android?.buildType || "debug"; const gradleArgs = [ "-x", "lint", @@ -114,7 +136,7 @@ export async function buildAndroid( const buildProcess = cancelToken.adapt( exec("./gradlew", gradleArgs, { cwd: androidSourceDir, - env: { ...buildOptions.env, JAVA_HOME, ANDROID_HOME }, + env: { ...env, JAVA_HOME, ANDROID_HOME }, buffer: false, }) ); diff --git a/packages/vscode-extension/src/builders/buildIOS.ts b/packages/vscode-extension/src/builders/buildIOS.ts index 465cae16c..48bab740f 100644 --- a/packages/vscode-extension/src/builders/buildIOS.ts +++ b/packages/vscode-extension/src/builders/buildIOS.ts @@ -1,4 +1,5 @@ import path from "path"; +import fs from "fs"; import { OutputChannel } from "vscode"; import { exec, lineReader } from "../utilities/subprocess"; import { Logger } from "../Logger"; @@ -83,7 +84,23 @@ export async function buildIos( cancelToken: CancelToken ) => Promise ): Promise { - const { ios: buildOptions } = getLaunchConfiguration(); + const { buildScript, ios: buildOptions } = getLaunchConfiguration(); + + if (buildScript) { + const buildProcess = cancelToken.adapt(exec(buildScript.name, 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"); + } + + return { appPath, bundleID: await getBundleID(appPath), platform: DevicePlatform.IOS }; + } if (await isExpoGoProject()) { const appPath = await downloadExpoGo(DevicePlatform.IOS, cancelToken); diff --git a/packages/vscode-extension/src/common/LaunchConfig.ts b/packages/vscode-extension/src/common/LaunchConfig.ts index d7ad79f6e..1f40ea5e9 100644 --- a/packages/vscode-extension/src/common/LaunchConfig.ts +++ b/packages/vscode-extension/src/common/LaunchConfig.ts @@ -1,6 +1,10 @@ export type LaunchConfigurationOptions = { appRoot?: string; metroConfigPath?: string; + buildScript?: { + name: string; + args?: string[]; + }; env?: Record; ios?: { scheme?: string; From 551a02f811ed3effb7edd2602a3847775ad7252f Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Mon, 19 Aug 2024 19:11:30 +0200 Subject: [PATCH 02/21] Pass platform to scripts --- packages/vscode-extension/src/builders/buildAndroid.ts | 4 +++- packages/vscode-extension/src/builders/buildIOS.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/vscode-extension/src/builders/buildAndroid.ts b/packages/vscode-extension/src/builders/buildAndroid.ts index 2fa517a88..c071cf7d9 100644 --- a/packages/vscode-extension/src/builders/buildAndroid.ts +++ b/packages/vscode-extension/src/builders/buildAndroid.ts @@ -79,7 +79,9 @@ export async function buildAndroid( const { buildScript, env, android } = getLaunchConfiguration(); if (buildScript) { - const buildProcess = cancelToken.adapt(exec(buildScript.name, buildScript.args)); + const buildProcess = cancelToken.adapt( + exec(buildScript.name, ["android", ...(buildScript.args ?? [])]) + ); let apkPath: string | undefined; lineReader(buildProcess).onLineRead((line) => { apkPath = line.trim(); diff --git a/packages/vscode-extension/src/builders/buildIOS.ts b/packages/vscode-extension/src/builders/buildIOS.ts index 48bab740f..0d0408465 100644 --- a/packages/vscode-extension/src/builders/buildIOS.ts +++ b/packages/vscode-extension/src/builders/buildIOS.ts @@ -87,7 +87,9 @@ export async function buildIos( const { buildScript, ios: buildOptions } = getLaunchConfiguration(); if (buildScript) { - const buildProcess = cancelToken.adapt(exec(buildScript.name, buildScript.args)); + const buildProcess = cancelToken.adapt( + exec(buildScript.name, ["ios", ...(buildScript.args ?? [])]) + ); let appPath: string | undefined; lineReader(buildProcess).onLineRead((line) => { appPath = line.trim(); From 857a8500371abbbfe03a4a6f4fee4312745bc9f3 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Mon, 19 Aug 2024 19:13:34 +0200 Subject: [PATCH 03/21] Update docs --- packages/vscode-extension/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 1fa6e4a1f..ca61c3a32 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -210,7 +210,7 @@ }, "buildScript": { "type": "object", - "description": "Script used to build app or fetch app from known location. Executed as part of building process. Should return a path to built app.", + "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.", "properties": { "name": { "type": "string", From a0728043c80dd91a15c95bf8ecc87261c701375a Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Thu, 5 Sep 2024 23:34:42 +0200 Subject: [PATCH 04/21] Add spellings for the project in VSC workspace settings --- .../vscode-extension/.vscode/settings.json | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/vscode-extension/.vscode/settings.json b/packages/vscode-extension/.vscode/settings.json index d98188a3a..53eb9c326 100644 --- a/packages/vscode-extension/.vscode/settings.json +++ b/packages/vscode-extension/.vscode/settings.json @@ -9,5 +9,57 @@ // Turn off tsc task auto detection since we have the necessary tasks as npm scripts "typescript.tsc.autoDetect": "off", "cmake.configureOnOpen": false, - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "cSpell.words": [ + "Aapt", + "avds", + "codicon", + "codicons", + "debugadapter", + "debuggable", + "debugprotocol", + "Deeplink", + "devmenu", + "execa", + "fastboot", + "getprop", + "gradlew", + "initscript", + "keyevent", + "launchservices", + "libexec", + "msgpack", + "ncore", + "openurl", + "outfile", + "playstore", + "Preact", + "prefs", + "RNIDE", + "Runtimes", + "schemeapproval", + "sdcard", + "sdkmanager", + "sharedpreferences", + "simctl", + "siri", + "sourcer", + "swmansion", + "sysdir", + "UDID", + "uimode", + "uuidv4", + "virtualscene", + "xcodebuild", + "xcodeproj", + "xcrun", + "xcscheme", + "xcschemes", + "xcshareddata", + "xcworkspace", + "xlarge", + "xsmall", + "xxlarge", + "xxxlarge" + ] } From b376de1647b9cfab6e449aa175fdfea31068b507 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Thu, 5 Sep 2024 23:36:04 +0200 Subject: [PATCH 05/21] Integrate with EAS --- packages/vscode-extension/package-lock.json | 3 +- packages/vscode-extension/package.json | 26 +-- .../src/builders/buildAndroid.ts | 21 +- .../vscode-extension/src/builders/buildIOS.ts | 25 +-- .../src/builders/customBuild.ts | 185 ++++++++++++++++++ .../src/common/LaunchConfig.ts | 4 +- .../src/devices/AndroidEmulatorDevice.ts | 61 +++--- .../vscode-extension/src/utilities/retry.ts | 34 ++-- .../src/utilities/subprocess.ts | 2 +- 9 files changed, 274 insertions(+), 87 deletions(-) create mode 100644 packages/vscode-extension/src/builders/customBuild.ts diff --git a/packages/vscode-extension/package-lock.json b/packages/vscode-extension/package-lock.json index 01a24fafa..2d88f8333 100644 --- a/packages/vscode-extension/package-lock.json +++ b/packages/vscode-extension/package-lock.json @@ -7915,8 +7915,9 @@ }, "node_modules/node-fetch": { "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dev": true, - "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" }, diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index ca61c3a32..59da848cf 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -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." }, "RadonIDE.showDeviceFrame": { "type": "boolean", @@ -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", @@ -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", @@ -342,7 +336,7 @@ } }, "preview": { - "description": "Custommize the behavior of device preview", + "description": "Customize the behavior of device preview", "type": "object", "properties": { "waitForAppLaunch": { diff --git a/packages/vscode-extension/src/builders/buildAndroid.ts b/packages/vscode-extension/src/builders/buildAndroid.ts index c071cf7d9..de892ccc5 100644 --- a/packages/vscode-extension/src/builders/buildAndroid.ts +++ b/packages/vscode-extension/src/builders/buildAndroid.ts @@ -13,6 +13,7 @@ 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; @@ -78,20 +79,12 @@ export async function buildAndroid( ): Promise { 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, @@ -150,7 +143,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 }; } diff --git a/packages/vscode-extension/src/builders/buildIOS.ts b/packages/vscode-extension/src/builders/buildIOS.ts index 0d0408465..724b1b7a8 100644 --- a/packages/vscode-extension/src/builders/buildIOS.ts +++ b/packages/vscode-extension/src/builders/buildIOS.ts @@ -1,6 +1,8 @@ import path from "path"; import fs from "fs"; import { OutputChannel } from "vscode"; +import path from "path"; + import { exec, lineReader } from "../utilities/subprocess"; import { Logger } from "../Logger"; import { CancelToken } from "./cancelToken"; @@ -15,6 +17,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; @@ -86,22 +89,14 @@ export async function buildIos( ): Promise { 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()) { diff --git a/packages/vscode-extension/src/builders/customBuild.ts b/packages/vscode-extension/src/builders/customBuild.ts new file mode 100644 index 000000000..fdcd0914f --- /dev/null +++ b/packages/vscode-extension/src/builders/customBuild.ts @@ -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 { + 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; + } +} diff --git a/packages/vscode-extension/src/common/LaunchConfig.ts b/packages/vscode-extension/src/common/LaunchConfig.ts index 1f40ea5e9..5e22efa55 100644 --- a/packages/vscode-extension/src/common/LaunchConfig.ts +++ b/packages/vscode-extension/src/common/LaunchConfig.ts @@ -2,8 +2,8 @@ export type LaunchConfigurationOptions = { appRoot?: string; metroConfigPath?: string; buildScript?: { - name: string; - args?: string[]; + ios?: string; + android?: string; }; env?: Record; ios?: { diff --git a/packages/vscode-extension/src/devices/AndroidEmulatorDevice.ts b/packages/vscode-extension/src/devices/AndroidEmulatorDevice.ts index 79ff81054..b180c4464 100644 --- a/packages/vscode-extension/src/devices/AndroidEmulatorDevice.ts +++ b/packages/vscode-extension/src/devices/AndroidEmulatorDevice.ts @@ -303,15 +303,20 @@ export class AndroidEmulatorDevice extends DeviceBase { if (build.platform !== DevicePlatform.Android) { throw new Error("Invalid platform"); } - // adb install sometimes fails because we call it too early after the device is initialized. - // we haven't found a better way to test if device is ready and already wait for boot_completed - // flag in waitForEmulatorOnline. But even after that even is delivered, adb install also sometimes - // fails claiming it is too early. The workaround therefore is to retry install command. - if (forceReinstall) { + + // allowNonZeroExit is set to true to not print errors when INSTALL_FAILED_UPDATE_INCOMPATIBLE occurs. + const installApk = (allowDowngrade: boolean) => + exec( + ADB_PATH, + ["-s", this.serial!, "install", ...(allowDowngrade ? ["-d"] : []), "-r", build.apkPath], + { allowNonZeroExit: true } + ); + + const uninstallApp = async (packageName: string) => { try { await retry( () => - exec(ADB_PATH, ["-s", this.serial!, "uninstall", build.packageName], { + exec(ADB_PATH, ["-s", this.serial!, "uninstall", packageName], { allowNonZeroExit: true, }), 2, @@ -320,27 +325,37 @@ export class AndroidEmulatorDevice extends DeviceBase { } catch (e) { Logger.error("Error while uninstalling will be ignored", e); } + }; + + // adb install sometimes fails because we call it too early after the device is initialized. + // we haven't found a better way to test if device is ready and already wait for boot_completed + // flag in waitForEmulatorOnline. But even after that even is delivered, adb install also sometimes + // fails claiming it is too early. The workaround therefore is to retry install command. + if (forceReinstall) { + uninstallApp(build.packageName); } - const installApk = (allowDowngrade: boolean) => { - return exec(ADB_PATH, [ - "-s", - this.serial!, - "install", - ...(allowDowngrade ? ["-d"] : []), - "-r", - build.apkPath, - ]); - }; await retry( - () => installApk(false), + async (retryNumber) => { + if (retryNumber === 0) { + installApk(false); + } else if (retryNumber === 1) { + // There's a chance that same emulator was used in newer version of Expo + // and then RN IDE was opened on older project, in which case installation + // will fail. We use -d flag which allows for downgrading debuggable + // applications (see `adb shell pm`, install command) + installApk(true); + } else { + // If the app is still not installed, we try to uninstall it first to + // avoid "INSTALL_FAILED_UPDATE_INCOMPATIBLE: Existing package + // signatures do not match newer version; ignoring!" error. This error + // may come when building locally and with EAS. + await uninstallApp(build.packageName); + installApk(true); + } + }, 2, - 1000, - // there's a chance that same emulator was used in newer version of Expo - // and then RN IDE was opened on older project, in which case installation - // will fail. We use -d flag which allows for downgrading debuggable - // applications (see `adb shell pm`, install command) - () => installApk(true) + 1000 ); } diff --git a/packages/vscode-extension/src/utilities/retry.ts b/packages/vscode-extension/src/utilities/retry.ts index 6fae93956..4f2035050 100644 --- a/packages/vscode-extension/src/utilities/retry.ts +++ b/packages/vscode-extension/src/utilities/retry.ts @@ -1,18 +1,22 @@ -export async function retry( - fn: () => Promise, - retriesLeft = 5, - interval = 1000, - fallbackFn?: () => Promise -): Promise { - try { - const val = await fn(); - return val; - } catch (error) { - if (retriesLeft) { - await new Promise((r) => setTimeout(r, interval)); - return retry(fallbackFn ?? fn, retriesLeft - 1, interval); - } else { - throw error; +type RetryFn = (retryNumber: number, retriesLeft: number) => Promise; + +export async function retry(fn: RetryFn, retries = 5, interval = 1000): Promise { + async function call(retriesLeft: number) { + try { + return await fn(retries - (retriesLeft - 1), retriesLeft); + } catch (error) { + if (retriesLeft > 0) { + await sleep(interval); + return call(retriesLeft - 1); + } else { + throw error; + } } } + + return await call(retries); +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/packages/vscode-extension/src/utilities/subprocess.ts b/packages/vscode-extension/src/utilities/subprocess.ts index 4ab57b6b6..203f3afc5 100644 --- a/packages/vscode-extension/src/utilities/subprocess.ts +++ b/packages/vscode-extension/src/utilities/subprocess.ts @@ -59,7 +59,7 @@ function overrideEnv(options?: T): T | undefined { } /** - * When using this methid, the subprocess should be started with buffer: false option + * When using this method, the subprocess should be started with buffer: false option * as there's no need for allocating memory for the output that's going to be very long. */ export function lineReader(childProcess: ExecaChildProcess) { From a39c956efe91f5916b4138fdb4d1cd4ba7ccf90c Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Tue, 10 Sep 2024 14:28:37 +0200 Subject: [PATCH 06/21] Support more expo commands --- .../src/builders/customBuild.ts | 108 ++++++++++++++---- 1 file changed, 83 insertions(+), 25 deletions(-) diff --git a/packages/vscode-extension/src/builders/customBuild.ts b/packages/vscode-extension/src/builders/customBuild.ts index fdcd0914f..71bb80b17 100644 --- a/packages/vscode-extension/src/builders/customBuild.ts +++ b/packages/vscode-extension/src/builders/customBuild.ts @@ -2,22 +2,30 @@ import path from "path"; import fs from "fs"; import os from "os"; import fetch from "node-fetch"; -import { mkdtemp } from "fs/promises"; +import { mkdtemp, readdir } from "fs/promises"; import { finished } from "stream/promises"; import { Logger } from "../Logger"; -import { command, lineReader } from "../utilities/subprocess"; +import { command, exec, 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 UnixTimestamp = number; + +type EasBuildUrl = { + platform: DevicePlatform; + binaryUrl: string; + completedAt: UnixTimestamp; +}; + +type IsoTimestamp = 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 + status: "FINISHED" | "CANCELLED" | string; platform: "ANDROID" | "IOS"; artifacts: { buildUrl: string; @@ -44,10 +52,10 @@ type EASBuild = { gitCommitHash: string; gitCommitMessage: string; priority: string; // e.g. "NORMAL_PLUS" - createdAt: Timestamp; - updatedAt: Timestamp; - completedAt: Timestamp; - expirationDate: Timestamp; + createdAt: IsoTimestamp; + updatedAt: IsoTimestamp; + completedAt: IsoTimestamp; + expirationDate: IsoTimestamp; isForIosSimulator: false; }; @@ -58,10 +66,11 @@ export async function runExternalBuild( ): Promise { const { stdout, lastLine: binaryPath } = await runExternalScript(cancelToken, externalCommand); - const easBinaryPath = await downloadAppFromEas(stdout, platform); - const isEasBuild = easBinaryPath !== undefined; + const easAppPath = await downloadAppFromEas(stdout, platform, cancelToken); + + const isEasBuild = easAppPath !== undefined; if (isEasBuild) { - return easBinaryPath; + return easAppPath; } if (binaryPath && !fs.existsSync(binaryPath)) { @@ -105,14 +114,17 @@ function getScriptName(fullCommand: string) { return externalCommandName ? path.basename(externalCommandName) : fullCommand; } -async function downloadAppFromEas(processOutput: string, platform: DevicePlatform) { +async function downloadAppFromEas( + processOutput: string, + platform: DevicePlatform, + cancelToken: CancelToken +) { const artifacts = parseEasBuildOutput(processOutput); - if (!artifacts) { + if (!artifacts || artifacts.length === 0) { return undefined; } - const easPlatformEnum = platform === DevicePlatform.Android ? "ANDROID" : "IOS"; - const { binaryUrl } = artifacts.find((buildInfo) => buildInfo.platform === easPlatformEnum) ?? {}; + const { binaryUrl } = getMostRecentBuild(artifacts, platform) ?? {}; if (!binaryUrl) { Logger.warn(`Failed to find binary URL from EAS for platform ${platform}, ignoring`); return undefined; @@ -124,30 +136,45 @@ async function downloadAppFromEas(processOutput: string, platform: DevicePlatfor Logger.warn(`Failed to download binary from ${binaryUrl}, ignoring`); } + if (appBinaryPath?.endsWith(".ipa")) { + return await extractIpa(appBinaryPath, cancelToken); + } + return appBinaryPath; } -function parseEasBuildOutput(stdout: string) { +function parseEasBuildOutput(stdout: string): EasBuildUrl[] | undefined { let buildInfo: EASBuild[]; try { - buildInfo = JSON.parse(stdout); + // Supports eas build, eas build:list, eas build:view + buildInfo = [JSON.parse(stdout)].flat(); assertEasBuildOutput(buildInfo); } catch (_e) { // Not an EAS build output, ignore return undefined; } - return buildInfo.map(({ platform, artifacts }) => { - return { platform, binaryUrl: artifacts.applicationArchiveUrl }; - }); + + const platformMapping = { ANDROID: DevicePlatform.Android, IOS: DevicePlatform.IOS }; + return buildInfo + .filter(({ status }) => status === "FINISHED") + .map(({ platform: easPlatform, artifacts, completedAt }) => { + return { + platform: platformMapping[easPlatform], + binaryUrl: artifacts.applicationArchiveUrl, + completedAt: Date.parse(completedAt), + }; + }); } function assertEasBuildOutput(buildInfo: any): asserts buildInfo is EASBuild[] { - if (!Array.isArray(buildInfo)) { - throw new Error("Not an EAS build output"); - } + for (const { platform, artifacts, completedAt, status } of buildInfo) { + if (status !== "FINISHED") { + continue; + } - for (const { platform, artifacts } of buildInfo) { - if (!platform || !artifacts) { + const isInvalidPlatform = platform !== "ANDROID" && platform !== "IOS"; + const hasMissingFields = !artifacts || !completedAt; + if (isInvalidPlatform || hasMissingFields) { throw new Error("Not an EAS build output"); } } @@ -183,3 +210,34 @@ async function downloadBinary(url: string, directory: string) { return undefined; } } + +function getMostRecentBuild(artifacts: EasBuildUrl[], platform: DevicePlatform) { + let latestBuild: EasBuildUrl | undefined = undefined; + for (const build of artifacts) { + if (platform !== build.platform) { + continue; + } + const isLater = !latestBuild || latestBuild.completedAt < build.completedAt; + + if (isLater) { + latestBuild = build; + } + } + return latestBuild; +} + +async function extractIpa(ipaPath: string, cancelToken: CancelToken) { + const extractDirName = path.basename(ipaPath, path.extname(ipaPath)); + const extractDir = path.join(path.dirname(ipaPath), extractDirName); + try { + await cancelToken.adapt(exec("unzip", ["-d", extractDir, ipaPath])); + } catch (error) { + Logger.error(`Failed to extract archive ${ipaPath} to ${extractDir}`, error); + return undefined; + } + + const payloadDir = path.join(extractDir, "Payload"); + + const appName = (await readdir(payloadDir))[0]; + return path.join(payloadDir, appName); +} From ee763b851fef30b4785cd0800813df7d4f96932f Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Mon, 23 Sep 2024 00:06:25 +0200 Subject: [PATCH 07/21] Add logs to .ipa extraction --- .../src/builders/customBuild.ts | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/vscode-extension/src/builders/customBuild.ts b/packages/vscode-extension/src/builders/customBuild.ts index 71bb80b17..14cb971d3 100644 --- a/packages/vscode-extension/src/builders/customBuild.ts +++ b/packages/vscode-extension/src/builders/customBuild.ts @@ -66,11 +66,14 @@ export async function runExternalBuild( ): Promise { const { stdout, lastLine: binaryPath } = await runExternalScript(cancelToken, externalCommand); - const easAppPath = await downloadAppFromEas(stdout, platform, cancelToken); + let easBinaryPath = await downloadAppFromEas(stdout, platform); - const isEasBuild = easAppPath !== undefined; - if (isEasBuild) { - return easAppPath; + if (easBinaryPath?.endsWith(".ipa")) { + easBinaryPath = await extractIpa(easBinaryPath, cancelToken); + } + + if (easBinaryPath) { + return easBinaryPath; } if (binaryPath && !fs.existsSync(binaryPath)) { @@ -88,7 +91,7 @@ async function runExternalScript(cancelToken: CancelToken, externalCommand: stri let lastLine: string | undefined; const scriptName = getScriptName(externalCommand); - lineReader(process, true).onLineRead((line) => { + lineReader(process).onLineRead((line) => { Logger.info(`External script: ${scriptName} (${process.pid})`, line); lastLine = line.trim(); }); @@ -114,11 +117,7 @@ function getScriptName(fullCommand: string) { return externalCommandName ? path.basename(externalCommandName) : fullCommand; } -async function downloadAppFromEas( - processOutput: string, - platform: DevicePlatform, - cancelToken: CancelToken -) { +async function downloadAppFromEas(processOutput: string, platform: DevicePlatform) { const artifacts = parseEasBuildOutput(processOutput); if (!artifacts || artifacts.length === 0) { return undefined; @@ -136,10 +135,6 @@ async function downloadAppFromEas( Logger.warn(`Failed to download binary from ${binaryUrl}, ignoring`); } - if (appBinaryPath?.endsWith(".ipa")) { - return await extractIpa(appBinaryPath, cancelToken); - } - return appBinaryPath; } @@ -239,5 +234,7 @@ async function extractIpa(ipaPath: string, cancelToken: CancelToken) { const payloadDir = path.join(extractDir, "Payload"); const appName = (await readdir(payloadDir))[0]; - return path.join(payloadDir, appName); + const appPath = path.join(payloadDir, appName); + Logger.debug("Extracted .ipa to", appPath); + return appPath; } From e705893e207fd7ba678883984174dd8fe4716376 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Mon, 23 Sep 2024 13:16:18 +0200 Subject: [PATCH 08/21] Extract tar.gz archive correctly --- .../src/builders/customBuild.ts | 82 ++++++++++++------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/packages/vscode-extension/src/builders/customBuild.ts b/packages/vscode-extension/src/builders/customBuild.ts index 14cb971d3..82b2e8c99 100644 --- a/packages/vscode-extension/src/builders/customBuild.ts +++ b/packages/vscode-extension/src/builders/customBuild.ts @@ -66,13 +66,9 @@ export async function runExternalBuild( ): Promise { const { stdout, lastLine: binaryPath } = await runExternalScript(cancelToken, externalCommand); - let easBinaryPath = await downloadAppFromEas(stdout, platform); - - if (easBinaryPath?.endsWith(".ipa")) { - easBinaryPath = await extractIpa(easBinaryPath, cancelToken); - } - + let easBinaryPath = await downloadAppFromEas(stdout, platform, cancelToken); if (easBinaryPath) { + Logger.debug(`Using binary from EAS: ${easBinaryPath}`); return easBinaryPath; } @@ -117,25 +113,59 @@ function getScriptName(fullCommand: string) { return externalCommandName ? path.basename(externalCommandName) : fullCommand; } -async function downloadAppFromEas(processOutput: string, platform: DevicePlatform) { +async function downloadAppFromEas( + processOutput: string, + platform: DevicePlatform, + cancelToken: CancelToken +) { + function isAppFile(name: string) { + return name.endsWith(".app"); + } + const artifacts = parseEasBuildOutput(processOutput); if (!artifacts || artifacts.length === 0) { + Logger.warn( + 'Failed to find any EAS build artifacts, ignoring. If you\'re building iOS app, make sure you set `"ios.simulator": true` option.' + ); return undefined; } const { binaryUrl } = getMostRecentBuild(artifacts, platform) ?? {}; if (!binaryUrl) { - Logger.warn(`Failed to find binary URL from EAS for platform ${platform}, ignoring`); + 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-eas-build-")); + const binaryPath = await downloadBinary(binaryUrl, tmpDirectory); + if (!binaryPath) { + Logger.warn(`Failed to download archive from ${binaryUrl}, ignoring`); + return undefined; + } + // on iOS we need to extract the .tar.gz archive to get the .app file + const shouldExtractArchive = platform === DevicePlatform.IOS; + if (!shouldExtractArchive) { + return binaryPath; + } + + const extractDir = path.dirname(binaryPath); + try { + await cancelToken.adapt(tarCommand({ archivePath: binaryPath, extractDir })); + } catch (error) { + Logger.error(`Failed to extract archive ${binaryPath} to ${extractDir}`, error); 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`); + // assuming that the archive contains only one .app file + const appName = (await readdir(extractDir)).find(isAppFile); + if (!appName) { + Logger.error(`Failed to find .app in extracted archive ${binaryPath}`); + return undefined; } - return appBinaryPath; + const appPath = path.join(extractDir, appName); + Logger.debug("Extracted app archive to", appPath); + return appPath; } function parseEasBuildOutput(stdout: string): EasBuildUrl[] | undefined { @@ -151,7 +181,12 @@ function parseEasBuildOutput(stdout: string): EasBuildUrl[] | undefined { const platformMapping = { ANDROID: DevicePlatform.Android, IOS: DevicePlatform.IOS }; return buildInfo - .filter(({ status }) => status === "FINISHED") + .filter(({ status, isForIosSimulator, platform }) => { + const isFinished = status === "FINISHED"; + const isUsableForDevice = (platform === "IOS" && isForIosSimulator) || platform === "ANDROID"; + + return isFinished && isUsableForDevice; + }) .map(({ platform: easPlatform, artifacts, completedAt }) => { return { platform: platformMapping[easPlatform], @@ -221,20 +256,7 @@ function getMostRecentBuild(artifacts: EasBuildUrl[], platform: DevicePlatform) return latestBuild; } -async function extractIpa(ipaPath: string, cancelToken: CancelToken) { - const extractDirName = path.basename(ipaPath, path.extname(ipaPath)); - const extractDir = path.join(path.dirname(ipaPath), extractDirName); - try { - await cancelToken.adapt(exec("unzip", ["-d", extractDir, ipaPath])); - } catch (error) { - Logger.error(`Failed to extract archive ${ipaPath} to ${extractDir}`, error); - return undefined; - } - - const payloadDir = path.join(extractDir, "Payload"); - - const appName = (await readdir(payloadDir))[0]; - const appPath = path.join(payloadDir, appName); - Logger.debug("Extracted .ipa to", appPath); - return appPath; +type TarCommandArgs = { archivePath: string; extractDir: string }; +function tarCommand({ archivePath, extractDir }: TarCommandArgs) { + return exec("tar", ["-xf", archivePath, "-C", extractDir]); } From 8d53af24c2c1e2d3a04e0ad4904a6877da9cd06c Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Mon, 23 Sep 2024 20:26:38 +0200 Subject: [PATCH 09/21] Add docs --- packages/docs/docs/launch-configuration.md | 37 ++++++++++++++++++++++ packages/vscode-extension/package.json | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/docs/docs/launch-configuration.md b/packages/docs/docs/launch-configuration.md index a41abe8a3..8e95ca8ad 100644 --- a/packages/docs/docs/launch-configuration.md +++ b/packages/docs/docs/launch-configuration.md @@ -124,6 +124,43 @@ Below is an example of how the `launch.json` file could look like with android v } ``` +### Custom build settings + +Instead of letting Radon IDE build your app, you can use scripts or [Expo +Application Services (EAS)](https://expo.dev/eas) to do +it with `buildScript` configuration option. + +The requirement for scripts is to output the absolute path to the built app as the +last line of standard output. + +You can also use `eas build`, `eas build:list` or `eas build:view` with +JSON output to use builds from EAS. If multiple builds are present, Radon IDE +will take the most recent one. Builds for iOS need to have `"ios.simulator": +true` config option set. + +`buildScript` configuration option is an object with `ios` and `android` keys. +Both are optional, specifying any of them will replace default build logic for +that platform with command of your choice. + +Below is an example that replaces Android build with build from EAS: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "type": "radon-ide", + "request": "launch", + "name": "Radon IDE panel", + "buildScript": { + "android": "eas build:list --non-interactive --json --platform android" + } + } + ] +} +``` + + ### Other settings Here, we list other attributes that can be configured using launch configuration which doesn't fit in any of the above categories: diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 59da848cf..dc99d0e01 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -210,7 +210,7 @@ }, "buildScript": { "type": "object", - "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.", + "description": "Scripts used to build Android or iOS app or fetch them from known location. Executed as a part of building process. Should print a JSON result from `eas build` command or a filesystem path to the built app as the last line of the standard output.\nIf using EAS, it should be invoked with --json --non-interactive flags and use a profile for development, iOS additionally needs `\"ios.simulator\": true` config option in eas.json", "properties": { "ios": { "type": "string", From ce78e6f6bab23583a7632c9f660f29e47cfdfe2c Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Tue, 24 Sep 2024 12:31:16 +0200 Subject: [PATCH 10/21] Add missing awaits --- .../vscode-extension/src/devices/AndroidEmulatorDevice.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vscode-extension/src/devices/AndroidEmulatorDevice.ts b/packages/vscode-extension/src/devices/AndroidEmulatorDevice.ts index b180c4464..1b8d5516f 100644 --- a/packages/vscode-extension/src/devices/AndroidEmulatorDevice.ts +++ b/packages/vscode-extension/src/devices/AndroidEmulatorDevice.ts @@ -332,26 +332,26 @@ export class AndroidEmulatorDevice extends DeviceBase { // flag in waitForEmulatorOnline. But even after that even is delivered, adb install also sometimes // fails claiming it is too early. The workaround therefore is to retry install command. if (forceReinstall) { - uninstallApp(build.packageName); + await uninstallApp(build.packageName); } await retry( async (retryNumber) => { if (retryNumber === 0) { - installApk(false); + await installApk(false); } else if (retryNumber === 1) { // There's a chance that same emulator was used in newer version of Expo // and then RN IDE was opened on older project, in which case installation // will fail. We use -d flag which allows for downgrading debuggable // applications (see `adb shell pm`, install command) - installApk(true); + await installApk(true); } else { // If the app is still not installed, we try to uninstall it first to // avoid "INSTALL_FAILED_UPDATE_INCOMPATIBLE: Existing package // signatures do not match newer version; ignoring!" error. This error // may come when building locally and with EAS. await uninstallApp(build.packageName); - installApk(true); + await installApk(true); } }, 2, From abfb71492b72f27f3ea9cab26ed3b9299ff0ff4e Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Tue, 24 Sep 2024 12:34:21 +0200 Subject: [PATCH 11/21] Pass env to custom build process --- .../src/builders/buildAndroid.ts | 3 ++- .../vscode-extension/src/builders/buildIOS.ts | 2 +- .../src/builders/customBuild.ts | 17 +++++++++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/vscode-extension/src/builders/buildAndroid.ts b/packages/vscode-extension/src/builders/buildAndroid.ts index de892ccc5..4128efd32 100644 --- a/packages/vscode-extension/src/builders/buildAndroid.ts +++ b/packages/vscode-extension/src/builders/buildAndroid.ts @@ -83,7 +83,8 @@ export async function buildAndroid( const apkPath = await runExternalBuild( cancelToken, DevicePlatform.Android, - buildScript.android + buildScript.android, + env ); return { diff --git a/packages/vscode-extension/src/builders/buildIOS.ts b/packages/vscode-extension/src/builders/buildIOS.ts index 724b1b7a8..095ca5eb2 100644 --- a/packages/vscode-extension/src/builders/buildIOS.ts +++ b/packages/vscode-extension/src/builders/buildIOS.ts @@ -90,7 +90,7 @@ export async function buildIos( const { buildScript, ios: buildOptions } = getLaunchConfiguration(); if (buildScript?.ios) { - const appPath = await runExternalBuild(cancelToken, DevicePlatform.IOS, buildScript.ios); + const appPath = await runExternalBuild(cancelToken, DevicePlatform.IOS, buildScript.ios, env); return { appPath, diff --git a/packages/vscode-extension/src/builders/customBuild.ts b/packages/vscode-extension/src/builders/customBuild.ts index 82b2e8c99..6b9b1eaa9 100644 --- a/packages/vscode-extension/src/builders/customBuild.ts +++ b/packages/vscode-extension/src/builders/customBuild.ts @@ -62,9 +62,14 @@ type EASBuild = { export async function runExternalBuild( cancelToken: CancelToken, platform: DevicePlatform, - externalCommand: string + externalCommand: string, + env: Record | undefined ): Promise { - const { stdout, lastLine: binaryPath } = await runExternalScript(cancelToken, externalCommand); + const { stdout, lastLine: binaryPath } = await runExternalScript( + cancelToken, + externalCommand, + env + ); let easBinaryPath = await downloadAppFromEas(stdout, platform, cancelToken); if (easBinaryPath) { @@ -81,8 +86,12 @@ export async function runExternalBuild( return binaryPath; } -async function runExternalScript(cancelToken: CancelToken, externalCommand: string) { - const process = cancelToken.adapt(command(externalCommand, { cwd: getAppRootFolder() })); +async function runExternalScript( + cancelToken: CancelToken, + externalCommand: string, + env: Record | undefined +) { + const process = cancelToken.adapt(command(externalCommand, { cwd: getAppRootFolder(), env })); Logger.info(`Running external script: ${externalCommand}`); let lastLine: string | undefined; From 13fe0992228895b618e31914620a0ed45d27a1e1 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Mon, 30 Sep 2024 21:03:29 +0200 Subject: [PATCH 12/21] Split custom build scripts and EAS --- packages/vscode-extension/package.json | 58 +++++ .../src/builders/buildAndroid.ts | 26 +- .../vscode-extension/src/builders/buildIOS.ts | 21 +- .../src/builders/customBuild.ts | 237 ++---------------- packages/vscode-extension/src/builders/eas.ts | 104 ++++++++ .../src/builders/easCommand.ts | 96 +++++++ .../src/common/LaunchConfig.ts | 10 +- .../vscode-extension/src/utilities/common.ts | 35 ++- .../src/utilities/launchConfiguration.ts | 43 +++- 9 files changed, 388 insertions(+), 242 deletions(-) create mode 100644 packages/vscode-extension/src/builders/eas.ts create mode 100644 packages/vscode-extension/src/builders/easCommand.ts diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index dc99d0e01..ec407fab2 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -222,6 +222,64 @@ } } }, + "eas": { + "type": "object", + "description": "Configuration for EAS build service", + "properties": { + "android": { + "type": "object", + "description": "Configuration for EAS build service for Android", + "properties": { + "profile": { + "type": "string", + "description": "Profile used to build the app in EAS. Built app should be in debug version." + }, + "useBuildType": { + "type": "string", + "enum": [ + "latest", + "id" + ], + "description": "Used to select build from EAS. Values: `latest` (uses latest built app) or `id` (uses specific build, identified by UUID)." + }, + "buildUUID": { + "type": "string", + "description": "UUID of the EAS build used in `eas build:view` command." + } + }, + "required": [ + "profile", + "useBuildType" + ] + }, + "ios": { + "type": "object", + "description": "Configuration for EAS build service for iOS", + "properties": { + "profile": { + "type": "string", + "description": "Profile used to build the app in EAS. Built app should be in debug version and have '\"ios.simulator\": true' option set." + }, + "useBuildType": { + "type": "string", + "enum": [ + "latest", + "id" + ], + "description": "Used to select build from EAS. Values: `latest` (uses latest built app) or `id` (uses specific build, identified by UUID)." + }, + "buildUUID": { + "type": "string", + "description": "UUID of the EAS build used in `eas build:view` command." + } + }, + "required": [ + "profile", + "useBuildType" + ] + } + } + }, "ios": { "description": "Provides a way to customize Xcode builds for iOS", "type": "object", diff --git a/packages/vscode-extension/src/builders/buildAndroid.ts b/packages/vscode-extension/src/builders/buildAndroid.ts index 4128efd32..cdedb10af 100644 --- a/packages/vscode-extension/src/builders/buildAndroid.ts +++ b/packages/vscode-extension/src/builders/buildAndroid.ts @@ -14,6 +14,7 @@ import { EXPO_GO_PACKAGE_NAME, downloadExpoGo, isExpoGoProject } from "./expoGo" import { DevicePlatform } from "../common/DeviceManager"; import { getReactNativeVersion } from "../utilities/reactNative"; import { runExternalBuild } from "./customBuild"; +import { fetchEasBuild } from "./eas"; export type AndroidBuildResult = { platform: DevicePlatform.Android; @@ -77,15 +78,26 @@ export async function buildAndroid( outputChannel: OutputChannel, progressListener: (newProgress: number) => void ): Promise { - const { buildScript, env, android } = getLaunchConfiguration(); + const { buildScript, eas, env, android } = getLaunchConfiguration(); if (buildScript?.android) { - const apkPath = await runExternalBuild( - cancelToken, - DevicePlatform.Android, - buildScript.android, - env - ); + const apkPath = await runExternalBuild(cancelToken, buildScript.android, env); + if (!apkPath) { + throw new Error("Failed to build Android app using custom script."); + } + + return { + apkPath, + packageName: await extractPackageName(apkPath, cancelToken), + platform: DevicePlatform.Android, + }; + } + + if (eas?.android) { + const apkPath = await fetchEasBuild(cancelToken, eas.android, DevicePlatform.Android); + if (!apkPath) { + throw new Error("Failed to build Android app using EAS build."); + } return { apkPath, diff --git a/packages/vscode-extension/src/builders/buildIOS.ts b/packages/vscode-extension/src/builders/buildIOS.ts index 095ca5eb2..b0d0632bd 100644 --- a/packages/vscode-extension/src/builders/buildIOS.ts +++ b/packages/vscode-extension/src/builders/buildIOS.ts @@ -18,6 +18,7 @@ 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"; +import { fetchEasBuild } from "./eas"; export type IOSBuildResult = { platform: DevicePlatform.IOS; @@ -87,10 +88,26 @@ export async function buildIos( cancelToken: CancelToken ) => Promise ): Promise { - const { buildScript, ios: buildOptions } = getLaunchConfiguration(); + const { buildScript, eas, ios: buildOptions, env } = getLaunchConfiguration(); if (buildScript?.ios) { - const appPath = await runExternalBuild(cancelToken, DevicePlatform.IOS, buildScript.ios, env); + const appPath = await runExternalBuild(cancelToken, buildScript.ios, env); + if (!appPath) { + throw new Error("Failed to build iOS app using custom script."); + } + + return { + appPath, + bundleID: await getBundleID(appPath), + platform: DevicePlatform.IOS, + }; + } + + if (eas?.ios) { + const appPath = await fetchEasBuild(cancelToken, eas.ios, DevicePlatform.IOS); + if (!appPath) { + throw new Error("Failed to build iOS app using EAS build."); + } return { appPath, diff --git a/packages/vscode-extension/src/builders/customBuild.ts b/packages/vscode-extension/src/builders/customBuild.ts index 6b9b1eaa9..1a9e8c1d9 100644 --- a/packages/vscode-extension/src/builders/customBuild.ts +++ b/packages/vscode-extension/src/builders/customBuild.ts @@ -1,86 +1,27 @@ import path from "path"; import fs from "fs"; -import os from "os"; -import fetch from "node-fetch"; -import { mkdtemp, readdir } from "fs/promises"; -import { finished } from "stream/promises"; - import { Logger } from "../Logger"; -import { command, exec, lineReader } from "../utilities/subprocess"; +import { command, lineReader } from "../utilities/subprocess"; import { CancelToken } from "./cancelToken"; -import { DevicePlatform } from "../common/DeviceManager"; import { getAppRootFolder } from "../utilities/extensionContext"; -type UnixTimestamp = number; - -type EasBuildUrl = { - platform: DevicePlatform; - binaryUrl: string; - completedAt: UnixTimestamp; -}; - -type IsoTimestamp = 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: "FINISHED" | "CANCELLED" | string; - 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: IsoTimestamp; - updatedAt: IsoTimestamp; - completedAt: IsoTimestamp; - expirationDate: IsoTimestamp; - isForIosSimulator: false; -}; - export async function runExternalBuild( cancelToken: CancelToken, - platform: DevicePlatform, externalCommand: string, env: Record | undefined -): Promise { - const { stdout, lastLine: binaryPath } = await runExternalScript( - cancelToken, - externalCommand, - env - ); +): Promise { + const output = await runExternalScript(cancelToken, externalCommand, env); - let easBinaryPath = await downloadAppFromEas(stdout, platform, cancelToken); - if (easBinaryPath) { - Logger.debug(`Using binary from EAS: ${easBinaryPath}`); - return easBinaryPath; + if (!output) { + return undefined; } + const binaryPath = output.lastLine; if (binaryPath && !fs.existsSync(binaryPath)) { - throw Error( + Logger.error( `External script: ${externalCommand} failed to output any existing app path, got: ${binaryPath}` ); + return undefined; } return binaryPath; @@ -101,19 +42,17 @@ async function runExternalScript( lastLine = line.trim(); }); - let stdout: string; - try { - const output = await process; - stdout = output.stdout; - } catch (error) { - throw Error(`External script: ${externalCommand} failed, error: ${error}`); + const { exitCode } = await process; + if (exitCode !== 0) { + return undefined; } if (!lastLine) { - throw Error(`External script: ${externalCommand} didn't print any output`); + Logger.error(`External script: ${externalCommand} didn't print any output`); + return undefined; } - return { stdout, lastLine }; + return { lastLine }; } function getScriptName(fullCommand: string) { @@ -121,151 +60,3 @@ function getScriptName(fullCommand: string) { const externalCommandName = fullCommand.match(escapedSpacesAwareRegex)?.[0]; return externalCommandName ? path.basename(externalCommandName) : fullCommand; } - -async function downloadAppFromEas( - processOutput: string, - platform: DevicePlatform, - cancelToken: CancelToken -) { - function isAppFile(name: string) { - return name.endsWith(".app"); - } - - const artifacts = parseEasBuildOutput(processOutput); - if (!artifacts || artifacts.length === 0) { - Logger.warn( - 'Failed to find any EAS build artifacts, ignoring. If you\'re building iOS app, make sure you set `"ios.simulator": true` option.' - ); - return undefined; - } - - const { binaryUrl } = getMostRecentBuild(artifacts, platform) ?? {}; - 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-eas-build-")); - const binaryPath = await downloadBinary(binaryUrl, tmpDirectory); - if (!binaryPath) { - Logger.warn(`Failed to download archive from ${binaryUrl}, ignoring`); - return undefined; - } - // on iOS we need to extract the .tar.gz archive to get the .app file - const shouldExtractArchive = platform === DevicePlatform.IOS; - if (!shouldExtractArchive) { - return binaryPath; - } - - const extractDir = path.dirname(binaryPath); - try { - await cancelToken.adapt(tarCommand({ archivePath: binaryPath, extractDir })); - } catch (error) { - Logger.error(`Failed to extract archive ${binaryPath} to ${extractDir}`, error); - return undefined; - } - - // assuming that the archive contains only one .app file - const appName = (await readdir(extractDir)).find(isAppFile); - if (!appName) { - Logger.error(`Failed to find .app in extracted archive ${binaryPath}`); - return undefined; - } - - const appPath = path.join(extractDir, appName); - Logger.debug("Extracted app archive to", appPath); - return appPath; -} - -function parseEasBuildOutput(stdout: string): EasBuildUrl[] | undefined { - let buildInfo: EASBuild[]; - try { - // Supports eas build, eas build:list, eas build:view - buildInfo = [JSON.parse(stdout)].flat(); - assertEasBuildOutput(buildInfo); - } catch (_e) { - // Not an EAS build output, ignore - return undefined; - } - - const platformMapping = { ANDROID: DevicePlatform.Android, IOS: DevicePlatform.IOS }; - return buildInfo - .filter(({ status, isForIosSimulator, platform }) => { - const isFinished = status === "FINISHED"; - const isUsableForDevice = (platform === "IOS" && isForIosSimulator) || platform === "ANDROID"; - - return isFinished && isUsableForDevice; - }) - .map(({ platform: easPlatform, artifacts, completedAt }) => { - return { - platform: platformMapping[easPlatform], - binaryUrl: artifacts.applicationArchiveUrl, - completedAt: Date.parse(completedAt), - }; - }); -} - -function assertEasBuildOutput(buildInfo: any): asserts buildInfo is EASBuild[] { - for (const { platform, artifacts, completedAt, status } of buildInfo) { - if (status !== "FINISHED") { - continue; - } - - const isInvalidPlatform = platform !== "ANDROID" && platform !== "IOS"; - const hasMissingFields = !artifacts || !completedAt; - if (isInvalidPlatform || hasMissingFields) { - 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; - } -} - -function getMostRecentBuild(artifacts: EasBuildUrl[], platform: DevicePlatform) { - let latestBuild: EasBuildUrl | undefined = undefined; - for (const build of artifacts) { - if (platform !== build.platform) { - continue; - } - const isLater = !latestBuild || latestBuild.completedAt < build.completedAt; - - if (isLater) { - latestBuild = build; - } - } - return latestBuild; -} - -type TarCommandArgs = { archivePath: string; extractDir: string }; -function tarCommand({ archivePath, extractDir }: TarCommandArgs) { - return exec("tar", ["-xf", archivePath, "-C", extractDir]); -} diff --git a/packages/vscode-extension/src/builders/eas.ts b/packages/vscode-extension/src/builders/eas.ts new file mode 100644 index 000000000..b811df1b4 --- /dev/null +++ b/packages/vscode-extension/src/builders/eas.ts @@ -0,0 +1,104 @@ +import path from "path"; +import os from "os"; +import { mkdtemp, readdir } from "fs/promises"; +import maxBy from "lodash/maxBy"; + +import { DevicePlatform } from "../common/DeviceManager"; +import { EasConfig } from "../common/LaunchConfig"; +import { Logger } from "../Logger"; +import { CancelToken } from "./cancelToken"; +import { exec } from "../utilities/subprocess"; +import { downloadBinary } from "../utilities/common"; +import { listEasBuilds, viewEasBuild } from "./easCommand"; + +export async function fetchEasBuild( + cancelToken: CancelToken, + config: EasConfig, + platform: DevicePlatform +): Promise { + const binaryUrl = await fetchBuildUrl(config, platform); + if (!binaryUrl) { + return undefined; + } + + let easBinaryPath = await downloadAppFromEas(binaryUrl, platform, cancelToken); + if (!easBinaryPath) { + return undefined; + } + + Logger.debug(`Using built app from EAS: ${easBinaryPath}`); + return easBinaryPath; +} + +async function fetchBuildUrl(config: EasConfig, platform: DevicePlatform) { + switch (config.useBuildType) { + case "latest": { + const builds = await listEasBuilds(platform, config.profile); + if (!builds || builds.length === 0) { + Logger.warn( + `Failed to find any EAS build artifacts for ${platform} with ${config.profile} profile. If you're building iOS app, make sure you set '"ios.simulator": true' option in eas.json.` + ); + return undefined; + } + return maxBy(builds, "completedAt")!.binaryUrl; + } + case "id": { + const build = await viewEasBuild(config.buildUUID, platform); + if (!build) { + Logger.warn( + `Failed to find EAS build artifact with ID ${config.buildUUID} for platform ${platform}.` + ); + return undefined; + } + return build.binaryUrl; + } + } +} + +async function downloadAppFromEas( + binaryUrl: string, + platform: DevicePlatform, + cancelToken: CancelToken +) { + function isAppFile(name: string) { + return name.endsWith(".app"); + } + + const tmpDirectory = await mkdtemp(path.join(os.tmpdir(), "rn-ide-eas-build-")); + // URL should be in format "https://expo.dev/artifacts/eas/ID.apk", where ID + // is unique identifier. + const binaryPath = await downloadBinary(binaryUrl, tmpDirectory); + if (!binaryPath) { + Logger.warn(`Failed to download archive from '${binaryUrl}'.`); + return undefined; + } + // on iOS we need to extract the .tar.gz archive to get the .app file + const shouldExtractArchive = platform === DevicePlatform.IOS; + if (!shouldExtractArchive) { + return binaryPath; + } + + const extractDir = path.dirname(binaryPath); + try { + await cancelToken.adapt(tarCommand({ archivePath: binaryPath, extractDir })); + } catch (error) { + Logger.error(`Failed to extract archive '${binaryPath}' to '${extractDir}'.`, error); + return undefined; + } + + // assuming that the archive contains only one .app file + const appName = (await readdir(extractDir)).find(isAppFile); + if (!appName) { + Logger.error(`Failed to find .app in extracted archive '${binaryPath}'.`); + return undefined; + } + + const appPath = path.join(extractDir, appName); + Logger.debug(`Extracted app archive to '${appPath}'.`); + return appPath; +} + +type TarCommandArgs = { archivePath: string; extractDir: string }; +function tarCommand({ archivePath, extractDir }: TarCommandArgs) { + return exec("tar", ["-xf", archivePath, "-C", extractDir]); +} diff --git a/packages/vscode-extension/src/builders/easCommand.ts b/packages/vscode-extension/src/builders/easCommand.ts new file mode 100644 index 000000000..382455a2d --- /dev/null +++ b/packages/vscode-extension/src/builders/easCommand.ts @@ -0,0 +1,96 @@ +import { DevicePlatform } from "../common/DeviceManager"; +import { exec } from "../utilities/subprocess"; + +type UnixTimestamp = number; + +export type EASBuild = { + platform: DevicePlatform; + binaryUrl: string; + appVersion: string; + completedAt: UnixTimestamp; +}; + +type IsoTimestamp = string; // e.g. "2024-09-04T09:44:07.001Z" +type UUID = string; +type Version = string; // e.g. "50.0.0" + +type EASBuildJson = { + id: string; + status: "FINISHED" | "CANCELLED" | string; + 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: IsoTimestamp; + updatedAt: IsoTimestamp; + completedAt: IsoTimestamp; + expirationDate: IsoTimestamp; + isForIosSimulator: false; +}; + +export async function listEasBuilds(platform: DevicePlatform, profile: string) { + const platformMapping = { [DevicePlatform.Android]: "android", [DevicePlatform.IOS]: "ios" }; + + const { stdout } = await exec("eas", [ + "build:list", + "--non-interactive", + "--json", + "--platform", + platformMapping[platform], + "--profile", + profile, + ]); + return parseEasBuildOutput(stdout, platform); +} + +export async function viewEasBuild(buildUUID: UUID, platform: DevicePlatform) { + const { stdout } = await exec("eas", ["build:view", buildUUID, "--json"]); + return parseEasBuildOutput(stdout, platform)?.at(0); +} + +function parseEasBuildOutput(stdout: string, platform: DevicePlatform): EASBuild[] | undefined { + const platformMapping = { ANDROID: DevicePlatform.Android, IOS: DevicePlatform.IOS }; + + let buildInfo: EASBuildJson[]; + // Supports eas build, eas build:list, eas build:view outputs + buildInfo = [JSON.parse(stdout)].flat(); + + return buildInfo + .filter(({ status, isForIosSimulator, platform: easPlatform }) => { + const isFinished = status === "FINISHED"; + const isUsableForDevice = + (easPlatform === "IOS" && isForIosSimulator) || easPlatform === "ANDROID"; + + return isFinished && isUsableForDevice && platformMapping[easPlatform] === platform; + }) + .map(({ platform: easPlatform, artifacts, completedAt, appVersion }) => { + return { + platform: platformMapping[easPlatform], + binaryUrl: artifacts.applicationArchiveUrl, + appVersion, + completedAt: Date.parse(completedAt), + }; + }); +} diff --git a/packages/vscode-extension/src/common/LaunchConfig.ts b/packages/vscode-extension/src/common/LaunchConfig.ts index 5e22efa55..8acad3993 100644 --- a/packages/vscode-extension/src/common/LaunchConfig.ts +++ b/packages/vscode-extension/src/common/LaunchConfig.ts @@ -1,3 +1,7 @@ +export type EasConfig = + | { profile: string; useBuildType: "latest" } + | { profile: string; useBuildType: "id"; buildUUID: string }; + export type LaunchConfigurationOptions = { appRoot?: string; metroConfigPath?: string; @@ -5,6 +9,10 @@ export type LaunchConfigurationOptions = { ios?: string; android?: string; }; + eas?: { + ios?: EasConfig; + android?: EasConfig; + }; env?: Record; ios?: { scheme?: string; @@ -35,9 +43,7 @@ export type LaunchConfigUpdater = ( export interface LaunchConfig { getConfig(): Promise; - update: LaunchConfigUpdater; - getAvailableXcodeSchemes(): Promise; addListener( eventType: K, diff --git a/packages/vscode-extension/src/utilities/common.ts b/packages/vscode-extension/src/utilities/common.ts index 186377bfb..4564e7c5f 100644 --- a/packages/vscode-extension/src/utilities/common.ts +++ b/packages/vscode-extension/src/utilities/common.ts @@ -2,10 +2,9 @@ import os from "os"; import fs from "fs"; import { createHash, Hash } from "crypto"; import path, { join } from "path"; -import { Readable } from "stream"; import { finished } from "stream/promises"; -import { ReadableStream } from "stream/web"; import { workspace } from "vscode"; +import fetch from "node-fetch"; import { Logger } from "../Logger"; export const ANDROID_FAIL_ERROR_MESSAGE = "Android failed."; @@ -158,6 +157,38 @@ function isPidRunning(pid: number) { } } +export async function downloadBinary(url: string, directory: string) { + 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); + if (!result.body) { + return undefined; + } + 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; + } +} + async function calculateFileMD5(filePath: string, hash: Hash) { const BUFFER_SIZE = 8192; const fd = fs.openSync(filePath, "r"); diff --git a/packages/vscode-extension/src/utilities/launchConfiguration.ts b/packages/vscode-extension/src/utilities/launchConfiguration.ts index 77b0f7449..c72df305a 100644 --- a/packages/vscode-extension/src/utilities/launchConfiguration.ts +++ b/packages/vscode-extension/src/utilities/launchConfiguration.ts @@ -1,10 +1,41 @@ import { workspace } from "vscode"; import { LaunchConfigurationOptions } from "../common/LaunchConfig"; -export function getLaunchConfiguration(): LaunchConfigurationOptions { - return ( - workspace.getConfiguration("launch")?.configurations?.find( - (config: any) => config.type === "react-native-ide" || config.type === "radon-ide" // we keep previous type name for compatibility with old configuration files - ) || {} - ); +function isIdeConfig(config: any) { + return config.type === "react-native-ide" || config.type === "radon-ide"; // we keep previous type name for compatibility with old configuration files +} + +function assertValidConfig({ buildScript, eas }: LaunchConfigurationOptions) { + for (const platform of ["ios", "android"] as const) { + const buildScriptConfig = buildScript?.[platform]; + const easConfig = eas?.[platform]; + + const illegalCustomBuildConfig = buildScriptConfig && easConfig; + if (illegalCustomBuildConfig) { + throw new Error( + `RN IDE doesn't support both custom build scripts and EAS Build configuration (${platform}). Please remove one of them.` + ); + } + + const missingBuildId = easConfig?.useBuildType === "id" && easConfig?.buildUUID === undefined; + if (missingBuildId) { + throw new Error( + `EAS Build configuration for ${platform} is missing buildUUID (using id for selecting build).` + ); + } + } +} + +export function getLaunchConfiguration() { + const ideConfig: LaunchConfigurationOptions = workspace + .getConfiguration("launch") + ?.configurations?.find(isIdeConfig); + + if (!ideConfig) { + return {}; + } + + assertValidConfig(ideConfig); + + return ideConfig; } From 36c08f4c4fc8cc9f7a7a03d6853b6bf5c735101f Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Mon, 30 Sep 2024 21:20:39 +0200 Subject: [PATCH 13/21] Update docs --- packages/docs/docs/launch-configuration.md | 55 ++++++++++++++++------ 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/docs/docs/launch-configuration.md b/packages/docs/docs/launch-configuration.md index 8e95ca8ad..25b8865d1 100644 --- a/packages/docs/docs/launch-configuration.md +++ b/packages/docs/docs/launch-configuration.md @@ -126,24 +126,43 @@ Below is an example of how the `launch.json` file could look like with android v ### Custom build settings -Instead of letting Radon IDE build your app, you can use scripts or [Expo -Application Services (EAS)](https://expo.dev/eas) to do -it with `buildScript` configuration option. +Instead of letting Radon IDE build your app, you can use scripts (`buildScript` option) or [Expo +Application Services (EAS)](https://expo.dev/eas) (`eas` option) to do it. The requirement for scripts is to output the absolute path to the built app as the -last line of standard output. +last line of the standard output. -You can also use `eas build`, `eas build:list` or `eas build:view` with -JSON output to use builds from EAS. If multiple builds are present, Radon IDE -will take the most recent one. Builds for iOS need to have `"ios.simulator": -true` config option set. +Both `buildScript` and `eas` are objects having `ios` and `android` optional +keys. You can't specify one platform in both custom script and EAS build +options. -`buildScript` configuration option is an object with `ios` and `android` keys. -Both are optional, specifying any of them will replace default build logic for -that platform with command of your choice. +`buildScript.ios` and `buildScript.android` are string keys, representing custom +command used to build the app. Example below: +```json +{ + "version": "0.2.0", + "configurations": [ + { + "type": "radon-ide", + "request": "launch", + "name": "Radon IDE panel", + "buildScript": { + "android": "npm run build:ftp-fetch-android" + } + } + ] +} +``` -Below is an example that replaces Android build with build from EAS: +`eas.ios` and `eas.android` are objects taking keys: +- `profile` – required, used for [selecting builds](https://docs.expo.dev/build/eas-json/#development-builds) suitable for running in simulators. +- `useBuildType` – required, affects how IDE will select builds from EAS. Can be + `latest` which will use the most recent build with matching platform and profile or `id` which + will use build with matching UUID. If no matching builds are found, IDE will + show an error. +- `buildUUID` – required when using `useBuildType=id`, selects build to use. +Below is an example that replaces iOS and Android local builds with builds from EAS: ```json { "version": "0.2.0", @@ -152,8 +171,16 @@ Below is an example that replaces Android build with build from EAS: "type": "radon-ide", "request": "launch", "name": "Radon IDE panel", - "buildScript": { - "android": "eas build:list --non-interactive --json --platform android" + "eas": { + "ios": { + "profile": "development", + "useBuildType": "latest" + }, + "android": { + "profile": "development", + "useBuildType": "id", + "buildUUID": "40466d0a-96fa-5d9c-80db-d055e78023bd" + } } } ] From 22baf8e235a41e12f50a6d85094f121dea6eb109 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Mon, 30 Sep 2024 22:14:52 +0200 Subject: [PATCH 14/21] Handle expired builds --- packages/vscode-extension/src/builders/eas.ts | 16 ++++++++-- .../src/builders/easCommand.ts | 31 ++++++++++++------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/packages/vscode-extension/src/builders/eas.ts b/packages/vscode-extension/src/builders/eas.ts index b811df1b4..1f2eb40df 100644 --- a/packages/vscode-extension/src/builders/eas.ts +++ b/packages/vscode-extension/src/builders/eas.ts @@ -35,21 +35,31 @@ async function fetchBuildUrl(config: EasConfig, platform: DevicePlatform) { case "latest": { const builds = await listEasBuilds(platform, config.profile); if (!builds || builds.length === 0) { - Logger.warn( + Logger.error( `Failed to find any EAS build artifacts for ${platform} with ${config.profile} profile. If you're building iOS app, make sure you set '"ios.simulator": true' option in eas.json.` ); return undefined; } + if (builds.every((build) => build.expired)) { + Logger.error( + `All EAS build artifacts for ${platform} with ${config.profile} profile have expired.` + ); + return undefined; + } return maxBy(builds, "completedAt")!.binaryUrl; } case "id": { const build = await viewEasBuild(config.buildUUID, platform); if (!build) { - Logger.warn( + Logger.error( `Failed to find EAS build artifact with ID ${config.buildUUID} for platform ${platform}.` ); return undefined; } + if (build.expired) { + Logger.error(`EAS build artifact with ID ${config.buildUUID} has expired.`); + return undefined; + } return build.binaryUrl; } } @@ -69,7 +79,7 @@ async function downloadAppFromEas( // is unique identifier. const binaryPath = await downloadBinary(binaryUrl, tmpDirectory); if (!binaryPath) { - Logger.warn(`Failed to download archive from '${binaryUrl}'.`); + Logger.error(`Failed to download archive from '${binaryUrl}'.`); return undefined; } // on iOS we need to extract the .tar.gz archive to get the .app file diff --git a/packages/vscode-extension/src/builders/easCommand.ts b/packages/vscode-extension/src/builders/easCommand.ts index 382455a2d..74c404d36 100644 --- a/packages/vscode-extension/src/builders/easCommand.ts +++ b/packages/vscode-extension/src/builders/easCommand.ts @@ -1,4 +1,5 @@ import { DevicePlatform } from "../common/DeviceManager"; +import { getAppRootFolder } from "../utilities/extensionContext"; import { exec } from "../utilities/subprocess"; type UnixTimestamp = number; @@ -8,6 +9,7 @@ export type EASBuild = { binaryUrl: string; appVersion: string; completedAt: UnixTimestamp; + expired: boolean; }; type IsoTimestamp = string; // e.g. "2024-09-04T09:44:07.001Z" @@ -53,20 +55,26 @@ type EASBuildJson = { export async function listEasBuilds(platform: DevicePlatform, profile: string) { const platformMapping = { [DevicePlatform.Android]: "android", [DevicePlatform.IOS]: "ios" }; - const { stdout } = await exec("eas", [ - "build:list", - "--non-interactive", - "--json", - "--platform", - platformMapping[platform], - "--profile", - profile, - ]); + const { stdout } = await exec( + "eas", + [ + "build:list", + "--non-interactive", + "--json", + "--platform", + platformMapping[platform], + "--profile", + profile, + ], + { cwd: getAppRootFolder() } + ); return parseEasBuildOutput(stdout, platform); } export async function viewEasBuild(buildUUID: UUID, platform: DevicePlatform) { - const { stdout } = await exec("eas", ["build:view", buildUUID, "--json"]); + const { stdout } = await exec("eas", ["build:view", buildUUID, "--json"], { + cwd: getAppRootFolder(), + }); return parseEasBuildOutput(stdout, platform)?.at(0); } @@ -85,12 +93,13 @@ function parseEasBuildOutput(stdout: string, platform: DevicePlatform): EASBuild return isFinished && isUsableForDevice && platformMapping[easPlatform] === platform; }) - .map(({ platform: easPlatform, artifacts, completedAt, appVersion }) => { + .map(({ platform: easPlatform, artifacts, completedAt, appVersion, expirationDate }) => { return { platform: platformMapping[easPlatform], binaryUrl: artifacts.applicationArchiveUrl, appVersion, completedAt: Date.parse(completedAt), + expired: Date.parse(expirationDate) < Date.now(), }; }); } From ec53947058ecabd6e42615266cd4f659da731ea7 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Mon, 30 Sep 2024 22:43:34 +0200 Subject: [PATCH 15/21] Use failed instead of exitCode --- packages/vscode-extension/src/builders/customBuild.ts | 4 ++-- packages/vscode-extension/src/builders/eas.ts | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/vscode-extension/src/builders/customBuild.ts b/packages/vscode-extension/src/builders/customBuild.ts index 1a9e8c1d9..42dfdb24a 100644 --- a/packages/vscode-extension/src/builders/customBuild.ts +++ b/packages/vscode-extension/src/builders/customBuild.ts @@ -42,8 +42,8 @@ async function runExternalScript( lastLine = line.trim(); }); - const { exitCode } = await process; - if (exitCode !== 0) { + const { failed } = await process; + if (failed) { return undefined; } diff --git a/packages/vscode-extension/src/builders/eas.ts b/packages/vscode-extension/src/builders/eas.ts index 1f2eb40df..0bdace2bc 100644 --- a/packages/vscode-extension/src/builders/eas.ts +++ b/packages/vscode-extension/src/builders/eas.ts @@ -46,6 +46,7 @@ async function fetchBuildUrl(config: EasConfig, platform: DevicePlatform) { ); return undefined; } + return maxBy(builds, "completedAt")!.binaryUrl; } case "id": { @@ -60,6 +61,7 @@ async function fetchBuildUrl(config: EasConfig, platform: DevicePlatform) { Logger.error(`EAS build artifact with ID ${config.buildUUID} has expired.`); return undefined; } + return build.binaryUrl; } } @@ -89,10 +91,9 @@ async function downloadAppFromEas( } const extractDir = path.dirname(binaryPath); - try { - await cancelToken.adapt(tarCommand({ archivePath: binaryPath, extractDir })); - } catch (error) { - Logger.error(`Failed to extract archive '${binaryPath}' to '${extractDir}'.`, error); + const { failed } = await cancelToken.adapt(tarCommand({ archivePath: binaryPath, extractDir })); + if (failed) { + Logger.error(`Failed to extract archive '${binaryPath}' to '${extractDir}'.`); return undefined; } From 4eb33035155422f5528a48a08771f5055242f778 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Mon, 30 Sep 2024 22:56:24 +0200 Subject: [PATCH 16/21] Improve logs --- packages/vscode-extension/src/builders/eas.ts | 2 +- packages/vscode-extension/src/utilities/launchConfiguration.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vscode-extension/src/builders/eas.ts b/packages/vscode-extension/src/builders/eas.ts index 0bdace2bc..6bee3ed54 100644 --- a/packages/vscode-extension/src/builders/eas.ts +++ b/packages/vscode-extension/src/builders/eas.ts @@ -26,7 +26,7 @@ export async function fetchEasBuild( return undefined; } - Logger.debug(`Using built app from EAS: ${easBinaryPath}`); + Logger.debug(`Using built app from EAS: '${easBinaryPath}'`); return easBinaryPath; } diff --git a/packages/vscode-extension/src/utilities/launchConfiguration.ts b/packages/vscode-extension/src/utilities/launchConfiguration.ts index c72df305a..5f4387e18 100644 --- a/packages/vscode-extension/src/utilities/launchConfiguration.ts +++ b/packages/vscode-extension/src/utilities/launchConfiguration.ts @@ -20,7 +20,7 @@ function assertValidConfig({ buildScript, eas }: LaunchConfigurationOptions) { const missingBuildId = easConfig?.useBuildType === "id" && easConfig?.buildUUID === undefined; if (missingBuildId) { throw new Error( - `EAS Build configuration for ${platform} is missing buildUUID (using id for selecting build).` + `EAS Build configuration for ${platform} is missing 'buildUUID' (using "id" for selecting build).` ); } } From 9e93bdbb08910d52e97b98d1642555305a965d8c Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Mon, 30 Sep 2024 22:59:57 +0200 Subject: [PATCH 17/21] Move helper functions to the bottom --- .../src/utilities/launchConfiguration.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/vscode-extension/src/utilities/launchConfiguration.ts b/packages/vscode-extension/src/utilities/launchConfiguration.ts index 5f4387e18..3ed9eb340 100644 --- a/packages/vscode-extension/src/utilities/launchConfiguration.ts +++ b/packages/vscode-extension/src/utilities/launchConfiguration.ts @@ -1,6 +1,20 @@ import { workspace } from "vscode"; import { LaunchConfigurationOptions } from "../common/LaunchConfig"; +export function getLaunchConfiguration() { + const ideConfig: LaunchConfigurationOptions = workspace + .getConfiguration("launch") + ?.configurations?.find(isIdeConfig); + + if (!ideConfig) { + return {}; + } + + assertValidConfig(ideConfig); + + return ideConfig; +} + function isIdeConfig(config: any) { return config.type === "react-native-ide" || config.type === "radon-ide"; // we keep previous type name for compatibility with old configuration files } @@ -25,17 +39,3 @@ function assertValidConfig({ buildScript, eas }: LaunchConfigurationOptions) { } } } - -export function getLaunchConfiguration() { - const ideConfig: LaunchConfigurationOptions = workspace - .getConfiguration("launch") - ?.configurations?.find(isIdeConfig); - - if (!ideConfig) { - return {}; - } - - assertValidConfig(ideConfig); - - return ideConfig; -} From 259cb6e18b5c5dbe5203be86941837af8a2f6e32 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Wed, 2 Oct 2024 20:52:42 +0200 Subject: [PATCH 18/21] Remove useBuildType option --- packages/vscode-extension/package.json | 22 +------ .../src/builders/buildAndroid.ts | 6 ++ .../vscode-extension/src/builders/buildIOS.ts | 6 ++ packages/vscode-extension/src/builders/eas.ts | 59 +++++++++---------- .../src/common/LaunchConfig.ts | 4 +- .../src/utilities/launchConfiguration.ts | 32 +--------- 6 files changed, 45 insertions(+), 84 deletions(-) diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index ec407fab2..97c31fbd2 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -234,22 +234,13 @@ "type": "string", "description": "Profile used to build the app in EAS. Built app should be in debug version." }, - "useBuildType": { - "type": "string", - "enum": [ - "latest", - "id" - ], - "description": "Used to select build from EAS. Values: `latest` (uses latest built app) or `id` (uses specific build, identified by UUID)." - }, "buildUUID": { "type": "string", "description": "UUID of the EAS build used in `eas build:view` command." } }, "required": [ - "profile", - "useBuildType" + "profile" ] }, "ios": { @@ -260,22 +251,13 @@ "type": "string", "description": "Profile used to build the app in EAS. Built app should be in debug version and have '\"ios.simulator\": true' option set." }, - "useBuildType": { - "type": "string", - "enum": [ - "latest", - "id" - ], - "description": "Used to select build from EAS. Values: `latest` (uses latest built app) or `id` (uses specific build, identified by UUID)." - }, "buildUUID": { "type": "string", "description": "UUID of the EAS build used in `eas build:view` command." } }, "required": [ - "profile", - "useBuildType" + "profile" ] } } diff --git a/packages/vscode-extension/src/builders/buildAndroid.ts b/packages/vscode-extension/src/builders/buildAndroid.ts index cdedb10af..58606e8df 100644 --- a/packages/vscode-extension/src/builders/buildAndroid.ts +++ b/packages/vscode-extension/src/builders/buildAndroid.ts @@ -80,6 +80,12 @@ export async function buildAndroid( ): Promise { const { buildScript, eas, env, android } = getLaunchConfiguration(); + if (buildScript?.android && eas?.android) { + throw new Error( + "Both custom build script and EAS build are configured for Android. Please use only one build method." + ); + } + if (buildScript?.android) { const apkPath = await runExternalBuild(cancelToken, buildScript.android, env); if (!apkPath) { diff --git a/packages/vscode-extension/src/builders/buildIOS.ts b/packages/vscode-extension/src/builders/buildIOS.ts index b0d0632bd..650c93618 100644 --- a/packages/vscode-extension/src/builders/buildIOS.ts +++ b/packages/vscode-extension/src/builders/buildIOS.ts @@ -90,6 +90,12 @@ export async function buildIos( ): Promise { const { buildScript, eas, ios: buildOptions, env } = getLaunchConfiguration(); + if (buildScript?.ios && eas?.ios) { + throw new Error( + "Both custom build script and EAS build are configured for iOS. Please use only one build method." + ); + } + if (buildScript?.ios) { const appPath = await runExternalBuild(cancelToken, buildScript.ios, env); if (!appPath) { diff --git a/packages/vscode-extension/src/builders/eas.ts b/packages/vscode-extension/src/builders/eas.ts index 6bee3ed54..f77ead56c 100644 --- a/packages/vscode-extension/src/builders/eas.ts +++ b/packages/vscode-extension/src/builders/eas.ts @@ -31,40 +31,37 @@ export async function fetchEasBuild( } async function fetchBuildUrl(config: EasConfig, platform: DevicePlatform) { - switch (config.useBuildType) { - case "latest": { - const builds = await listEasBuilds(platform, config.profile); - if (!builds || builds.length === 0) { - Logger.error( - `Failed to find any EAS build artifacts for ${platform} with ${config.profile} profile. If you're building iOS app, make sure you set '"ios.simulator": true' option in eas.json.` - ); - return undefined; - } - if (builds.every((build) => build.expired)) { - Logger.error( - `All EAS build artifacts for ${platform} with ${config.profile} profile have expired.` - ); - return undefined; - } - - return maxBy(builds, "completedAt")!.binaryUrl; + if (config.buildUUID) { + const build = await viewEasBuild(config.buildUUID, platform); + if (!build) { + Logger.error( + `Failed to find EAS build artifact with ID ${config.buildUUID} for platform ${platform}.` + ); + return undefined; } - case "id": { - const build = await viewEasBuild(config.buildUUID, platform); - if (!build) { - Logger.error( - `Failed to find EAS build artifact with ID ${config.buildUUID} for platform ${platform}.` - ); - return undefined; - } - if (build.expired) { - Logger.error(`EAS build artifact with ID ${config.buildUUID} has expired.`); - return undefined; - } - - return build.binaryUrl; + if (build.expired) { + Logger.error(`EAS build artifact with ID ${config.buildUUID} has expired.`); + return undefined; } + + return build.binaryUrl; } + + const builds = await listEasBuilds(platform, config.profile); + if (!builds || builds.length === 0) { + Logger.error( + `Failed to find any EAS build artifacts for ${platform} with ${config.profile} profile. If you're building iOS app, make sure you set '"ios.simulator": true' option in eas.json.` + ); + return undefined; + } + if (builds.every((build) => build.expired)) { + Logger.error( + `All EAS build artifacts for ${platform} with ${config.profile} profile have expired.` + ); + return undefined; + } + + return maxBy(builds, "completedAt")!.binaryUrl; } async function downloadAppFromEas( diff --git a/packages/vscode-extension/src/common/LaunchConfig.ts b/packages/vscode-extension/src/common/LaunchConfig.ts index 8acad3993..a2c25d6e1 100644 --- a/packages/vscode-extension/src/common/LaunchConfig.ts +++ b/packages/vscode-extension/src/common/LaunchConfig.ts @@ -1,6 +1,4 @@ -export type EasConfig = - | { profile: string; useBuildType: "latest" } - | { profile: string; useBuildType: "id"; buildUUID: string }; +export type EasConfig = { profile: string; buildUUID?: string }; export type LaunchConfigurationOptions = { appRoot?: string; diff --git a/packages/vscode-extension/src/utilities/launchConfiguration.ts b/packages/vscode-extension/src/utilities/launchConfiguration.ts index 3ed9eb340..abf86d61f 100644 --- a/packages/vscode-extension/src/utilities/launchConfiguration.ts +++ b/packages/vscode-extension/src/utilities/launchConfiguration.ts @@ -2,15 +2,8 @@ import { workspace } from "vscode"; import { LaunchConfigurationOptions } from "../common/LaunchConfig"; export function getLaunchConfiguration() { - const ideConfig: LaunchConfigurationOptions = workspace - .getConfiguration("launch") - ?.configurations?.find(isIdeConfig); - - if (!ideConfig) { - return {}; - } - - assertValidConfig(ideConfig); + const ideConfig: LaunchConfigurationOptions = + workspace.getConfiguration("launch")?.configurations?.find(isIdeConfig) ?? {}; return ideConfig; } @@ -18,24 +11,3 @@ export function getLaunchConfiguration() { function isIdeConfig(config: any) { return config.type === "react-native-ide" || config.type === "radon-ide"; // we keep previous type name for compatibility with old configuration files } - -function assertValidConfig({ buildScript, eas }: LaunchConfigurationOptions) { - for (const platform of ["ios", "android"] as const) { - const buildScriptConfig = buildScript?.[platform]; - const easConfig = eas?.[platform]; - - const illegalCustomBuildConfig = buildScriptConfig && easConfig; - if (illegalCustomBuildConfig) { - throw new Error( - `RN IDE doesn't support both custom build scripts and EAS Build configuration (${platform}). Please remove one of them.` - ); - } - - const missingBuildId = easConfig?.useBuildType === "id" && easConfig?.buildUUID === undefined; - if (missingBuildId) { - throw new Error( - `EAS Build configuration for ${platform} is missing 'buildUUID' (using "id" for selecting build).` - ); - } - } -} From ad34172be8e1f1ad7a288b52d1a07d0880eef73e Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Wed, 2 Oct 2024 20:55:26 +0200 Subject: [PATCH 19/21] Update docs --- packages/docs/docs/launch-configuration.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/docs/docs/launch-configuration.md b/packages/docs/docs/launch-configuration.md index 25b8865d1..6b85716dd 100644 --- a/packages/docs/docs/launch-configuration.md +++ b/packages/docs/docs/launch-configuration.md @@ -156,11 +156,8 @@ command used to build the app. Example below: `eas.ios` and `eas.android` are objects taking keys: - `profile` – required, used for [selecting builds](https://docs.expo.dev/build/eas-json/#development-builds) suitable for running in simulators. -- `useBuildType` – required, affects how IDE will select builds from EAS. Can be - `latest` which will use the most recent build with matching platform and profile or `id` which - will use build with matching UUID. If no matching builds are found, IDE will - show an error. -- `buildUUID` – required when using `useBuildType=id`, selects build to use. +- `buildUUID` – when specified, downloads build using its UUID. It uses latest + build otherwise. Below is an example that replaces iOS and Android local builds with builds from EAS: ```json @@ -174,11 +171,9 @@ Below is an example that replaces iOS and Android local builds with builds from "eas": { "ios": { "profile": "development", - "useBuildType": "latest" }, "android": { "profile": "development", - "useBuildType": "id", "buildUUID": "40466d0a-96fa-5d9c-80db-d055e78023bd" } } From d6802358250f62b54ca80e0d284074621e50379e Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Wed, 2 Oct 2024 21:13:56 +0200 Subject: [PATCH 20/21] Cleanup archive extraction --- packages/vscode-extension/src/builders/eas.ts | 41 +++++++++++-------- .../src/builders/easCommand.ts | 4 +- .../vscode-extension/src/utilities/common.ts | 27 +++++------- 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/packages/vscode-extension/src/builders/eas.ts b/packages/vscode-extension/src/builders/eas.ts index f77ead56c..94d428863 100644 --- a/packages/vscode-extension/src/builders/eas.ts +++ b/packages/vscode-extension/src/builders/eas.ts @@ -9,19 +9,19 @@ import { Logger } from "../Logger"; import { CancelToken } from "./cancelToken"; import { exec } from "../utilities/subprocess"; import { downloadBinary } from "../utilities/common"; -import { listEasBuilds, viewEasBuild } from "./easCommand"; +import { EASBuild, listEasBuilds, viewEasBuild } from "./easCommand"; export async function fetchEasBuild( cancelToken: CancelToken, config: EasConfig, platform: DevicePlatform ): Promise { - const binaryUrl = await fetchBuildUrl(config, platform); - if (!binaryUrl) { + const build = await fetchBuild(config, platform); + if (!build) { return undefined; } - let easBinaryPath = await downloadAppFromEas(binaryUrl, platform, cancelToken); + let easBinaryPath = await downloadAppFromEas(build, platform, cancelToken); if (!easBinaryPath) { return undefined; } @@ -30,7 +30,7 @@ export async function fetchEasBuild( return easBinaryPath; } -async function fetchBuildUrl(config: EasConfig, platform: DevicePlatform) { +async function fetchBuild(config: EasConfig, platform: DevicePlatform) { if (config.buildUUID) { const build = await viewEasBuild(config.buildUUID, platform); if (!build) { @@ -44,7 +44,8 @@ async function fetchBuildUrl(config: EasConfig, platform: DevicePlatform) { return undefined; } - return build.binaryUrl; + Logger.debug(`Using EAS build artifact with ID ${build.id}.`); + return build; } const builds = await listEasBuilds(platform, config.profile); @@ -61,11 +62,14 @@ async function fetchBuildUrl(config: EasConfig, platform: DevicePlatform) { return undefined; } - return maxBy(builds, "completedAt")!.binaryUrl; + const build = maxBy(builds, "completedAt")!; + + Logger.debug(`Using EAS build artifact with ID ${build.id}.`); + return build; } async function downloadAppFromEas( - binaryUrl: string, + build: EASBuild, platform: DevicePlatform, cancelToken: CancelToken ) { @@ -73,11 +77,13 @@ async function downloadAppFromEas( return name.endsWith(".app"); } + const { id, binaryUrl } = build; + const tmpDirectory = await mkdtemp(path.join(os.tmpdir(), "rn-ide-eas-build-")); - // URL should be in format "https://expo.dev/artifacts/eas/ID.apk", where ID - // is unique identifier. - const binaryPath = await downloadBinary(binaryUrl, tmpDirectory); - if (!binaryPath) { + const binaryPath = path.join(tmpDirectory, id); + + const success = await downloadBinary(binaryUrl, binaryPath); + if (!success) { Logger.error(`Failed to download archive from '${binaryUrl}'.`); return undefined; } @@ -87,21 +93,22 @@ async function downloadAppFromEas( return binaryPath; } - const extractDir = path.dirname(binaryPath); - const { failed } = await cancelToken.adapt(tarCommand({ archivePath: binaryPath, extractDir })); + const { failed } = await cancelToken.adapt( + tarCommand({ archivePath: binaryPath, extractDir: tmpDirectory }) + ); if (failed) { - Logger.error(`Failed to extract archive '${binaryPath}' to '${extractDir}'.`); + Logger.error(`Failed to extract archive '${binaryPath}' to '${tmpDirectory}'.`); return undefined; } // assuming that the archive contains only one .app file - const appName = (await readdir(extractDir)).find(isAppFile); + const appName = (await readdir(tmpDirectory)).find(isAppFile); if (!appName) { Logger.error(`Failed to find .app in extracted archive '${binaryPath}'.`); return undefined; } - const appPath = path.join(extractDir, appName); + const appPath = path.join(tmpDirectory, appName); Logger.debug(`Extracted app archive to '${appPath}'.`); return appPath; } diff --git a/packages/vscode-extension/src/builders/easCommand.ts b/packages/vscode-extension/src/builders/easCommand.ts index 74c404d36..aa63aca39 100644 --- a/packages/vscode-extension/src/builders/easCommand.ts +++ b/packages/vscode-extension/src/builders/easCommand.ts @@ -5,6 +5,7 @@ import { exec } from "../utilities/subprocess"; type UnixTimestamp = number; export type EASBuild = { + id: string; platform: DevicePlatform; binaryUrl: string; appVersion: string; @@ -93,8 +94,9 @@ function parseEasBuildOutput(stdout: string, platform: DevicePlatform): EASBuild return isFinished && isUsableForDevice && platformMapping[easPlatform] === platform; }) - .map(({ platform: easPlatform, artifacts, completedAt, appVersion, expirationDate }) => { + .map(({ id, platform: easPlatform, artifacts, completedAt, appVersion, expirationDate }) => { return { + id, platform: platformMapping[easPlatform], binaryUrl: artifacts.applicationArchiveUrl, appVersion, diff --git a/packages/vscode-extension/src/utilities/common.ts b/packages/vscode-extension/src/utilities/common.ts index 4564e7c5f..2f8679069 100644 --- a/packages/vscode-extension/src/utilities/common.ts +++ b/packages/vscode-extension/src/utilities/common.ts @@ -157,36 +157,29 @@ function isPidRunning(pid: number) { } } -export async function downloadBinary(url: string, directory: string) { - const filename = url.split("/").pop(); - const hasInvalidFormat = !filename; - if (hasInvalidFormat) { - return undefined; - } - +export async function downloadBinary(url: string, destination: string) { let body: NodeJS.ReadableStream; let ok: boolean; try { const result = await fetch(url); if (!result.body) { - return undefined; + return false; } body = result.body; ok = result.ok; } catch (_e) { // Network error - return undefined; + return false; } - 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; + if (!ok) { + return false; } + + const fileStream = fs.createWriteStream(destination, { flags: "w" }); + await finished(body.pipe(fileStream)); + + return true; } async function calculateFileMD5(filePath: string, hash: Hash) { From d4a7c5604aef628dfa95c7b3caa0c73024b5983b Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Wed, 2 Oct 2024 21:20:49 +0200 Subject: [PATCH 21/21] Fix imports --- packages/vscode-extension/src/builders/buildIOS.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/vscode-extension/src/builders/buildIOS.ts b/packages/vscode-extension/src/builders/buildIOS.ts index 650c93618..72593052d 100644 --- a/packages/vscode-extension/src/builders/buildIOS.ts +++ b/packages/vscode-extension/src/builders/buildIOS.ts @@ -1,8 +1,5 @@ import path from "path"; -import fs from "fs"; import { OutputChannel } from "vscode"; -import path from "path"; - import { exec, lineReader } from "../utilities/subprocess"; import { Logger } from "../Logger"; import { CancelToken } from "./cancelToken";