electron/android-device.mjs
Rama main · réplica de lectura del repositorio original.
import { execFile } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import localOriginModule from "./local-origin.cjs";
const execFileAsync = promisify(execFile);
const STATUS_TTL_MS = 750;
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const KEYCODES = new Map([
["back", "KEYCODE_BACK"],
["delete", "KEYCODE_DEL"],
["down", "KEYCODE_DPAD_DOWN"],
["end", "KEYCODE_MOVE_END"],
["enter", "KEYCODE_ENTER"],
["escape", "KEYCODE_BACK"],
["home", "KEYCODE_HOME"],
["left", "KEYCODE_DPAD_LEFT"],
["recent", "KEYCODE_APP_SWITCH"],
["return", "KEYCODE_ENTER"],
["right", "KEYCODE_DPAD_RIGHT"],
["space", "KEYCODE_SPACE"],
["tab", "KEYCODE_TAB"],
["up", "KEYCODE_DPAD_UP"],
]);
function executableName(platform) {
return platform === "win32" ? "adb.exe" : "adb";
}
export function resolveAdbBinary({
platform = process.platform,
env = process.env,
homeDir = os.homedir(),
resourcesPath = process.resourcesPath,
exists = fs.existsSync,
} = {}) {
const executable = executableName(platform);
const candidates = [
env.OMB_ADB_PATH,
resourcesPath && path.join(resourcesPath, "android-platform-tools", platform, executable),
...String(env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean)
.map((directory) => path.join(directory, executable)),
platform === "darwin" && path.join(homeDir, "Library/Android/sdk/platform-tools/adb"),
platform === "darwin" && "/opt/homebrew/bin/adb",
platform === "darwin" && "/usr/local/bin/adb",
platform === "linux" && path.join(homeDir, "Android/Sdk/platform-tools/adb"),
].filter(Boolean);
return candidates.find((candidate) => exists(candidate)) ?? null;
}
function connectionKind(serial, fields) {
if (serial.startsWith("emulator-")) return "emulator";
if (fields.some((field) => field.startsWith("usb:"))) return "usb";
if (serial.includes(":") || serial.startsWith("adb-")) return "network";
return "usb";
}
export function parseAdbDevices(output) {
const lines = String(output).split(/\r?\n/);
const devices = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("List of devices attached") || trimmed.startsWith("* daemon")) {
continue;
}
const [serial, state, ...fields] = trimmed.split(/\s+/);
if (!serial || !state) continue;
const properties = Object.fromEntries(
fields
.map((field) => field.split(/:(.*)/s))
.filter(([key, value]) => Boolean(key && value)),
);
devices.push({
serial,
state,
connection: connectionKind(serial, fields),
model: String(properties.model ?? properties.product ?? "Android device").replaceAll("_", " "),
product: properties.product,
transportId: properties.transport_id,
});
}
return devices;
}
function trustedMainFrame(event) {
const sender = event?.sender;
const frame = event?.senderFrame;
const mainFrame = sender?.mainFrame;
return Boolean(
sender &&
frame &&
mainFrame &&
frame.processId === mainFrame.processId &&
frame.routingId === mainFrame.routingId,
);
}
function safeDimension(value) {
return Number.isFinite(value) && value >= 100 && value <= 10_000 ? value : null;
}
function safeUnit(value) {
return Number.isFinite(value) && value >= 0 && value <= 1 ? value : null;
}
export function createAndroidDeviceController(options = {}) {
const run = options.run ?? execFileAsync;
const resolveBinary = options.resolveBinary ?? (() => resolveAdbBinary(options));
let cachedStatus = null;
const invoke = async (binary, args, extra = {}) => {
const result = await run(binary, args, {
timeout: extra.timeout ?? 6_000,
maxBuffer: extra.maxBuffer ?? 32 * 1024 * 1024,
encoding: extra.encoding ?? "utf8",
env: { ...process.env, ADB_TRACE: "" },
});
return result;
};
const status = async ({ fresh = false } = {}) => {
if (!fresh && cachedStatus && Date.now() - cachedStatus.at < STATUS_TTL_MS) {
return cachedStatus.value;
}
const binary = resolveBinary();
if (!binary) {
const value = { available: false, reasonCode: "adb-unavailable", devices: [] };
cachedStatus = { at: Date.now(), value };
return value;
}
try {
const { stdout } = await invoke(binary, ["devices", "-l"]);
const devices = parseAdbDevices(stdout).filter((device) => device.connection === "usb");
const value = { available: true, adbPath: binary, devices };
cachedStatus = { at: Date.now(), value };
return value;
} catch (error) {
const value = {
available: false,
reasonCode: "adb-failed",
message: error instanceof Error ? error.message : String(error),
devices: [],
};
cachedStatus = { at: Date.now(), value };
return value;
}
};
const readyDevice = async (serial) => {
if (typeof serial !== "string" || !serial) throw new Error("An Android device is required");
const current = await status({ fresh: true });
const device = current.devices.find((candidate) => candidate.serial === serial);
if (!device) throw new Error("That USB Android device is no longer connected");
if (device.state !== "device") {
throw new Error(
device.state === "unauthorized"
? "Unlock the Android phone and allow USB debugging"
: `The Android device is ${device.state}`,
);
}
const binary = resolveBinary();
if (!binary) throw new Error("Android platform tools are unavailable");
return { binary, device };
};
const frame = async (serial) => {
const { binary } = await readyDevice(serial);
const { stdout } = await invoke(binary, ["-s", serial, "exec-out", "screencap", "-p"], {
timeout: 8_000,
encoding: "buffer",
});
const png = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout);
if (png.length < 1_024 || !png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
throw new Error("The Android device returned an invalid screen capture");
}
return { serial, dataUrl: `data:image/png;base64,${png.toString("base64")}` };
};
const input = async (serial, payload) => {
const { binary } = await readyDevice(serial);
if (!payload || typeof payload !== "object") throw new Error("Invalid Android input");
const width = safeDimension(payload.width);
const height = safeDimension(payload.height);
const point = (x, y) => {
const unitX = safeUnit(x);
const unitY = safeUnit(y);
if (unitX === null || unitY === null || width === null || height === null) {
throw new Error("Invalid Android coordinates");
}
return [Math.round(unitX * width), Math.round(unitY * height)];
};
let command;
if (payload.type === "tap") {
const [x, y] = point(payload.x, payload.y);
command = ["input", "tap", String(x), String(y)];
} else if (payload.type === "swipe") {
const [fromX, fromY] = point(payload.fromX, payload.fromY);
const [toX, toY] = point(payload.toX, payload.toY);
const duration = Number.isFinite(payload.durationMs)
? Math.max(80, Math.min(1_500, Math.round(payload.durationMs)))
: 260;
command = [
"input",
"swipe",
String(fromX),
String(fromY),
String(toX),
String(toY),
String(duration),
];
} else if (payload.type === "key") {
const keycode = KEYCODES.get(String(payload.key ?? "").toLowerCase());
if (!keycode) throw new Error("Unsupported Android key");
command = ["input", "keyevent", keycode];
} else if (payload.type === "text") {
if (
typeof payload.text !== "string" ||
payload.text.length < 1 ||
payload.text.length > 64 ||
!/^[A-Za-z0-9 _.,@-]+$/.test(payload.text)
) {
throw new Error("Android text currently supports letters, numbers, spaces, and basic punctuation");
}
command = ["input", "text", payload.text.replaceAll(" ", "%s")];
} else {
throw new Error("Unsupported Android input");
}
await invoke(binary, ["-s", serial, "shell", ...command]);
};
const registerIpc = (ipcMain) => {
const protect = (handler) => async (event, ...args) => {
if (!trustedMainFrame(event) || !localOriginModule.isLocalSender(event)) {
throw new Error("Android device access is limited to the local app");
}
return handler(...args);
};
ipcMain.handle("android-device:status", protect(() => status({ fresh: true })));
ipcMain.handle("android-device:frame", protect(frame));
ipcMain.handle("android-device:input", protect(input));
};
return { frame, input, registerIpc, status };
}