import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const ADB_BIN = process.env.ADB_BIN || "adb"; const EXEC_TIMEOUT_MS = 8000; class AdbError extends Error { constructor(message, { code, stderr } = {}) { super(message); this.name = "AdbError"; this.code = code; this.stderr = stderr; } } function run(args) { return execFileAsync(ADB_BIN, args, { timeout: EXEC_TIMEOUT_MS }).catch((err) => { if (err.code === "ENOENT") { throw new AdbError( "adb binary not found on this machine. Install Android platform-tools and make sure `adb` is on PATH.", { code: "ADB_NOT_FOUND" } ); } throw new AdbError(err.stderr?.trim() || err.message, { code: "ADB_COMMAND_FAILED", stderr: err.stderr, }); }); } // Accepts "host" or "host:port" and rejects anything that isn't a plausible // address, since this value is passed straight to execFile as a single arg // (no shell involved, but we still don't want to hand adb garbage). const HOST_PORT_RE = /^[a-zA-Z0-9.\-]+(:\d{1,5})?$/; export function normalizeTarget(hostOrHostPort) { const trimmed = String(hostOrHostPort || "").trim(); if (!HOST_PORT_RE.test(trimmed)) { throw new AdbError(`"${hostOrHostPort}" doesn't look like a valid host or host:port`, { code: "INVALID_TARGET", }); } return trimmed.includes(":") ? trimmed : `${trimmed}:5555`; } export async function connect(hostOrHostPort) { const target = normalizeTarget(hostOrHostPort); const { stdout } = await run(["connect", target]); const text = stdout.trim(); // adb prints "failed to connect..." on stdout with exit code 0, so we // have to sniff the message instead of relying on the exit status. if (/failed|unable|cannot/i.test(text)) { throw new AdbError(text, { code: "CONNECT_FAILED" }); } return { target, message: text }; } export async function disconnect(hostOrHostPort) { const target = normalizeTarget(hostOrHostPort); const { stdout } = await run(["disconnect", target]); return { target, message: stdout.trim() }; } export async function pair(hostAndPort, code) { if (!/^\d{6}$/.test(String(code))) { throw new AdbError("Pairing code must be exactly 6 digits", { code: "INVALID_PAIR_CODE" }); } const target = normalizeTarget(hostAndPort); const { stdout } = await run(["pair", target, String(code)]); const text = stdout.trim(); if (/failed|unable|cannot/i.test(text)) { throw new AdbError(text, { code: "PAIR_FAILED" }); } return { target, message: text }; } export async function listDevices() { const { stdout } = await run(["devices", "-l"]); return stdout .split("\n") .slice(1) .map((line) => line.trim()) .filter(Boolean) .map((line) => { const [serial, state, ...rest] = line.split(/\s+/); return { serial, state, details: rest.join(" ") }; }); } export async function shell(serial, ...shellArgs) { const target = normalizeTarget(serial); return run(["-s", target, "shell", ...shellArgs]); } export async function keyevent(serial, code) { await shell(serial, "input", "keyevent", String(code)); } export async function sendText(serial, text) { // Android's `input text` treats spaces specially; %s is the documented escape. const escaped = String(text).replace(/ /g, "%s"); await shell(serial, "input", "text", escaped); } export async function launchApp(serial, packageName) { await shell(serial, "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1"); } export { AdbError };