Ctrl K
ComunidadGuías…

electron/android-device.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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 };
}