Skip to content
Merged
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
176 changes: 12 additions & 164 deletions script/command-executor.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { extractMetadataFromAndroid, extractMetadataFromIOS, getIosVersion, parseAabManifest } from "./binary-utils";

const childProcess = require("child_process");
import debugCommand from "./commands/debug";
import * as fs from "fs";
import * as chalk from "chalk";
import * as os from "os";
import * as path from "path";
import * as cli from "../script/types/cli";
import sign from "./sign";
import sign from "./utils/sign";
const ApkReader = require("@devicefarmer/adbkit-apkreader");
import {
AccessKey,
Expand All @@ -25,18 +22,14 @@ import {
PackageWithMetrics,
Session,
} from "./types";
import {
getMinifyParams,
getReactNativePackagePath,
getSourceMapPaths,
runHermesEmitBinaryCommand,
SourceMapPaths,
takeHermesBaseBytecode,
} from "./react-native-utils";
import { isHermesEnabled } from "./utils/hermes-utils";
import { extractMetadataFromAndroid, parseAabManifest } from "./platforms/android/binary";
import { getAndroidVersionInfo } from "./platforms/android/gradle";
import { extractMetadataFromIOS, getIosVersion } from "./platforms/ios/binary";
import { getIosVersionInfo } from "./platforms/ios/xcode";
import { BundleOptions, runExpoExportEmbedCommand, runReactNativeBundleCommand } from "./platforms/react-native/bundler";
import { isHermesEnabled, runHermesEmitBinaryCommand, takeHermesBaseBytecode } from "./platforms/react-native/hermes";
import { getSourceMapPaths, SourceMapPaths } from "./platforms/react-native/source-maps";
import { fileDoesNotExistOrIsDirectory, fileExists, isBinaryOrZip, extractArchive } from "./utils/file-utils";
import { getAndroidVersionInfo } from "./utils/gradle-utils";
import { getIosVersionInfo } from "./utils/ios-utils";
import {
isCommandOptionSpecified,
throwForInvalidEmail,
Expand All @@ -54,7 +47,6 @@ import {

import AccountManager = require("./management-sdk");
import { ReactNativePackageInfo } from "./types/rest-definitions";
import { getExpoCliPath } from "./expo-utils";
import {
deleteConnectionInfoCache,
deserializeConnectionInfo,
Expand All @@ -77,8 +69,6 @@ import {
const opener = require("opener");

export let sdk: AccountManager;
export const spawn = childProcess.spawn;
export const execSync = childProcess.execSync;

let connectionInfo: ILoginConnectionInfo;

Expand Down Expand Up @@ -534,12 +524,10 @@ async function logout(_command: cli.ICommand): Promise<void> {
deleteConnectionInfoCache();
}

interface ProjectVersionInfo {
appVersion?: string;
buildNumber?: string;
}

async function getReactNativeProjectVersionInfo(command: cli.IReleaseReactCommand, projectName: string): Promise<ProjectVersionInfo> {
async function getReactNativeProjectVersionInfo(
command: cli.IReleaseReactCommand,
projectName: string
): Promise<cli.ProjectVersionInfo> {
log(chalk.cyan(`Detecting ${command.platform} app version:\n`));

if (command.platform === "ios") {
Expand Down Expand Up @@ -622,83 +610,6 @@ export const release = async (command: cli.IReleaseCommand): Promise<void> => {
return doRelease(command, updateMetadata);
};

export interface BundleOptions {
platform: string;
development: boolean;
outputFolder: string;
bundleName: string;
packagerSourceMap: string;
extraBundlerOptions: string[];
}

export const runExpoExportEmbedCommand = async ({
platform,
development,
outputFolder,
bundleName,
packagerSourceMap,
extraBundlerOptions,
}: BundleOptions) => {
const expoBundleArgs: string[] = [];
const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS;

if (typeof envNodeArgs !== "undefined") {
expoBundleArgs.push(...envNodeArgs.trim().split(/\s+/));
}

const expoCliPath = getExpoCliPath();

expoBundleArgs.push(
expoCliPath,
"export:embed",
"--assets-dest",
outputFolder,
"--bundle-output",
path.join(outputFolder, bundleName),
"--dev",
String(development),
"--platform",
platform,
"--minify",
"false",
"--reset-cache"
);

expoBundleArgs.push("--sourcemap-output", packagerSourceMap);

if (extraBundlerOptions.length > 0) {
expoBundleArgs.push(...extraBundlerOptions);
}

log(chalk.cyan('Running "expo export:embed" command:\n'));

const projectRoot = process.cwd();
expoBundleArgs.push(projectRoot);

log("expoBundleArgs raw:" + JSON.stringify(expoBundleArgs, null, 2));

const expoBundleProcess = spawn("node", expoBundleArgs);
log(`node ${expoBundleArgs.join(" ")}`);

return new Promise<void>((resolve, reject) => {
expoBundleProcess.stdout.on("data", (data: Buffer) => {
log(data.toString().trim());
});

expoBundleProcess.stderr.on("data", (data: Buffer) => {
console.error(data.toString().trim());
});

expoBundleProcess.on("close", (exitCode: number) => {
if (exitCode) {
reject(new Error(`"expo export:embed" command exited with code ${exitCode}.`));
}

resolve();
});
});
};

const REQUIRED_OUTPUT_DIR_NAME = "CodePush";

interface ReactReleaseInputs {
Expand Down Expand Up @@ -1132,69 +1043,6 @@ async function rollback(command: cli.IRollbackCommand): Promise<void> {
log('Successfully performed a rollback on the "' + command.deploymentName + '" deployment of the "' + command.appName + '" app.');
}

export const runReactNativeBundleCommand = async (
{ platform, development, outputFolder, bundleName, packagerSourceMap, extraBundlerOptions }: BundleOptions,
entryFile: string,
isHermes: boolean
) => {
const reactNativeBundleArgs: string[] = [];
const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS;

if (typeof envNodeArgs !== "undefined") {
reactNativeBundleArgs.push(...envNodeArgs.trim().split(/\s+/));
}

const reactNativePackagePath = getReactNativePackagePath();
const oldCliPath = path.join(reactNativePackagePath, "local-cli", "cli.js");
const cliPath = fs.existsSync(oldCliPath) ? oldCliPath : path.join(reactNativePackagePath, "cli.js");

reactNativeBundleArgs.push(
cliPath,
"bundle",
"--assets-dest",
outputFolder,
"--bundle-output",
path.join(outputFolder, bundleName),
"--dev",
String(development),
"--entry-file",
entryFile,
"--platform",
platform,
"--reset-cache"
);

reactNativeBundleArgs.push("--sourcemap-output", packagerSourceMap);

reactNativeBundleArgs.push(...getMinifyParams(platform, isHermes));

if (extraBundlerOptions.length > 0) {
reactNativeBundleArgs.push(...extraBundlerOptions);
}

log(chalk.cyan('Running "react-native bundle" command:\n'));
const reactNativeBundleProcess = spawn("node", reactNativeBundleArgs);
log(`node ${reactNativeBundleArgs.join(" ")}`);

return new Promise<void>((resolve, reject) => {
reactNativeBundleProcess.stdout.on("data", (data: Buffer) => {
log(data.toString().trim());
});

reactNativeBundleProcess.stderr.on("data", (data: Buffer) => {
console.error(data.toString().trim());
});

reactNativeBundleProcess.on("close", (exitCode: number) => {
if (exitCode) {
reject(new Error(`"react-native bundle" command exited with code ${exitCode}.`));
}

resolve();
});
});
};

async function sessionList(command: cli.ISessionListCommand): Promise<void> {
throwForInvalidOutputFormat(command.format);

Expand Down
14 changes: 0 additions & 14 deletions script/expo-utils.ts

This file was deleted.

131 changes: 7 additions & 124 deletions script/binary-utils.ts → script/platforms/android/binary.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import * as path from "path";
import * as fs from "fs";
import * as crypto from "crypto";
import * as chalk from "chalk";
import { log } from "./ui/log";
import * as os from "os";
import * as yauzl from "yauzl";
import { createZip, ZipEntry } from "./utils/file-utils";
import { readFile } from "node:fs/promises";
import { buffer as readStreamToBuffer } from "node:stream/consumers";
import * as os from "os";
import * as path from "path";
import * as chalk from "chalk";
import * as protobuf from "protobufjs";
import * as plist from "plist";
import * as bplist from "bplist-parser";
import * as yauzl from "yauzl";

import { log } from "../../ui/log";
import { createZipArchive } from "../../utils/file-utils";

export async function extractMetadataFromAndroid(extractFolder, outputFolder) {
const assetsFolder = path.join(extractFolder, "assets");
Expand Down Expand Up @@ -51,82 +49,6 @@ export async function extractMetadataFromAndroid(extractFolder, outputFolder) {
return zipPath;
}

export async function extractMetadataFromIOS(ipaPath: string, outputFolder: string) {
const { files, appPrefix, close } = await openIPA(ipaPath);

const assetsPrefix = `${appPrefix}assets/`;
const bundlePath = `${appPrefix}main.jsbundle`;

const fileHashes: { [key: string]: string } = {};
let bundleBuffer: Buffer | null = null;

try {
for (const entry of files) {
if (entry.path === bundlePath) {
bundleBuffer = await entry.buffer();
} else if (entry.path.startsWith(assetsPrefix)) {
const relativePath = entry.path.slice(appPrefix.length); // e.g. assets/img/logo.png
const hash = sha256(await entry.buffer());
fileHashes[`CodePush/${relativePath}`] = hash;
log(chalk.gray(` ${relativePath}:${hash.substring(0, 8)}...\n`));
}
}
} finally {
close();
}

if (Object.keys(fileHashes).length === 0) {
log(chalk.yellow(`\nWarning: CodePush assets folder not found in IPA.\n`));
}

if (!bundleBuffer) {
throw new Error("main.jsbundle not found in IPA app folder.");
}

log(chalk.cyan(`\nFound main.jsbundle, calculating hash:\n`));
fileHashes["CodePush/main.jsbundle"] = sha256(bundleBuffer);

// Write bundle to output folder (needed for the release package zip)
const outputCodePushFolder = path.join(outputFolder, "CodePush");
fs.mkdirSync(outputCodePushFolder, { recursive: true });
fs.writeFileSync(path.join(outputCodePushFolder, "main.jsbundle"), bundleBuffer);

// Save packageManifest.json
const manifestPath = path.join(outputFolder, "packageManifest.json");
fs.writeFileSync(manifestPath, JSON.stringify(fileHashes, null, 2));
log(chalk.cyan(`\nSaved packageManifest.json with ${Object.keys(fileHashes).length} entries.\n`));

// Create zip archive with packageManifest.json and bundle file
const zipPath = path.join(os.tmpdir(), `CodePushBinary-${Date.now()}.zip`);
await createZipArchive(outputFolder, zipPath, ["packageManifest.json", "CodePush/main.jsbundle"]);

return zipPath;
}

function sha256(buffer: Buffer): string {
return crypto.createHash("sha256").update(buffer).digest("hex");
}

type IpaFile = { path: string; buffer: () => Promise<Buffer> };

// Reads an IPA via its central directory. Entries are read lazily, so only the files a caller
// touches are loaded — never the whole IPA. Caller must close().
async function openIPA(ipaPath: string): Promise<{ files: IpaFile[]; appPrefix: string; close: () => void }> {
const zipFile = await yauzl.openPromise(ipaPath, { autoClose: false, lazyEntries: true });
const files: IpaFile[] = [];
for await (const entry of zipFile.eachEntry()) {
if (entry.fileName.endsWith("/")) continue;
files.push({ path: entry.fileName, buffer: async () => readStreamToBuffer(await zipFile.openReadStreamPromise(entry)) });
}

const appPrefix = files.map((f) => f.path.match(/^(Payload\/[^/]+\.app)\//)?.[1]).find(Boolean);
if (!appPrefix) {
zipFile.close();
throw new Error('Invalid IPA structure: no "Payload/*.app" folder found.');
}
return { files, appPrefix: `${appPrefix}/`, close: () => zipFile.close() };
}

type BinaryHashes = { [p: string]: string };

async function takeHashesFromMetadata(metadataPath: string): Promise<BinaryHashes> {
Expand All @@ -139,45 +61,6 @@ async function takeHashesFromMetadata(metadataPath: string): Promise<BinaryHashe
return Object.fromEntries(metadata.manifest.map((item) => item.split(":")));
}

function createZipArchive(sourceFolder: string, zipPath: string, filesToInclude: string[]): Promise<void> {
const entries: ZipEntry[] = filesToInclude
.map((file): ZipEntry => [path.join(sourceFolder, file), file])
.filter(([filePath]) => fs.existsSync(filePath));
return createZip(zipPath, entries);
}

function parsePlistBuffer(buf: Buffer): any {
if (buf.slice(0, 6).toString("ascii") === "bplist") {
const arr = bplist.parseBuffer(buf);
if (!arr?.length) throw new Error("Empty binary plist");
return arr[0];
}

return plist.parse(buf.toString("utf8"));
}

export async function getIosVersion(ipaPath: string) {
const { files, appPrefix, close } = await openIPA(ipaPath);

try {
const plistEntry = files.find((f) => f.path === `${appPrefix}Info.plist`);
if (!plistEntry) {
throw new Error("Info.plist not found in IPA app folder.");
}

const data = parsePlistBuffer(await plistEntry.buffer());

log(chalk.cyan(`App Version: ${data.CFBundleShortVersionString}, Build: ${data.CFBundleVersion}\n`));

return {
version: data.CFBundleShortVersionString,
build: data.CFBundleVersion,
};
} finally {
close();
}
}

// ---------------------------------------------------------------------------
// Android App Bundle (.aab)
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading