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
5 changes: 5 additions & 0 deletions .changeset/windows-cmdline-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"expo-device-hub": patch
---

Run `avdmanager` and `sdkmanager` on Windows, where they ship as `.bat` wrappers.
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildBatchCommand, execSdkTool, isBatchFile } from "../exec-sdk-tool";

describe("isBatchFile", () => {
test("matches .bat and .cmd regardless of case", () => {
expect(isBatchFile("C:\\sdk\\cmdline-tools\\latest\\bin\\avdmanager.bat")).toBe(true);
expect(isBatchFile("C:\\tool.CMD")).toBe(true);
});

test("ignores native binaries", () => {
expect(isBatchFile("/sdk/cmdline-tools/latest/bin/avdmanager")).toBe(false);
expect(isBatchFile("C:\\sdk\\platform-tools\\adb.exe")).toBe(false);
});
});

describe("buildBatchCommand", () => {
test("leaves path-safe arguments bare", () => {
expect(buildBatchCommand("C:\\sdk\\avdmanager.bat", ["list", "avd"])).toBe(
"C:\\sdk\\avdmanager.bat list avd",
);
});

test("quotes arguments cmd.exe would otherwise split", () => {
const command = buildBatchCommand("C:\\Program Files\\sdk\\avdmanager.bat", [
"create",
"avd",
"--name",
"expo-emu-host-0",
"--package",
"system-images;android-36.1;google_apis_playstore;x86_64",
"--device",
"pixel 6",
]);

expect(command).toBe(
'"C:\\Program Files\\sdk\\avdmanager.bat" create avd --name expo-emu-host-0 ' +
'--package "system-images;android-36.1;google_apis_playstore;x86_64" --device "pixel 6"',
);
});

test("escapes embedded double quotes", () => {
expect(buildBatchCommand("tool.bat", ['say "hi"'])).toBe('tool.bat "say ""hi"""');
});
});

describe("execSdkTool", () => {
test("spawns native binaries directly with their arguments", async () => {
const { stdout } = await execSdkTool(process.execPath, ["-e", "console.log('direct')"]);
expect(stdout.trim()).toBe("direct");
});

describe.skipIf(process.platform !== "win32")("on Windows", () => {
let directory = "";
let echoArgs = "";

beforeAll(() => {
// The space in the directory name exercises quoting of the tool path itself.
directory = mkdtempSync(join(tmpdir(), "hub android-"));
echoArgs = join(directory, "echo-args.bat");
writeFileSync(echoArgs, "@echo off\r\necho %*\r\n");
});

afterAll(() => {
rmSync(directory, { recursive: true, force: true });
});

test("runs a .bat wrapper and keeps quoted arguments whole", async () => {
const { stdout } = await execSdkTool(echoArgs, [
"--package",
"system-images;android-36.1;google_apis_playstore;x86_64",
"--device",
"pixel 6",
]);

expect(stdout.trim()).toBe(
'--package "system-images;android-36.1;google_apis_playstore;x86_64" --device "pixel 6"',
);
});
});
});
16 changes: 14 additions & 2 deletions packages/@expo/hub-android-utils/src/__tests__/sdk-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ describe("resolveSdkRoot", () => {

describe("avdmanagerPath", () => {
test("appends the cmdline-tools binary subpath", () => {
expect(avdmanagerPath("/sdk")).toBe("/sdk/cmdline-tools/latest/bin/avdmanager");
expect(avdmanagerPath("/sdk", "darwin")).toBe("/sdk/cmdline-tools/latest/bin/avdmanager");
});

test("targets the .bat wrapper on Windows", () => {
expect(avdmanagerPath("/sdk", "win32")).toEndWith("avdmanager.bat");
expect(avdmanagerPath("/sdk", "linux")).toEndWith("avdmanager");
});
});

Expand All @@ -53,7 +58,14 @@ describe("resolveAvdmanagerPath", () => {

describe("sdkmanagerPath", () => {
test("appends the cmdline-tools binary subpath", () => {
expect(sdkmanagerPath("/sdk")).toBe("/sdk/cmdline-tools/latest/bin/sdkmanager");
expect(sdkmanagerPath("/sdk", "darwin")).toBe("/sdk/cmdline-tools/latest/bin/sdkmanager");
});

test("targets the .bat wrapper on Windows", () => {
expect(sdkmanagerPath("/sdk", "win32")).toEndWith("sdkmanager.bat");
expect(resolveSdkmanagerPath({ ANDROID_HOME: "/sdk" }, HOME, "win32")).toEndWith(
"sdkmanager.bat",
);
});
});

Expand Down
13 changes: 5 additions & 8 deletions packages/@expo/hub-android-utils/src/avdmanager.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { type AndroidUtilsResult, reportError, result } from "./errors";
import { execSdkTool } from "./exec-sdk-tool";
import { parseConfigIni } from "./parse-config-ini";
import type { CreateDeviceOptions } from "./types";

const execFileAsync = promisify(execFile);

/**
* Run `avdmanager list avd` and return its stdout, or `null` on failure.
* Never throws.
Expand All @@ -16,7 +13,7 @@ export async function runAvdmanagerListAvd(
avdmanagerPath: string,
): Promise<AndroidUtilsResult<string | null>> {
try {
const { stdout } = await execFileAsync(avdmanagerPath, ["list", "avd"]);
const { stdout } = await execSdkTool(avdmanagerPath, ["list", "avd"]);
return result(stdout);
} catch (error) {
return result(null, reportError("[android-utils] Failed to run `avdmanager list avd`:", error));
Expand All @@ -31,7 +28,7 @@ export async function runAvdmanagerListDevice(
avdmanagerPath: string,
): Promise<AndroidUtilsResult<string | null>> {
try {
const { stdout } = await execFileAsync(avdmanagerPath, ["list", "device"]);
const { stdout } = await execSdkTool(avdmanagerPath, ["list", "device"]);
return result(stdout);
} catch (error) {
return result(
Expand Down Expand Up @@ -92,7 +89,7 @@ export async function runAvdmanagerCreateAvd(
const args = buildCreateAvdArgs(options);

try {
const { stdout } = await execFileAsync(avdmanagerPath, args);
const { stdout } = await execSdkTool(avdmanagerPath, args);
return result(stdout);
} catch (error) {
return result(
Expand Down Expand Up @@ -132,7 +129,7 @@ export async function runAvdmanagerDeleteAvd(
const args = buildDeleteAvdArgs(name);

try {
const { stdout } = await execFileAsync(avdmanagerPath, args);
const { stdout } = await execSdkTool(avdmanagerPath, args);
return result(stdout);
} catch (error) {
return result(
Expand Down
47 changes: 47 additions & 0 deletions packages/@expo/hub-android-utils/src/exec-sdk-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

export interface ExecSdkToolOptions {
timeout?: number;
signal?: AbortSignal;
}

/**
* Run an SDK command-line tool and resolve with its output.
*
* On Windows `avdmanager` and `sdkmanager` are `.bat` wrappers, which Node
* refuses to spawn directly (`spawn EINVAL` since 18.20 / 20.12). Those run
* through `cmd.exe` as one pre-quoted command line; native binaries such as
* `adb` spawn as-is. `timeout` and `signal` apply either way.
*/
export function execSdkTool(
toolPath: string,
args: string[],
options: ExecSdkToolOptions = {},
): Promise<{ stdout: string; stderr: string }> {
if (!isBatchFile(toolPath)) return execFileAsync(toolPath, args, options);
return execFileAsync(buildBatchCommand(toolPath, args), [], { ...options, shell: true });
}

/** Whether `toolPath` is a Windows batch wrapper rather than a native binary. */
export function isBatchFile(toolPath: string): boolean {
return /\.(bat|cmd)$/i.test(toolPath);
}

/**
* Join a `.bat` path and its arguments into a single `cmd.exe` command line.
*
* Anything beyond path-safe characters is double-quoted so `cmd.exe` passes it
* through whole: the `;` in system image packages and spaces in `Program Files`
* would otherwise be split.
*/
export function buildBatchCommand(toolPath: string, args: string[]): string {
return [toolPath, ...args].map(quoteBatchArg).join(" ");
}

function quoteBatchArg(arg: string): string {
if (/^[\w./:\\-]+$/.test(arg)) return arg;
return `"${arg.replace(/"/g, '""')}"`;
}
43 changes: 33 additions & 10 deletions packages/@expo/hub-android-utils/src/sdk-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,43 @@ export function resolveSdkRoot(env: NodeJS.ProcessEnv, homeDir: string): string
return join(homeDir, DEFAULT_SDK_SUBPATH);
}

/** Build the absolute path to the `avdmanager` binary inside an SDK root. */
export function avdmanagerPath(sdkRoot: string): string {
return join(sdkRoot, AVDMANAGER_SUBPATH);
/**
* Build the absolute path to the `avdmanager` wrapper inside an SDK root.
*
* The cmdline-tools ship as `.bat` wrappers on Windows. `adb` and `emulator`
* need no such suffix: Windows resolves a missing `.exe` on its own.
*/
export function avdmanagerPath(
sdkRoot: string,
platform: NodeJS.Platform = process.platform,
): string {
return join(sdkRoot, cmdlineTool(AVDMANAGER_SUBPATH, platform));
}

/** Resolve the `avdmanager` path directly from the environment. */
export function resolveAvdmanagerPath(env: NodeJS.ProcessEnv, homeDir: string): string {
return avdmanagerPath(resolveSdkRoot(env, homeDir));
export function resolveAvdmanagerPath(
env: NodeJS.ProcessEnv,
homeDir: string,
platform: NodeJS.Platform = process.platform,
): string {
return avdmanagerPath(resolveSdkRoot(env, homeDir), platform);
}

/** Build the absolute path to the `sdkmanager` binary inside an SDK root. */
export function sdkmanagerPath(sdkRoot: string): string {
return join(sdkRoot, SDKMANAGER_SUBPATH);
/** Build the absolute path to the `sdkmanager` wrapper inside an SDK root. */
export function sdkmanagerPath(
sdkRoot: string,
platform: NodeJS.Platform = process.platform,
): string {
return join(sdkRoot, cmdlineTool(SDKMANAGER_SUBPATH, platform));
}

/** Resolve the `sdkmanager` path directly from the environment. */
export function resolveSdkmanagerPath(env: NodeJS.ProcessEnv, homeDir: string): string {
return sdkmanagerPath(resolveSdkRoot(env, homeDir));
export function resolveSdkmanagerPath(
env: NodeJS.ProcessEnv,
homeDir: string,
platform: NodeJS.Platform = process.platform,
): string {
return sdkmanagerPath(resolveSdkRoot(env, homeDir), platform);
}

/** Build the absolute path to the `emulator` binary inside an SDK root. */
Expand All @@ -62,6 +81,10 @@ export function resolveAdbPath(env: NodeJS.ProcessEnv, homeDir: string): string
return adbPath(resolveSdkRoot(env, homeDir));
}

function cmdlineTool(subpath: string, platform: NodeJS.Platform): string {
return platform === "win32" ? `${subpath}.bat` : subpath;
}

function nonEmpty(value: string | undefined): string | null {
if (!value) return null;

Expand Down
7 changes: 2 additions & 5 deletions packages/@expo/hub-android-utils/src/sdkmanager.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { type AndroidUtilsResult, reportError, result } from "./errors";

const execFileAsync = promisify(execFile);
import { execSdkTool } from "./exec-sdk-tool";

/**
* Run `sdkmanager --list_installed` and return its stdout, or `null` on failure.
Expand All @@ -12,7 +9,7 @@ export async function runSdkmanagerListInstalled(
sdkmanagerPath: string,
): Promise<AndroidUtilsResult<string | null>> {
try {
const { stdout } = await execFileAsync(sdkmanagerPath, ["--list_installed"]);
const { stdout } = await execSdkTool(sdkmanagerPath, ["--list_installed"]);
return result(stdout);
} catch (error) {
return result(
Expand Down