Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 115 additions & 46 deletions script/react-native-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import * as chalk from "chalk";
import * as path from "path";
import * as childProcess from "child_process";
import { coerce, compare, gte, valid } from "semver";
import { downloadBlob, extractArchive, fileDoesNotExistOrIsDirectory } from "./utils/file-utils";
import { downloadBlob, extractArchive, fileDoesNotExistOrIsDirectory, fileExists } from "./utils/file-utils";
import { evaluateKotlinDslReactConfig, resolveGradleBuildFile } from "./utils/gradle-utils";
import * as dotenv from "dotenv";
import { DotenvParseOutput } from "dotenv";
import * as cli from "../script/types/cli";
Expand Down Expand Up @@ -144,12 +145,14 @@ export async function runHermesEmitBinaryCommand(
hermesArgs.push("-output-source-map");
}

const hermesCommand = await getHermesCommand(gradleFile);
const hermesCommand = await getHermesCommand(command.platform, gradleFile, command.podFile);

const resolvedBaseBytecode =
baseBytecode && resolveHermesBaseBytecode(baseBytecode, getHermesCompilerBytecodeVersion(hermesCommand));
if (resolvedBaseBytecode) {
hermesArgs.push("-base-bytecode", resolvedBaseBytecode);
if (baseBytecode) {
const compilerBytecodeVersion = getHermesCompilerBytecodeVersion(hermesCommand);
const resolvedBaseBytecode = resolveHermesBaseBytecode(baseBytecode, compilerBytecodeVersion);
if (resolvedBaseBytecode) {
hermesArgs.push("-base-bytecode", resolvedBaseBytecode);
}
}

console.log(chalk.cyan("Converting JS bundle to byte code via Hermes, running command:\n"));
Expand Down Expand Up @@ -271,7 +274,8 @@ export async function runHermesEmitBinaryCommand(
}

export function getXcodeDotEnvValue(key: string): string | undefined {
const xcodeEnvs = loadEnvAsMap([path.join("ios", ".xcode.env.local"), path.join("ios", ".xcode.env.local")]);
// Xcode sources .xcode.env, then .xcode.env.local
const xcodeEnvs = loadEnvAsMap([path.join("ios", ".xcode.env"), path.join("ios", ".xcode.env.local")]);
return xcodeEnvs.get(key);
}

Expand Down Expand Up @@ -364,18 +368,64 @@ function parseGradlePropertiesFile(gradleFile: string): Record<string, string> {
return props;
}

// Only literal paths (plus $rootDir / $projectDir) can be resolved; other Groovy expressions, like Expo's, are ignored
async function getHermesCommandFromGradle(gradleFile: string): Promise<string> {
const buildFile = resolveGradleBuildFile(gradleFile || path.join("android", "app"));
if (buildFile.endsWith(".kts")) {
return getHermesCommandFromKotlinDsl(buildFile);
}

const buildGradle: any = await parseBuildGradleFile(gradleFile);
const hermesCommandProperty: any = Array.from(buildGradle["project.ext.react"] || []).find((prop: string) =>
prop.trim().startsWith("hermesCommand:")
);
if (hermesCommandProperty) {
return hermesCommandProperty.replace("hermesCommand:", "").trim().slice(1, -1);
} else {
const gradlePath = gradleFile || path.join("android", "app");
const appDir = path.resolve(directoryExistsSync(gradlePath) ? gradlePath : path.dirname(gradlePath));
const rootDir = path.dirname(appDir);

// The gradle plugin runs hermesc from react.root (default: $rootDir/..); legacy react.gradle from the app module
let hermesCommand: string = buildGradle.react?.hermesCommand;
let workingDir = path.dirname(rootDir);
if (!hermesCommand) {
const legacyProperties: string[] = Array.from(buildGradle["project.ext.react"] || []);
const legacyProperty = legacyProperties.find((prop) => prop.trim().startsWith("hermesCommand:"));
hermesCommand = legacyProperty?.replace("hermesCommand:", "").trim().slice(1, -1);
workingDir = appDir;
}
if (!hermesCommand) {
return "";
}

hermesCommand = hermesCommand
.replace(/\$(\{rootDir\}|rootDir\b)/g, () => rootDir)
.replace(/\$(\{projectDir\}|projectDir\b)/g, () => appDir);
if (/["'$()+]/.test(hermesCommand)) {
return "";
}

return path.resolve(workingDir, hermesCommand.replace("%OS-BIN%", getHermesOSBin()));
}

// Kotlin DSL can't be parsed statically, so evaluate it with Gradle, but only if it sets hermesCommand
async function getHermesCommandFromKotlinDsl(buildFile: string): Promise<string> {
if (!/^\s*(react\.)?hermesCommand\b/m.test(fs.readFileSync(buildFile, "utf8"))) {
return "";
}

try {
const { hermesCommand, root } = await evaluateKotlinDslReactConfig(buildFile);
return hermesCommand && root ? path.resolve(root, hermesCommand.replace("%OS-BIN%", getHermesOSBin())) : "";
} catch (error) {
log(chalk.yellow(`\nWarning: ${error.message}\nUsing the default hermesc instead.\n`));
return "";
}
}

function isHermesV1EnabledInGradle(gradleFile: string): boolean {
try {
return parseGradlePropertiesFile(gradleFile).hermesV1Enabled?.toLowerCase() === "true";
} catch {
return false;
}
}

async function getAndroidHermesEnabled(gradleFile: string): Promise<boolean> {
try {
const props = parseGradlePropertiesFile(gradleFile);
Expand Down Expand Up @@ -461,45 +511,64 @@ function getHermesOSExe(): string {
}
}

async function getHermesCommand(gradleFile: string): Promise<string> {
const fileExists = (file: string): boolean => {
try {
return fs.statSync(file).isFile();
} catch (e) {
return false;
}
};
// Hermes is bundled with react-native since 0.69
// Mirrors how React Native picks hermesc, so updates are compiled like the store binary.
// Android: gradle-plugin PathUtils.detectOSAwareHermesCommand; iOS: scripts/react-native-xcode.sh
export async function getHermesCommand(platform: string, gradleFile: string, podFile: string): Promise<string> {
const reactNativePath = getReactNativePackagePath();
const bundledHermesEngine = path.join(reactNativePath, "sdks", "hermesc", getHermesOSBin(), getHermesOSExe());
if (fileExists(bundledHermesEngine)) {
return bundledHermesEngine;
}

let gradleHermesCommand = "";
try {
gradleHermesCommand = await getHermesCommandFromGradle(gradleFile);
} catch {
// Gradle files not present (e.g. iOS-only project); skip to node_modules fallback
}
if (gradleHermesCommand) {
return path.join("android", "app", gradleHermesCommand.replace("%OS-BIN%", getHermesOSBin()));
const bundledHermesc = path.join(reactNativePath, "sdks", "hermesc", getHermesOSBin(), getHermesOSExe());
const hermesCompiler = getHermesCompilerPath(reactNativePath);
let candidates: string[];

if (platform === "ios") {
// react-native-xcode.sh honors HERMES_CLI_PATH from the environment or ios/.xcode.env(.local)
const hermesCliPath = getXcodeDotEnvValue("HERMES_CLI_PATH");
if (hermesCliPath) {
return hermesCliPath;
}
const podsDir = path.join(path.dirname(podFile || path.join("ios", "Podfile")), "Pods");
const podsHermesc = path.join(podsDir, "hermes-engine", "destroot", "bin", "hermesc");

// Without Pods (e.g. CI), pick the compiler like Android: RN 0.82 uses hermes-compiler only for Hermes V1
if (process.env.RCT_HERMES_V1_ENABLED === "1") {
candidates = [podsHermesc, hermesCompiler, bundledHermesc];
} else {
candidates = [podsHermesc, bundledHermesc, hermesCompiler];
}
} else {
const nodeModulesPath = getNodeModulesPath(reactNativePath);

// assume if hermes-engine exists it should be used instead of hermesvm
const hermesEngine = path.join(nodeModulesPath, "hermes-engine", getHermesOSBin(), getHermesOSExe());
if (fileExists(hermesEngine)) {
return hermesEngine;
// A missing or unparsable gradle file just means there is no hermesCommand override
const gradleHermesCommand = await getHermesCommandFromGradle(gradleFile).catch(() => "");
if (gradleHermesCommand) {
return gradleHermesCommand;
}

// RN 0.83 hermes-compiler
const hermesCompiler = path.join(nodeModulesPath, "hermes-compiler", "hermesc", getHermesOSBin(), getHermesOSExe());
if (fileExists(hermesCompiler)) {
return hermesCompiler;
const overrideHermesDir = process.env.REACT_NATIVE_OVERRIDE_HERMES_DIR;
const sourceBuiltHermesc = overrideHermesDir
? path.join(overrideHermesDir, "build", "bin", getHermesOSExe())
: path.join(reactNativePath, "ReactAndroid", "hermes-engine", "build", "hermes", "bin", getHermesOSExe());

// RN 0.82 uses hermes-compiler only with hermesV1Enabled; RN <= 0.82 ships sdks/hermesc, RN 0.83+ only hermes-compiler
if (isHermesV1EnabledInGradle(gradleFile)) {
candidates = [sourceBuiltHermesc, hermesCompiler, bundledHermesc];
} else {
candidates = [sourceBuiltHermesc, bundledHermesc, hermesCompiler];
}
}

// RN < 0.69 shipped hermesc in the hermes-engine / hermesvm packages
const nodeModulesPath = getNodeModulesPath(reactNativePath);
candidates.push(path.join(nodeModulesPath, "hermes-engine", getHermesOSBin(), getHermesOSExe()));

return path.join(nodeModulesPath, "hermesvm", getHermesOSBin(), "hermes");
const hermesc = candidates.find((candidate) => candidate && fileExists(candidate));
return hermesc || path.join(nodeModulesPath, "hermesvm", getHermesOSBin(), "hermes");
}

// hermes-compiler is a dependency of react-native, so resolve it from there (it may not be hoisted)
function getHermesCompilerPath(reactNativePath: string): string | null {
try {
const packageJson = require.resolve("hermes-compiler/package.json", { paths: [reactNativePath] });
return path.join(path.dirname(packageJson), "hermesc", getHermesOSBin(), getHermesOSExe());
} catch {
return null;
}
}

Expand Down
56 changes: 40 additions & 16 deletions script/utils/gradle-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,28 +33,38 @@ const FALLBACK_HINT = "Pass the version explicitly with --targetBinaryVersion to

// "revopush"-prefixed to avoid collisions with project tasks; written in Groovy,
// which Gradle accepts as an init script for projects of either DSL.
const PRINT_VERSION_TASK = "_revopushPrintVersion";
const printVersionInitScript = (moduleName: string) => `
const PRINT_TASK = "_revopushPrint";
const printInitScript = (moduleName: string, printJson: string) => `
allprojects {
afterEvaluate { proj ->
if (proj.name == '${moduleName}') {
task ${PRINT_VERSION_TASK} {
task ${PRINT_TASK} {
doLast {
def android = proj.extensions.findByName('android')
if (android == null) {
throw new GradleException("Project ':${moduleName}' does not apply the Android Gradle plugin (no 'android' extension found).")
}
println groovy.json.JsonOutput.toJson([
versionName: android.defaultConfig.versionName,
versionCode: android.defaultConfig.versionCode?.toString()
])
${printJson}
}
}
}
}
}
`.trim();

const PRINT_VERSION = `
def android = proj.extensions.findByName('android')
if (android == null) {
throw new GradleException("Project ':\${proj.name}' does not apply the Android Gradle plugin (no 'android' extension found).")
}
println groovy.json.JsonOutput.toJson([
versionName: android.defaultConfig.versionName,
versionCode: android.defaultConfig.versionCode?.toString()
])`;

const PRINT_REACT_CONFIG = `
def react = proj.extensions.findByName('react')
println groovy.json.JsonOutput.toJson([
hermesCommand: react?.hermesCommand?.getOrElse(''),
root: react?.root?.get()?.asFile?.absolutePath
])`;

/** @param gradleFile build script path or its directory; defaults to "android/app". */
export async function getAndroidVersionInfo(gradleFile?: string | null): Promise<AndroidVersionInfo> {
const buildFile = resolveGradleBuildFile(gradleFile ?? path.join("android", "app"));
Expand All @@ -74,7 +84,7 @@ export async function getAndroidVersionInfo(gradleFile?: string | null): Promise
}

/** Locates the build script: the given file itself, or inside the given directory (Kotlin DSL preferred). */
function resolveGradleBuildFile(gradleFile: string): string {
export function resolveGradleBuildFile(gradleFile: string): string {
const candidates = [gradleFile, path.join(gradleFile, "build.gradle.kts"), path.join(gradleFile, "build.gradle")];
const buildFile = candidates.find(fileExists);
if (!buildFile) {
Expand Down Expand Up @@ -104,27 +114,41 @@ async function parseGroovyDslBuildFile(buildFile: string): Promise<GradleVersion
}

async function evaluateKotlinDslBuildFile(buildFile: string): Promise<GradleVersionFields> {
try {
return await runGradlePrintTask(buildFile, PRINT_VERSION);
} catch (error) {
throw new Error(`${error.message}\n${FALLBACK_HINT}`);
}
}

/** Evaluates the `react { }` extension of a Kotlin DSL build script. */
export function evaluateKotlinDslReactConfig(buildFile: string): Promise<{ hermesCommand?: string; root?: string }> {
return runGradlePrintTask(buildFile, PRINT_REACT_CONFIG);
}

/** Runs the project's Gradle wrapper with an injected task and returns the JSON it prints. */
async function runGradlePrintTask(buildFile: string, printJson: string): Promise<any> {
// The build file lives in the application module folder (typically android/app);
// its parent is the Gradle project root, and the folder name is the module name.
const moduleName = path.basename(path.dirname(path.resolve(buildFile)));
const androidDir = path.resolve(buildFile, "..", "..");
const gradlew = path.join(androidDir, process.platform === "win32" ? "gradlew.bat" : "gradlew");
if (!fileExists(gradlew)) {
throw new Error(`No Gradle wrapper found at "${gradlew}", required to evaluate "${buildFile}". ${FALLBACK_HINT}`);
throw new Error(`No Gradle wrapper found at "${gradlew}", required to evaluate "${buildFile}".`);
}

const initScript = path.join(os.tmpdir(), `revopush-init-${process.pid}.gradle`);

fs.writeFileSync(initScript, printVersionInitScript(moduleName), "utf8");
fs.writeFileSync(initScript, printInitScript(moduleName, printJson), "utf8");
try {
const { stdout } = await exec(
`"${gradlew}" --project-dir "${androidDir}" --init-script "${initScript}" -q :${moduleName}:${PRINT_VERSION_TASK}`,
`"${gradlew}" --project-dir "${androidDir}" --init-script "${initScript}" -q :${moduleName}:${PRINT_TASK}`,
{ timeout: 120000 }
);
// The task's JSON is the last line; configuration-phase output (plugin notices, printlns) may precede it.
return JSON.parse(stdout.trim().split(/\r?\n/).pop() ?? "");
} catch (error) {
throw new Error(`Gradle failed while reading the version from "${buildFile}": ${error.message}\n${FALLBACK_HINT}`);
throw new Error(`Gradle failed while evaluating "${buildFile}": ${error.message}`);
} finally {
fs.rmSync(initScript, { force: true });
}
Expand Down
Loading
Loading