зеркало из https://github.com/microsoft/rnx-kit.git
feat(tools-android): add primitives for build and run commands (#3322)
This commit is contained in:
Родитель
923c91f6db
Коммит
c2023a7e05
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@rnx-kit/cli": patch
|
||||
---
|
||||
|
||||
Added Android support to the experimental commands for building and running
|
||||
apps. Again, this still needs more testing and will not be publicly available
|
||||
yet.
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@rnx-kit/tools-android": patch
|
||||
---
|
||||
|
||||
Added primitives for building 'build' and 'run' commands
|
|
@ -50,6 +50,7 @@
|
|||
"@rnx-kit/metro-serializer-esbuild": "^0.1.38",
|
||||
"@rnx-kit/metro-service": "^3.1.6",
|
||||
"@rnx-kit/third-party-notices": "^1.3.4",
|
||||
"@rnx-kit/tools-android": "^0.1.0",
|
||||
"@rnx-kit/tools-apple": "^0.1.2",
|
||||
"@rnx-kit/tools-language": "^2.0.0",
|
||||
"@rnx-kit/tools-node": "^2.1.1",
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import type { Config } from "@react-native-community/cli-types";
|
||||
import { InvalidArgumentError } from "commander";
|
||||
import { buildAndroid } from "./build/android";
|
||||
import { buildIOS } from "./build/ios";
|
||||
import { buildMacOS } from "./build/macos";
|
||||
import type {
|
||||
BuildConfiguration,
|
||||
DeviceType,
|
||||
InputParams,
|
||||
} from "./build/apple";
|
||||
import { buildIOS } from "./build/ios";
|
||||
import { buildMacOS } from "./build/macos";
|
||||
} from "./build/types";
|
||||
|
||||
function asConfiguration(configuration: string): BuildConfiguration {
|
||||
switch (configuration) {
|
||||
|
@ -35,13 +36,14 @@ function asDestination(destination: string): DeviceType {
|
|||
|
||||
function asSupportedPlatform(platform: string): InputParams["platform"] {
|
||||
switch (platform) {
|
||||
case "android":
|
||||
case "ios":
|
||||
case "macos":
|
||||
case "visionos":
|
||||
return platform;
|
||||
default:
|
||||
throw new InvalidArgumentError(
|
||||
"Supported platforms: 'ios', 'macos', 'visionos'."
|
||||
"Supported platforms: 'android', 'ios', 'macos', 'visionos'."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -52,9 +54,13 @@ export function rnxBuild(
|
|||
buildParams: InputParams
|
||||
) {
|
||||
switch (buildParams.platform) {
|
||||
case "android":
|
||||
return buildAndroid(config, buildParams);
|
||||
|
||||
case "ios":
|
||||
case "visionos":
|
||||
return buildIOS(config, buildParams);
|
||||
|
||||
case "macos":
|
||||
return buildMacOS(config, buildParams);
|
||||
}
|
||||
|
@ -78,7 +84,7 @@ export const rnxBuildCommand = {
|
|||
},
|
||||
{
|
||||
name: "--scheme <string>",
|
||||
description: "Name of scheme to build",
|
||||
description: "Name of scheme to build (Apple platforms only)",
|
||||
},
|
||||
{
|
||||
name: "--configuration <string>",
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
import type { Config } from "@react-native-community/cli-types";
|
||||
import ora from "ora";
|
||||
import type { AndroidBuildParams } from "./types";
|
||||
|
||||
export async function buildAndroid(
|
||||
config: Config,
|
||||
buildParams: AndroidBuildParams,
|
||||
logger = ora()
|
||||
): Promise<string | number | null> {
|
||||
const { sourceDir } = config.project.android ?? {};
|
||||
if (!sourceDir) {
|
||||
logger.fail("No Android project was found");
|
||||
process.exitCode = 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
return import("@rnx-kit/tools-android").then(({ assemble }) => {
|
||||
return new Promise((resolve) => {
|
||||
logger.start("Building");
|
||||
|
||||
const errors: Buffer[] = [];
|
||||
const gradle = assemble(sourceDir, buildParams);
|
||||
|
||||
gradle.stdout.on("data", () => (logger.text += "."));
|
||||
gradle.stderr.on("data", (data) => errors.push(data));
|
||||
|
||||
gradle.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
logger.succeed("Build succeeded");
|
||||
resolve(sourceDir);
|
||||
} else {
|
||||
logger.fail(Buffer.concat(errors).toString());
|
||||
process.exitCode = code ?? 1;
|
||||
resolve(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -1,32 +1,5 @@
|
|||
import type { Ora } from "ora";
|
||||
|
||||
// Copy of types from `@rnx-kit/tools-apple`. If Jest wasn't such a pain to
|
||||
// configure, we would have added an `import type` at the top instead:
|
||||
//
|
||||
// import type { BuildParams } from "@rnx-kit/tools-apple" with { "resolution-mode": "import" };
|
||||
//
|
||||
// But Jest doesn't like import attributes and it doesn't matter if we add
|
||||
// `@babel/plugin-syntax-import-attributes` in the config.
|
||||
//
|
||||
// TOOD: Remove the `DeviceType`, `BuildConfiguration` and `BuildParams` when we
|
||||
// can migrate away from Jest in this package.
|
||||
export type DeviceType = "device" | "emulator" | "simulator";
|
||||
export type BuildConfiguration = "Debug" | "Release";
|
||||
type BuildParams =
|
||||
| {
|
||||
platform: "ios" | "visionos";
|
||||
scheme?: string;
|
||||
destination?: DeviceType;
|
||||
configuration?: BuildConfiguration;
|
||||
archs?: string;
|
||||
isBuiltRemotely?: boolean;
|
||||
}
|
||||
| {
|
||||
platform: "macos";
|
||||
scheme?: string;
|
||||
configuration?: BuildConfiguration;
|
||||
isBuiltRemotely?: boolean;
|
||||
};
|
||||
import type { AppleBuildParams } from "./types";
|
||||
|
||||
export type BuildArgs = {
|
||||
xcworkspace: string;
|
||||
|
@ -35,19 +8,14 @@ export type BuildArgs = {
|
|||
|
||||
export type BuildResult = BuildArgs | number | null;
|
||||
|
||||
export type InputParams = BuildParams & {
|
||||
device?: string;
|
||||
workspace?: string;
|
||||
};
|
||||
|
||||
export function runBuild(
|
||||
xcworkspace: string,
|
||||
buildParams: BuildParams,
|
||||
buildParams: AppleBuildParams,
|
||||
logger: Ora
|
||||
): Promise<BuildResult> {
|
||||
return import("@rnx-kit/tools-apple").then(({ xcodebuild }) => {
|
||||
return new Promise<BuildResult>((resolve) => {
|
||||
logger.start("Building...");
|
||||
logger.start("Building");
|
||||
|
||||
const errors: Buffer[] = [];
|
||||
const proc = xcodebuild(xcworkspace, buildParams, (text) => {
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import type { Config } from "@react-native-community/cli-types";
|
||||
import * as path from "node:path";
|
||||
import ora from "ora";
|
||||
import type { BuildResult, InputParams } from "./apple";
|
||||
import type { BuildResult } from "./apple";
|
||||
import { runBuild } from "./apple";
|
||||
import type { AppleInputParams } from "./types";
|
||||
|
||||
export function buildIOS(
|
||||
config: Config,
|
||||
buildParams: InputParams,
|
||||
buildParams: AppleInputParams,
|
||||
logger = ora()
|
||||
): Promise<BuildResult> {
|
||||
const { platform } = buildParams;
|
||||
|
|
|
@ -2,8 +2,9 @@ import type { Config } from "@react-native-community/cli-types";
|
|||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import ora from "ora";
|
||||
import type { BuildResult, InputParams } from "./apple";
|
||||
import type { BuildResult } from "./apple";
|
||||
import { runBuild } from "./apple";
|
||||
import type { AppleInputParams } from "./types";
|
||||
|
||||
function findXcodeWorkspaces(searchDir: string) {
|
||||
return fs.existsSync(searchDir)
|
||||
|
@ -13,7 +14,7 @@ function findXcodeWorkspaces(searchDir: string) {
|
|||
|
||||
export function buildMacOS(
|
||||
_config: Config,
|
||||
{ workspace, ...buildParams }: InputParams,
|
||||
{ workspace, ...buildParams }: AppleInputParams,
|
||||
logger = ora()
|
||||
): Promise<BuildResult> {
|
||||
if (workspace) {
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Copy of types from `@rnx-kit/tools-apple`. If Jest wasn't such a pain to
|
||||
* configure, we would have added an `import type` at the top instead:
|
||||
*
|
||||
* import type { BuildParams as AndroidBuildParams } from "@rnx-kit/tools-android" with { "resolution-mode": "import" };
|
||||
* import type { BuildParams as AppleBuildParams } from "@rnx-kit/tools-apple" with { "resolution-mode": "import" };
|
||||
*
|
||||
* But Jest doesn't like import attributes and it doesn't matter if we add
|
||||
* `@babel/plugin-syntax-import-attributes` in the config.
|
||||
*
|
||||
* TOOD: Remove this file when we can migrate away from Jest in this package.
|
||||
*/
|
||||
|
||||
export type DeviceType = "device" | "emulator" | "simulator";
|
||||
|
||||
export type BuildConfiguration = "Debug" | "Release";
|
||||
|
||||
export type AndroidBuildParams = {
|
||||
platform: "android";
|
||||
destination?: DeviceType;
|
||||
configuration?: BuildConfiguration;
|
||||
archs?: string;
|
||||
};
|
||||
|
||||
export type AppleBuildParams =
|
||||
| {
|
||||
platform: "ios" | "visionos";
|
||||
scheme?: string;
|
||||
destination?: DeviceType;
|
||||
configuration?: BuildConfiguration;
|
||||
archs?: string;
|
||||
isBuiltRemotely?: boolean;
|
||||
}
|
||||
| {
|
||||
platform: "macos";
|
||||
scheme?: string;
|
||||
configuration?: BuildConfiguration;
|
||||
isBuiltRemotely?: boolean;
|
||||
};
|
||||
|
||||
export type AndroidInputParams = AndroidBuildParams & {
|
||||
device?: string;
|
||||
};
|
||||
|
||||
export type AppleInputParams = AppleBuildParams & {
|
||||
device?: string;
|
||||
workspace?: string;
|
||||
};
|
||||
|
||||
export type InputParams = AndroidInputParams | AppleInputParams;
|
|
@ -1,6 +1,7 @@
|
|||
import type { Config } from "@react-native-community/cli-types";
|
||||
import { rnxBuildCommand } from "./build";
|
||||
import type { InputParams } from "./build/apple";
|
||||
import type { InputParams } from "./build/types";
|
||||
import { runAndroid } from "./run/android";
|
||||
import { runIOS } from "./run/ios";
|
||||
import { runMacOS } from "./run/macos";
|
||||
|
||||
|
@ -10,9 +11,13 @@ export function rnxRun(
|
|||
buildParams: InputParams
|
||||
) {
|
||||
switch (buildParams.platform) {
|
||||
case "android":
|
||||
return runAndroid(config, buildParams);
|
||||
|
||||
case "ios":
|
||||
case "visionos":
|
||||
return runIOS(config, buildParams);
|
||||
|
||||
case "macos":
|
||||
return runMacOS(config, buildParams);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
import type { Config } from "@react-native-community/cli-types";
|
||||
import * as path from "node:path";
|
||||
import ora from "ora";
|
||||
import { buildAndroid } from "../build/android";
|
||||
import type { AndroidInputParams } from "../build/types";
|
||||
|
||||
export async function runAndroid(
|
||||
config: Config,
|
||||
buildParams: AndroidInputParams
|
||||
) {
|
||||
const logger = ora();
|
||||
|
||||
const projectDir = await buildAndroid(config, buildParams, logger);
|
||||
if (typeof projectDir !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.start("Preparing to launch app");
|
||||
|
||||
const { findOutputFile, getPackageName, install, selectDevice, start } =
|
||||
await import("@rnx-kit/tools-android");
|
||||
|
||||
const { configuration = "Debug" } = buildParams;
|
||||
const apks = findOutputFile(projectDir, configuration);
|
||||
if (apks.length === 0) {
|
||||
logger.fail("Failed to find the APK that was just built");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (apks.length > 1) {
|
||||
const currentStatus = logger.text;
|
||||
const choices = apks.map((p) => path.basename(p)).join(", ");
|
||||
logger.info(`Multiple APKs were found; picking the first one: ${choices}`);
|
||||
logger.info("If this is wrong, remove the others and try again");
|
||||
logger.start(currentStatus);
|
||||
}
|
||||
|
||||
const apk = apks[0];
|
||||
const info = getPackageName(apk);
|
||||
if (info instanceof Error) {
|
||||
logger.fail(info.message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const device = await selectDevice(buildParams.device, logger);
|
||||
if (!device) {
|
||||
logger.fail("Failed to launch app: Could not find an appropriate device");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
logger.start(`Installing ${apk}`);
|
||||
|
||||
const { packageName, activityName } = info;
|
||||
const error = await install(device, apk, packageName);
|
||||
if (error) {
|
||||
logger.fail(error.message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
logger.text = `Starting ${packageName}`;
|
||||
await start(device, packageName, activityName);
|
||||
|
||||
logger.succeed(`Started ${packageName}`);
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import type { Config } from "@react-native-community/cli-types";
|
||||
import * as path from "node:path";
|
||||
import ora from "ora";
|
||||
import type { InputParams } from "../build/apple";
|
||||
import { buildIOS } from "../build/ios";
|
||||
import type { InputParams } from "../build/types";
|
||||
|
||||
export async function runIOS(config: Config, buildParams: InputParams) {
|
||||
const { platform } = buildParams;
|
||||
|
@ -17,7 +17,7 @@ export async function runIOS(config: Config, buildParams: InputParams) {
|
|||
return;
|
||||
}
|
||||
|
||||
logger.start("Preparing to launch app...");
|
||||
logger.start("Preparing to launch app");
|
||||
|
||||
const {
|
||||
getBuildSettings,
|
||||
|
@ -52,7 +52,7 @@ export async function runIOS(config: Config, buildParams: InputParams) {
|
|||
settings.buildSettings;
|
||||
const app = path.join(TARGET_BUILD_DIR, EXECUTABLE_FOLDER_PATH);
|
||||
|
||||
logger.start(`Installing '${FULL_PRODUCT_NAME}' on ${device.name}...`);
|
||||
logger.start(`Installing '${FULL_PRODUCT_NAME}' on ${device.name}`);
|
||||
|
||||
const installError = await install(device, app);
|
||||
if (installError) {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type { Config } from "@react-native-community/cli-types";
|
||||
import * as path from "node:path";
|
||||
import ora from "ora";
|
||||
import type { InputParams } from "../build/apple";
|
||||
import { buildMacOS } from "../build/macos";
|
||||
import type { AppleInputParams } from "../build/types";
|
||||
|
||||
export async function runMacOS(config: Config, buildParams: InputParams) {
|
||||
export async function runMacOS(config: Config, buildParams: AppleInputParams) {
|
||||
const logger = ora();
|
||||
|
||||
const result = await buildMacOS(config, buildParams, logger);
|
||||
|
@ -14,7 +14,7 @@ export async function runMacOS(config: Config, buildParams: InputParams) {
|
|||
|
||||
const { getBuildSettings, open } = await import("@rnx-kit/tools-apple");
|
||||
|
||||
logger.start("Launching app...");
|
||||
logger.start("Launching app");
|
||||
|
||||
const settings = await getBuildSettings(result.xcworkspace, result.args);
|
||||
if (!settings) {
|
||||
|
@ -26,7 +26,7 @@ export async function runMacOS(config: Config, buildParams: InputParams) {
|
|||
const { FULL_PRODUCT_NAME, TARGET_BUILD_DIR } = settings.buildSettings;
|
||||
const appPath = path.join(TARGET_BUILD_DIR, FULL_PRODUCT_NAME);
|
||||
|
||||
logger.text = `Launching '${FULL_PRODUCT_NAME}'...`;
|
||||
logger.text = `Launching '${FULL_PRODUCT_NAME}'`;
|
||||
|
||||
const { stderr, status } = await open(appPath);
|
||||
if (status !== 0) {
|
||||
|
|
|
@ -15,15 +15,17 @@ import * as tools from "@rnx-kit/tools-android";
|
|||
<!-- The following table can be updated by running `yarn update-readme` -->
|
||||
<!-- @rnx-kit/api start -->
|
||||
|
||||
| Category | Function | Description |
|
||||
| -------- | ------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| - | `getBuildToolsPath()` | Returns the path to Android SDK Build-Tools. |
|
||||
| - | `getDevices()` | Returns a list of attached physical Android devices. |
|
||||
| - | `getEmulators()` | Returns a list of available Android virtual devices. |
|
||||
| - | `getPackageName(apk)` | Returns the package name and the first launchable activity of the specified APK. |
|
||||
| - | `install(device, apk, packageName)` | Installs the specified APK on specified emulator or physical device. |
|
||||
| - | `launchEmulator(emulatorName)` | Launches the emulator with the specified name. |
|
||||
| - | `selectDevice(emulatorName, logger)` | Returns the emulator or physical device with the specified name. |
|
||||
| - | `start(options, packageName, activityName)` | Starts the specified activity on specified emulator or physical device. |
|
||||
| Category | Function | Description |
|
||||
| -------- | ------------------------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| apk | `getPackageName(apk)` | Returns the package name and the first launchable activity of the specified APK. |
|
||||
| apk | `install(device, apk, packageName)` | Installs the specified APK on specified emulator or physical device. |
|
||||
| apk | `start(options, packageName, activityName)` | Starts the specified activity on specified emulator or physical device. |
|
||||
| device | `getDevices()` | Returns a list of attached physical Android devices. |
|
||||
| device | `getEmulators()` | Returns a list of available Android virtual devices. |
|
||||
| device | `launchEmulator(emulatorName)` | Launches the emulator with the specified name. |
|
||||
| device | `selectDevice(emulatorName, logger)` | Returns the emulator or physical device with the specified name. |
|
||||
| gradle | `assemble(projectDir, buildParams)` | Invokes Gradle build. |
|
||||
| gradle | `findOutputFile(projectDir, buildConfiguration)` | Tries to find Gradle build output file. |
|
||||
| sdk | `getBuildToolsPath()` | Returns the path to Android SDK Build-Tools. |
|
||||
|
||||
<!-- @rnx-kit/api end -->
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import { idle } from "@rnx-kit/tools-shell/async";
|
||||
import { makeCommandSync } from "@rnx-kit/tools-shell/command";
|
||||
import * as path from "node:path";
|
||||
import { adb, getBuildToolsPath } from "./sdk.js";
|
||||
import type { DeviceInfo, PackageInfo } from "./types.js";
|
||||
|
||||
/**
|
||||
* Returns the package name and the first launchable activity of the
|
||||
* specified APK.
|
||||
*/
|
||||
export function getPackageName(apk: string): PackageInfo | Error {
|
||||
const buildToolsPath = getBuildToolsPath();
|
||||
if (!buildToolsPath) {
|
||||
return new Error("Could not find Android SDK Build-Tools");
|
||||
}
|
||||
|
||||
const aapt = makeCommandSync(path.join(buildToolsPath, "aapt2"));
|
||||
|
||||
const { stdout } = aapt("dump", "badging", apk);
|
||||
const packageMatch = stdout.match(/package: name='(.*?)'/);
|
||||
if (!packageMatch) {
|
||||
return new Error("Could not find package name");
|
||||
}
|
||||
|
||||
const activityMatch = stdout.match(/launchable-activity: name='(.*?)'/);
|
||||
if (!activityMatch) {
|
||||
return new Error("Could not find any launchable activities");
|
||||
}
|
||||
|
||||
return { packageName: packageMatch[1], activityName: activityMatch[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the specified APK on specified emulator or physical device.
|
||||
*
|
||||
* @remarks
|
||||
* This function automatically uninstalls the existing app if an
|
||||
* `INSTALL_FAILED_UPDATE_INCOMPATIBLE` error is encountered.
|
||||
*/
|
||||
export async function install(
|
||||
device: DeviceInfo,
|
||||
apk: string,
|
||||
packageName: string
|
||||
): Promise<Error | null> {
|
||||
const { stderr, status } = await adb("-s", device.serial, "install", apk);
|
||||
if (status !== 0) {
|
||||
if (stderr.includes("device offline")) {
|
||||
await idle(1000);
|
||||
return install(device, apk, packageName);
|
||||
} else if (stderr.includes("INSTALL_FAILED_UPDATE_INCOMPATIBLE")) {
|
||||
await adb("uninstall", packageName);
|
||||
return install(device, apk, packageName);
|
||||
}
|
||||
return new Error(stderr);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the specified activity on specified emulator or physical device.
|
||||
* @param options
|
||||
* @param packageName
|
||||
* @param activityName
|
||||
*/
|
||||
export function start(
|
||||
{ serial }: DeviceInfo,
|
||||
packageName: string,
|
||||
activityName: string
|
||||
) {
|
||||
const activity = `${packageName}/${activityName}`;
|
||||
return adb("-s", serial, "shell", "am", "start", "-n", activity);
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
import { retry } from "@rnx-kit/tools-shell/async";
|
||||
import { ensure, makeCommand } from "@rnx-kit/tools-shell/command";
|
||||
import { spawn } from "node:child_process";
|
||||
import * as path from "node:path";
|
||||
import { adb, ANDROID_HOME } from "./sdk.js";
|
||||
import type { DeviceInfo, Logger } from "./types.js";
|
||||
|
||||
const EMULATOR_BIN = path.join(ANDROID_HOME, "emulator", "emulator");
|
||||
const MAX_ATTEMPTS = 8;
|
||||
|
||||
/**
|
||||
* Returns a list of attached physical Android devices.
|
||||
*/
|
||||
export async function getDevices(): Promise<DeviceInfo[]> {
|
||||
// https://developer.android.com/studio/command-line/adb#devicestatus
|
||||
const { stdout } = await adb("devices", "-l");
|
||||
return stdout
|
||||
.split("\n")
|
||||
.splice(1) // First line is 'List of devices attached'
|
||||
.map((device: string) => {
|
||||
const [serial, state, ...props] = device.split(/\s+/);
|
||||
return {
|
||||
serial,
|
||||
state,
|
||||
description: Object.fromEntries(
|
||||
props.map((prop) => prop.split(":"))
|
||||
) as DeviceInfo["description"],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of available Android virtual devices.
|
||||
*/
|
||||
export async function getEmulators(): Promise<string[]> {
|
||||
const emulator = makeCommand(EMULATOR_BIN);
|
||||
const result = await emulator("-list-avds");
|
||||
|
||||
// Make sure we don't include lines like:
|
||||
// INFO | Storing crashdata in: /tmp/android-user/emu-crash-34.2.13.db
|
||||
return ensure(result)
|
||||
.split("\n")
|
||||
.map((device: string) => device.trim())
|
||||
.filter((line) => line && !line.includes(" | "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the emulator with the specified name.
|
||||
*/
|
||||
export async function launchEmulator(
|
||||
emulatorName: string
|
||||
): Promise<DeviceInfo | Error> {
|
||||
spawn(EMULATOR_BIN, ["@" + emulatorName], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}).unref();
|
||||
|
||||
const result = await retry(async () => {
|
||||
const devices = await getDevices();
|
||||
return devices.find((device) => device.state === "device") || null;
|
||||
}, MAX_ATTEMPTS);
|
||||
return result || new Error("Timed out waiting for the emulator");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the emulator or physical device with the specified name.
|
||||
*
|
||||
* @remarks
|
||||
* If an emulator is found, it is also booted if necessary.
|
||||
*/
|
||||
export async function selectDevice(
|
||||
emulatorName: string | undefined,
|
||||
logger: Logger
|
||||
): Promise<DeviceInfo | null> {
|
||||
const attachedDevices = await getDevices();
|
||||
if (!emulatorName) {
|
||||
const physicalDevice = attachedDevices.find(
|
||||
(device) => device.state === "device" && "usb" in device.description
|
||||
);
|
||||
if (physicalDevice) {
|
||||
logger.info(`Found Android device ${physicalDevice.serial}`);
|
||||
return physicalDevice;
|
||||
}
|
||||
}
|
||||
|
||||
// There is currently no way to get the emulator name based on the list of
|
||||
// attached devices. If we find an emulator, we'll have to assume it's the
|
||||
// one the user wants.
|
||||
const attachedEmulator = attachedDevices.find(
|
||||
(device) => device.state === "device" && !("usb" in device.description)
|
||||
);
|
||||
if (attachedEmulator) {
|
||||
logger.info("An Android emulator is already attached");
|
||||
return attachedEmulator;
|
||||
}
|
||||
|
||||
const avd = emulatorName || (await getEmulators())[0];
|
||||
if (!avd) {
|
||||
logger.warn("No emulators were found");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.start(`Booting Android emulator @${avd}`);
|
||||
const emulator = await launchEmulator(avd);
|
||||
if (emulator instanceof Error) {
|
||||
logger.fail();
|
||||
logger.fail(emulator.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.succeed(`Booted @${avd}`);
|
||||
return emulator;
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import * as nodefs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { BuildParams } from "./types.js";
|
||||
|
||||
/**
|
||||
* Invokes Gradle build.
|
||||
* @param projectDir
|
||||
* @param buildParams
|
||||
*/
|
||||
export function assemble(
|
||||
projectDir: string,
|
||||
{ configuration = "Debug", archs }: BuildParams
|
||||
) {
|
||||
const args = [`assemble${configuration}`];
|
||||
|
||||
if (archs) {
|
||||
args.push(`-PreactNativeArchitectures=${archs}`);
|
||||
}
|
||||
|
||||
const gradlew = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
|
||||
return spawn(gradlew, args, { cwd: projectDir });
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find Gradle build output file.
|
||||
* @remarks This function may return several files.
|
||||
*/
|
||||
export function findOutputFile(
|
||||
projectDir: string,
|
||||
buildConfiguration: string,
|
||||
/** @internal */ fs = nodefs
|
||||
): string[] {
|
||||
const apks: string[] = [];
|
||||
|
||||
const configName = buildConfiguration.toLowerCase();
|
||||
for (const moduleName of fs.readdirSync(projectDir)) {
|
||||
const outputFile = path.join(
|
||||
projectDir,
|
||||
moduleName,
|
||||
"build",
|
||||
"outputs",
|
||||
"apk",
|
||||
configName,
|
||||
`${moduleName}-${configName}.apk`
|
||||
);
|
||||
if (fs.existsSync(outputFile)) {
|
||||
apks.push(outputFile);
|
||||
}
|
||||
}
|
||||
|
||||
return apks;
|
||||
}
|
|
@ -1,257 +1,19 @@
|
|||
import { idle, retry } from "@rnx-kit/tools-shell/async";
|
||||
import {
|
||||
ensure,
|
||||
makeCommand,
|
||||
makeCommandSync,
|
||||
} from "@rnx-kit/tools-shell/command";
|
||||
import { spawn } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
type Logger = {
|
||||
start: (str?: string) => void;
|
||||
succeed: (str?: string) => void;
|
||||
fail: (str?: string) => void;
|
||||
info: (str: string) => void;
|
||||
warn: (str: string) => void;
|
||||
};
|
||||
|
||||
export type EmulatorInfo = {
|
||||
product: string;
|
||||
model: string;
|
||||
device: string;
|
||||
transport_id: string;
|
||||
};
|
||||
|
||||
export type PhysicalDeviceInfo = {
|
||||
usb: string;
|
||||
product: string;
|
||||
model: string;
|
||||
device: string;
|
||||
transport_id: string;
|
||||
};
|
||||
|
||||
export type DeviceInfo = {
|
||||
serial: string;
|
||||
state: "offline" | "device" | string;
|
||||
description: EmulatorInfo | PhysicalDeviceInfo;
|
||||
};
|
||||
|
||||
export type PackageInfo = {
|
||||
packageName: string;
|
||||
activityName: string;
|
||||
};
|
||||
|
||||
const ANDROID_HOME = (() => {
|
||||
const home = process.env.ANDROID_HOME;
|
||||
if (!home) {
|
||||
throw new Error(
|
||||
"ANDROID_HOME is not set and is required to install and launch APKs"
|
||||
);
|
||||
}
|
||||
return home;
|
||||
})();
|
||||
const ADB_BIN = path.join(ANDROID_HOME, "platform-tools", "adb");
|
||||
const BUILD_TOOLS_DIR = path.join(ANDROID_HOME, "build-tools");
|
||||
const EMULATOR_BIN = path.join(ANDROID_HOME, "emulator", "emulator");
|
||||
const MAX_ATTEMPTS = 8;
|
||||
|
||||
const adb = makeCommand(ADB_BIN);
|
||||
|
||||
function latestVersion(versions: string[]): string {
|
||||
let latestVersion = "0.0.0";
|
||||
let maxValue = 0;
|
||||
|
||||
for (const version of versions) {
|
||||
const [major, minor = 0, patch = 0] = version.split(".");
|
||||
const value =
|
||||
Number(major) * 1000000 + Number(minor) * 1000 + Number(patch);
|
||||
if (maxValue < value) {
|
||||
latestVersion = version;
|
||||
maxValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
return latestVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to Android SDK Build-Tools.
|
||||
*/
|
||||
export function getBuildToolsPath(): string | null {
|
||||
if (!fs.existsSync(BUILD_TOOLS_DIR)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const versions = fs.readdirSync(BUILD_TOOLS_DIR);
|
||||
return path.join(BUILD_TOOLS_DIR, latestVersion(versions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of attached physical Android devices.
|
||||
*/
|
||||
export async function getDevices(): Promise<DeviceInfo[]> {
|
||||
// https://developer.android.com/studio/command-line/adb#devicestatus
|
||||
const { stdout } = await adb("devices", "-l");
|
||||
return stdout
|
||||
.split("\n")
|
||||
.splice(1) // First line is 'List of devices attached'
|
||||
.map((device: string) => {
|
||||
const [serial, state, ...props] = device.split(/\s+/);
|
||||
return {
|
||||
serial,
|
||||
state,
|
||||
description: Object.fromEntries(
|
||||
props.map((prop) => prop.split(":"))
|
||||
) as DeviceInfo["description"],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of available Android virtual devices.
|
||||
*/
|
||||
export async function getEmulators(): Promise<string[]> {
|
||||
const emulator = makeCommand(EMULATOR_BIN);
|
||||
const result = await emulator("-list-avds");
|
||||
return ensure(result)
|
||||
.split("\n")
|
||||
.map((device: string) => device.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the package name and the first launchable activity of the
|
||||
* specified APK.
|
||||
*/
|
||||
export function getPackageName(apk: string): PackageInfo | Error {
|
||||
const buildToolsPath = getBuildToolsPath();
|
||||
if (!buildToolsPath) {
|
||||
return new Error("Could not find Android SDK Build-Tools");
|
||||
}
|
||||
|
||||
const aapt = makeCommandSync(path.join(buildToolsPath, "aapt2"));
|
||||
|
||||
const { stdout } = aapt("dump", "badging", apk);
|
||||
const packageMatch = stdout.match(/package: name='(.*?)'/);
|
||||
if (!packageMatch) {
|
||||
return new Error("Could not find package name");
|
||||
}
|
||||
|
||||
const activityMatch = stdout.match(/launchable-activity: name='(.*?)'/);
|
||||
if (!activityMatch) {
|
||||
return new Error("Could not find launchable activity");
|
||||
}
|
||||
|
||||
return { packageName: packageMatch[1], activityName: activityMatch[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the specified APK on specified emulator or physical device.
|
||||
*
|
||||
* @remarks
|
||||
* This function automatically uninstalls the existing app if an
|
||||
* `INSTALL_FAILED_UPDATE_INCOMPATIBLE` error is encountered.
|
||||
*/
|
||||
export async function install(
|
||||
device: DeviceInfo,
|
||||
apk: string,
|
||||
packageName: string
|
||||
): Promise<Error | null> {
|
||||
const { stderr, status } = await adb("-s", device.serial, "install", apk);
|
||||
if (status !== 0) {
|
||||
if (stderr.includes("device offline")) {
|
||||
await idle(1000);
|
||||
return install(device, apk, packageName);
|
||||
} else if (stderr.includes("INSTALL_FAILED_UPDATE_INCOMPATIBLE")) {
|
||||
await adb("uninstall", packageName);
|
||||
return install(device, apk, packageName);
|
||||
}
|
||||
return new Error(stderr);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the emulator with the specified name.
|
||||
*/
|
||||
export async function launchEmulator(
|
||||
emulatorName: string
|
||||
): Promise<DeviceInfo | Error> {
|
||||
spawn(EMULATOR_BIN, ["@" + emulatorName], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}).unref();
|
||||
|
||||
const result = await retry(async () => {
|
||||
const devices = await getDevices();
|
||||
return devices.find((device) => device.state === "device") || null;
|
||||
}, MAX_ATTEMPTS);
|
||||
return result || new Error("Timed out waiting for the emulator");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the emulator or physical device with the specified name.
|
||||
*
|
||||
* @remarks
|
||||
* If an emulator is found, it is also booted if necessary.
|
||||
*/
|
||||
export async function selectDevice(
|
||||
emulatorName: string | undefined,
|
||||
logger: Logger
|
||||
): Promise<DeviceInfo | null> {
|
||||
const attachedDevices = await getDevices();
|
||||
if (!emulatorName) {
|
||||
const physicalDevice = attachedDevices.find(
|
||||
(device) => device.state === "device" && "usb" in device.description
|
||||
);
|
||||
if (physicalDevice) {
|
||||
logger.info(`Found Android device ${physicalDevice.serial}`);
|
||||
return physicalDevice;
|
||||
}
|
||||
}
|
||||
|
||||
// There is currently no way to get the emulator name based on the list of
|
||||
// attached devices. If we find an emulator, we'll have to assume it's the
|
||||
// one the user wants.
|
||||
const attachedEmulator = attachedDevices.find(
|
||||
(device) => device.state === "device" && !("usb" in device.description)
|
||||
);
|
||||
if (attachedEmulator) {
|
||||
logger.info("An Android emulator is already attached");
|
||||
return attachedEmulator;
|
||||
}
|
||||
|
||||
const avd = emulatorName || (await getEmulators())[0];
|
||||
if (!avd) {
|
||||
logger.warn("No emulators were found");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.start(`Booting Android emulator @${avd}`);
|
||||
const emulator = await launchEmulator(avd);
|
||||
if (emulator instanceof Error) {
|
||||
logger.fail();
|
||||
logger.fail(emulator.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.succeed(`Booted @${avd}`);
|
||||
return emulator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the specified activity on specified emulator or physical device.
|
||||
* @param options
|
||||
* @param packageName
|
||||
* @param activityName
|
||||
*/
|
||||
export function start(
|
||||
{ serial }: DeviceInfo,
|
||||
packageName: string,
|
||||
activityName: string
|
||||
) {
|
||||
const activity = `${packageName}/${activityName}`;
|
||||
return adb("-s", serial, "shell", "am", "start", "-n", activity);
|
||||
}
|
||||
export { getPackageName, install, start } from "./apk.js";
|
||||
export {
|
||||
getDevices,
|
||||
getEmulators,
|
||||
launchEmulator,
|
||||
selectDevice,
|
||||
} from "./device.js";
|
||||
export { assemble, findOutputFile } from "./gradle.js";
|
||||
export { getBuildToolsPath } from "./sdk.js";
|
||||
export type {
|
||||
BuildConfiguration,
|
||||
BuildParams,
|
||||
DeviceInfo,
|
||||
DeviceType,
|
||||
EmulatorInfo,
|
||||
Logger,
|
||||
PackageInfo,
|
||||
PhysicalDeviceInfo,
|
||||
} from "./types.js";
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
import { makeCommand } from "@rnx-kit/tools-shell/command";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export const ANDROID_HOME = (() => {
|
||||
const home = process.env.ANDROID_HOME;
|
||||
if (!home) {
|
||||
throw new Error(
|
||||
"ANDROID_HOME is not set and is required to install and launch APKs"
|
||||
);
|
||||
}
|
||||
return home;
|
||||
})();
|
||||
|
||||
const ADB_BIN = path.join(ANDROID_HOME, "platform-tools", "adb");
|
||||
const BUILD_TOOLS_DIR = path.join(ANDROID_HOME, "build-tools");
|
||||
|
||||
export const adb = makeCommand(ADB_BIN);
|
||||
|
||||
function latestVersion(versions: string[]): string {
|
||||
let latestVersion = "0.0.0";
|
||||
let maxValue = 0;
|
||||
|
||||
for (const version of versions) {
|
||||
const [major, minor = 0, patch = 0] = version.split(".");
|
||||
const value =
|
||||
Number(major) * 1000000 + Number(minor) * 1000 + Number(patch);
|
||||
if (maxValue < value) {
|
||||
latestVersion = version;
|
||||
maxValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
return latestVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to Android SDK Build-Tools.
|
||||
*/
|
||||
export function getBuildToolsPath(): string | null {
|
||||
if (!fs.existsSync(BUILD_TOOLS_DIR)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const versions = fs.readdirSync(BUILD_TOOLS_DIR);
|
||||
return path.join(BUILD_TOOLS_DIR, latestVersion(versions));
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
export type BuildConfiguration = "Debug" | "Release";
|
||||
|
||||
export type DeviceType = "device" | "emulator" | "simulator";
|
||||
|
||||
export type EmulatorInfo = {
|
||||
product: string;
|
||||
model: string;
|
||||
device: string;
|
||||
transport_id: string;
|
||||
};
|
||||
|
||||
export type Logger = {
|
||||
start: (str?: string) => void;
|
||||
succeed: (str?: string) => void;
|
||||
fail: (str?: string) => void;
|
||||
info: (str: string) => void;
|
||||
warn: (str: string) => void;
|
||||
};
|
||||
|
||||
export type PhysicalDeviceInfo = {
|
||||
usb: string;
|
||||
product: string;
|
||||
model: string;
|
||||
device: string;
|
||||
transport_id: string;
|
||||
};
|
||||
|
||||
export type BuildParams = {
|
||||
platform: "android";
|
||||
destination?: DeviceType;
|
||||
configuration?: BuildConfiguration;
|
||||
archs?: string;
|
||||
};
|
||||
|
||||
export type DeviceInfo = {
|
||||
serial: string;
|
||||
state: "offline" | "device" | string;
|
||||
description: EmulatorInfo | PhysicalDeviceInfo;
|
||||
};
|
||||
|
||||
export type PackageInfo = {
|
||||
packageName: string;
|
||||
activityName: string;
|
||||
};
|
|
@ -3819,6 +3819,7 @@ __metadata:
|
|||
"@rnx-kit/metro-service": "npm:^3.1.6"
|
||||
"@rnx-kit/scripts": "npm:*"
|
||||
"@rnx-kit/third-party-notices": "npm:^1.3.4"
|
||||
"@rnx-kit/tools-android": "npm:^0.1.0"
|
||||
"@rnx-kit/tools-apple": "npm:^0.1.2"
|
||||
"@rnx-kit/tools-filesystem": "npm:*"
|
||||
"@rnx-kit/tools-language": "npm:^2.0.0"
|
||||
|
|
Загрузка…
Ссылка в новой задаче