Skip to content

Latest commit

 

History

History
883 lines (647 loc) · 20.8 KB

File metadata and controls

883 lines (647 loc) · 20.8 KB

crScrcpy JavaScript 接口指南

crScrcpy 内置了一个 JavaScript 运行环境,可以通过脚本发现 Android 设备、发送触摸和按键、操作剪贴板、监听设备状态,以及同时控制多个设备。

脚本在 crScrcpy 的 Scripts 页面中创建、保存和运行。每个正在运行的脚本拥有独立的 JavaScript 线程和 V8 Isolate;一个脚本阻塞或停止时,不会直接阻塞其他脚本的 JavaScript 执行。

本文描述的是当前版本已经实现的接口。浏览器中的 DOM、window、document、fetch 等 Web API 不属于本文档保证的运行环境。

快速开始

下面的脚本获取第一台设备,读取当前视频尺寸,然后点击屏幕中心:

(async () => {
  const devices = crscrcpy.getDevices();
  if (devices.length === 0) {
    console.warn("No Android device is available");
    return;
  }

  const device = crscrcpy.device(devices[0].serial);
  const video = await device.getVideoInfo();
  console.info("Video:", video);

  if (!video.known || video.width <= 0 || video.height <= 0) {
    console.warn("Video size is not ready");
    return;
  }

  const x = Math.floor(video.width / 2);
  const y = Math.floor(video.height / 2);
  await device.touch("down", x, y);
  await new Promise(resolve => setTimeout(resolve, 80));
  const result = await device.touch("up", x, y);
  console.log("Tap accepted:", result.accepted);
})().catch(error => console.error(error));

基本约定

同步接口和异步接口

设备列表、分组列表和对象创建接口是同步的:

const devices = crscrcpy.getDevices();
const device = crscrcpy.device(devices[0].serial);

设备读取和控制接口返回 Promise,建议使用 async/await:

(async () => {
  const device = crscrcpy.device("DEVICE_SERIAL");
  const state = await device.getState();
  const result = await device.backOrScreenOn();
  console.log(state, result);
})();

脚本按普通 JavaScript Script 执行,不支持直接使用顶层 await。需要异步入口时,使用上面这种 async IIFE。

控制命令的返回值

单设备控制方法返回:

interface CommandResult {
  serial: string;
  accepted: boolean;
}

批量设备和设备组控制方法返回 CommandResult[]。

accepted: true 表示 crScrcpy 已接受并派发命令,不表示 Android 端已经执行完成,也不是设备端确认。自动坐标模式下,如果视频尺寸尚不可用或坐标越界,accepted 为 false。

const result = await crscrcpy.device("DEVICE_SERIAL").backOrScreenOn();
if (!result.accepted) {
  console.error("Command was not accepted");
}

公共数据类型

interface DeviceInfo {
  serial: string;
  state: string;
  model: string;
  product: string;
  device: string;
  usb: string;
  transportId: string;
}

interface DeviceGroupInfo {
  id: string;
  name: string;
  color:
    | "grey" | "blue" | "red" | "yellow" | "green"
    | "pink" | "purple" | "cyan" | "orange";
  serials: string[];
}

ADB 没有提供的 DeviceInfo 字段可能是空字符串。

全局 crscrcpy 对象

crscrcpy.getDevices()

返回当前已知设备的快照。

crscrcpy.getDevices(): DeviceInfo[]
for (const device of crscrcpy.getDevices()) {
  console.log(device.serial, device.model, device.state);
}

返回的数组不会自动更新。需要跟踪设备变化时,请同时监听全局设备事件。

crscrcpy.device(serial)

根据序列号创建或取得一个设备控制对象。

crscrcpy.device(serial: string): ScrcpyDevice
const device = crscrcpy.device("192.168.1.20:5555");
console.log(device.serial);

该接口只要求非空序列号,不保证设备当前在线。通常应从 getDevices() 的结果中选择序列号。

crscrcpy.devices(serials)

创建批量设备控制对象。最多接受 64 个序列号,重复序列号会被去重。

crscrcpy.devices(serials: string[]): DeviceCollection
const serials = crscrcpy.getDevices().map(device => device.serial);
const results = await crscrcpy.devices(serials).setDisplayPower(true);

for (const result of results) {
  console.log(result.serial, result.accepted);
}

crscrcpy.getDeviceGroups()

返回当前设备组的快照。

crscrcpy.getDeviceGroups(): DeviceGroupInfo[]
for (const group of crscrcpy.getDeviceGroups()) {
  console.log(group.name, group.serials);
}

crscrcpy.group(id)

根据组 ID 取得设备组对象。分组不存在时返回 null。

crscrcpy.group(id: string): DeviceGroup | null
const info = crscrcpy.getDeviceGroups()[0];
if (info) {
  const group = crscrcpy.group(info.id);
  const results = await group.backOrScreenOn();
  console.log(results);
}

全局设备列表事件

支持以下事件:

  • deviceadded:发现新设备。
  • deviceremoved:设备从列表中移除。
  • deviceschange:设备列表发生变化。

crscrcpy.on(type, callback)

持续订阅事件,成功时返回 true。

function onDeviceAdded(event) {
  console.info("Added:", event.device.serial);
}

crscrcpy.on("deviceadded", onDeviceAdded);

crscrcpy.once(type, callback)

只监听下一次事件,回调执行前会自动取消订阅。

crscrcpy.once("deviceremoved", event => {
  console.warn("Removed:", event.device.serial);
});

crscrcpy.off(type, callback)

取消订阅。必须传入注册时的同一个函数对象;成功删除至少一个回调时返回 true。

function onDevicesChange(event) {
  console.log("Device count:", event.devices.length);
}

crscrcpy.on("deviceschange", onDevicesChange);
crscrcpy.off("deviceschange", onDevicesChange);

全局事件对象

interface DevicesEvent {
  type: "deviceadded" | "deviceremoved" | "deviceschange";
  device?: DeviceInfo;
  devices: DeviceInfo[];
  added: DeviceInfo[];
  removed: DeviceInfo[];
}

device 只在 deviceadded 和 deviceremoved 事件中提供。devices 是变化后的完整列表。

crscrcpy.on("deviceschange", event => {
  console.log("Added:", event.added.map(item => item.serial));
  console.log("Removed:", event.removed.map(item => item.serial));
  console.log("Current:", event.devices.map(item => item.serial));
});

设备读取接口

以下读取接口只存在于单设备对象上,不存在于批量对象或设备组对象上。

device.getState()

读取 crScrcpy Session 的当前状态。

interface DeviceStateInfo {
  serial: string;
  known: boolean;
  state: "connecting" | "connected" | "disconnected" | "error" | "unknown";
  generation: number;
}

device.getState(): Promise<DeviceStateInfo>
const state = await device.getState();
console.log(state.state, state.generation);

known: false 表示 Session 状态快照尚未建立。

device.getDisplayInfo()

读取当前显示相关信息。

interface DisplayInfo {
  serial: string;
  known: boolean;
  width: number;
  height: number;
  powerOn: boolean;
}

device.getDisplayInfo(): Promise<DisplayInfo>
const display = await device.getDisplayInfo();
console.log(`${display.width}x${display.height}`, display.powerOn);

当前的 width 和 height 来自已建立的视频流尺寸。视频尚未初始化时可能为 0。

powerOn 是 crScrcpy 当前缓存的显示电源状态,不是设备端对本次读取请求的实时确认。

device.getVideoInfo()

读取当前视频流和解码器信息。

interface VideoInfo {
  serial: string;
  known: boolean;
  state: "connecting" | "connected" | "disconnected" | "error" | "unknown";
  generation: number;
  width: number;
  height: number;
  videoCodec: string;
  hardwareDecoder: boolean;
}

device.getVideoInfo(): Promise<VideoInfo>
const video = await device.getVideoInfo();
if (video.known) {
  console.log(video.videoCodec, video.width, video.height);
  console.log("Hardware decoder:", video.hardwareDecoder);
}

device.getClipboard()

请求并返回设备剪贴板文本。5 秒内没有收到设备回复时,Promise 会 reject。

device.getClipboard(): Promise<string>
try {
  const text = await device.getClipboard();
  console.log("Clipboard:", text);
} catch (error) {
  console.error("Cannot read clipboard:", error);
}

设备控制接口

本节中的所有方法都存在于 ScrcpyDevice、DeviceCollection 和 DeviceGroup 上。单设备返回 Promise<CommandResult>,批量设备和设备组返回 Promise<CommandResult[]>。

reconnect()

要求 crScrcpy 重新连接设备 Session。

const result = await device.reconnect();
console.log(result.accepted);

touch(action, x, y[, width, height])

发送触摸事件。

type TouchAction = "down" | "move" | "up" | "cancel";

device.touch(action: TouchAction, x: number, y: number): Promise<CommandResult>
device.touch(
  action: TouchAction,
  x: number,
  y: number,
  width: number,
  height: number
): Promise<CommandResult>

省略 width 和 height 时,crScrcpy 自动使用该设备最新的视频尺寸。坐标和尺寸必须是整数,坐标不能为负数。

下面的代码点击视频画面中心:

const video = await device.getVideoInfo();
const x = Math.floor(video.width / 2);
const y = Math.floor(video.height / 2);

await device.touch("down", x, y);
await new Promise(resolve => setTimeout(resolve, 60));
await device.touch("up", x, y);

也可以显式传入坐标空间尺寸:

await device.touch("down", 500, 900, 1080, 1920);
await device.touch("up", 500, 900, 1080, 1920);

scroll(x, y[, width, height], horizontal, vertical)

以指定坐标为中心发送滚动事件。水平和垂直滚动量必须是有限数值,范围为 -100 到 100。

device.scroll(
  x: number,
  y: number,
  horizontal: number,
  vertical: number
): Promise<CommandResult>

device.scroll(
  x: number,
  y: number,
  width: number,
  height: number,
  horizontal: number,
  vertical: number
): Promise<CommandResult>
// 使用自动视频尺寸,在 (500, 900) 处垂直滚动。
await device.scroll(500, 900, 0, -1);
// 显式指定 1080 x 1920 的坐标空间。
await device.scroll(500, 900, 1080, 1920, 0, 1);

key(pressed, androidKeycode[, repeat, metastate])

发送 Android 按键事件。androidKeycode 是 Android KeyEvent key code,当前允许范围为 0 到 1000;repeat 和 metastate 是可选的无符号整数,默认值为 0。

device.key(
  pressed: boolean,
  androidKeycode: number,
  repeat?: number,
  metastate?: number
): Promise<CommandResult>
const KEYCODE_HOME = 3;
await device.key(true, KEYCODE_HOME);
await device.key(false, KEYCODE_HOME);

inputText(text)

向当前输入焦点发送文本。

await device.inputText("Hello from crScrcpy");

paste(text)

设置设备剪贴板并立即触发粘贴。

await device.paste("Text to paste");

setClipboard(text)

只同步设备剪贴板,不触发粘贴动作。

await device.setClipboard("Clipboard text");

copyClipboard()

请求设备剪贴板,并将结果复制到主机剪贴板。该方法的 Promise 只表示请求是否被接受;如果脚本需要取得文本,应使用 getClipboard()。

await device.copyClipboard();

backOrScreenOn()

设备亮屏时执行返回操作;设备息屏时请求亮屏。

await device.backOrScreenOn();

setDisplayPower(on)

开启或关闭设备显示。

await device.setDisplayPower(false);
await new Promise(resolve => setTimeout(resolve, 1000));
await device.setDisplayPower(true);

expandNotifications()

展开通知面板。

await device.expandNotifications();

expandQuickSettings()

展开快速设置面板。

await device.expandQuickSettings();

collapsePanels()

收起通知和快速设置面板。

await device.collapsePanels();

startApp(nameOrPackage)

通过应用名称或包名启动应用。建议将参数控制在 255 UTF-8 字节以内。

await device.startApp("com.android.settings");

resetVideo()

请求设备端重置视频流,可用于视频画面异常后的恢复。

await device.resetVideo();

设备事件

单设备对象支持以下事件:

  • statechange
  • clipboardchange
  • displaypowerchange
  • videochange
  • error

device.on(type, callback)

持续订阅设备事件,成功时返回 true。

function onStateChange(event) {
  console.log("State:", event.state);
}

device.on("statechange", onStateChange);

device.once(type, callback)

只监听下一次指定事件。

device.once("clipboardchange", event => {
  console.log("Next clipboard value:", event.text);
});

device.off(type, callback)

取消订阅。必须传入注册时的同一个函数对象。成功移除回调时返回 true。

function onVideoChange(event) {
  console.log(event.width, event.height, event.videoCodec);
}

device.on("videochange", onVideoChange);
device.off("videochange", onVideoChange);

事件回调保持注册时,脚本会继续处于运行状态。长期运行的脚本应在不再需要事件时调用 off(),或者使用 once()。

Session、视频和错误事件对象

statechange、videochange 和 error 使用相同的基础对象:

interface SessionEvent {
  type: "statechange" | "videochange" | "error";
  serial: string;
  state: "connecting" | "connected" | "disconnected" | "error";
  generation: number;
  videoCodec: string;
  hardwareDecoder: boolean;
  width: number;
  height: number;
}

videochange 在 Session/视频状态更新时触发;error 在 Session 进入 error 状态时触发。

device.on("videochange", event => {
  console.info(
    `${event.serial}: ${event.width}x${event.height}`,
    event.videoCodec,
    event.hardwareDecoder ? "hardware" : "software"
  );
});

clipboardchange 事件对象

interface ClipboardEvent {
  type: "clipboardchange";
  serial: string;
  text: string;
}
device.on("clipboardchange", event => {
  console.log("Clipboard changed:", event.text);
});

displaypowerchange 事件对象

interface DisplayPowerEvent {
  type: "displaypowerchange";
  serial: string;
  powerOn: boolean;
}
device.on("displaypowerchange", event => {
  console.log(event.powerOn ? "Display on" : "Display off");
});

批量设备 API

crscrcpy.devices(serials) 返回的对象具有全部设备控制方法,但没有单设备读取方法和事件方法。

(async () => {
  const serials = crscrcpy.getDevices().map(device => device.serial);
  const devices = crscrcpy.devices(serials);

  const results = await devices.startApp("com.android.settings");
  const rejected = results.filter(result => !result.accepted);
  if (rejected.length > 0) {
    console.warn("Not accepted:", rejected);
  }
})();

自动 touch/scroll 尺寸会针对每台设备分别读取,因此不同分辨率的设备可以使用同一批量调用。传入的 x、y 仍然是绝对像素坐标;如果需要按比例点击,应先分别读取各设备尺寸并单独调用。

(async () => {
  for (const info of crscrcpy.getDevices()) {
    const device = crscrcpy.device(info.serial);
    const video = await device.getVideoInfo();
    if (video.width <= 0 || video.height <= 0) {
      continue;
    }

    await device.touch(
      "down",
      Math.floor(video.width * 0.5),
      Math.floor(video.height * 0.5)
    );
  }
})();

Device Group API

设备组对象提供以下成员:

interface DeviceGroup {
  readonly id: string;
  info(): DeviceGroupInfo | null;
  devices(): DeviceCollection | null;

  // 同时还提供“设备控制接口”一节中的全部控制方法。
}

直接调用组对象的控制方法,或者先调用 group.devices(),效果相同。每次执行控制命令时都会读取组内当前的设备序列号。

(async () => {
  const groups = crscrcpy.getDeviceGroups();
  if (groups.length === 0) {
    console.warn("No device group");
    return;
  }

  const group = crscrcpy.group(groups[0].id);
  console.log("Running on group:", group.info().name);

  const results = await group.expandNotifications();
  console.log(results);
})();

Timer API

Timer 接口与浏览器中的常用形式相似,并支持向回调传递额外参数。

setTimeout(callback, delay[, ...args])

创建一次性 Timer,返回整数 ID。延迟单位为毫秒,范围会限制在 0 到 24 小时。

const timerId = setTimeout((message) => {
  console.log(message);
}, 500, "Executed after 500 ms");

setInterval(callback, delay[, ...args])

创建重复 Timer。最小间隔为 10 毫秒。

let count = 0;
const timerId = setInterval(() => {
  console.log("Tick", ++count);
  if (count >= 3) {
    clearInterval(timerId);
  }
}, 1000);

clearTimeout(id) / clearInterval(id)

取消 Timer。两个清理函数行为相同,可以清理任意一种 Timer。

const timerId = setTimeout(() => console.log("Not executed"), 5000);
clearTimeout(timerId);

存在活动 Timer 时,脚本会保持运行。重复 Timer 应在不再需要时清理。

Console API

支持四个输出级别:

console.log("normal message");
console.info("information", { connected: true });
console.warn("warning");
console.error("error");

多个参数会以空格连接。对象和数组会优先使用 JSON 格式输出。

Promise 和错误处理

原生读取、控制方法以及 async 事件/Timer 回调返回的 Promise 会被运行时跟踪。建议始终处理可能的 reject:

(async () => {
  try {
    const device = crscrcpy.device("DEVICE_SERIAL");
    const clipboard = await device.getClipboard();
    console.log(clipboard);
  } catch (error) {
    console.error(error);
  }
})();

未处理的 Promise rejection 会输出错误并使脚本进入失败状态。编译错误和运行异常会在 Console 中包含阶段、源码位置、相关源码行和 JavaScript Stack。

脚本生命周期

运行状态包括:

  • starting
  • running
  • stopped
  • completed
  • failed

同步代码执行完毕后,如果没有 Timer、事件监听器、待处理命令、读取请求或被跟踪的 Promise,脚本会自动进入 completed。点击 Stop 会清理脚本运行环境并进入 stopped。

当前资源限制

这些限制用于避免单个脚本影响应用和其他脚本:

  • 最多同时运行 8 个脚本。
  • 每个脚本最多使用约 64 MiB V8 Heap。
  • 单次同步 JavaScript 执行或回调最长 2 秒。
  • 每个脚本最多 256 个 Timer。
  • 每个脚本最多 256 个事件监听器。
  • 每个脚本最多分别保留 256 个待处理控制操作和 256 个设备查询。
  • 单次批量调用最多 64 台设备。
  • 每台设备最多 16 个并行剪贴板读取请求。
  • 文本参数最大 256 KiB。
  • 单条 Console 消息最大 64 KiB。
  • 每个脚本的 Console 输出速率最大约 1 MiB/s,超出后会暂时限流。

不要在脚本中执行持续的同步循环:

// 错误示例:可能触发 2 秒 watchdog。
while (true) {
}

需要持续工作时应使用事件、setInterval(),或者在循环中通过 Promise/Timer 主动让出执行权。

当前尚未提供的接口

当前版本还没有公开以下 JavaScript 能力:

  • Screenshot 和 Recording API。
  • 视频帧内容读取。
  • 显示 rotation、density、displayId 读取。
  • 视频 frameRate、bitRate 读取。
  • 文件系统、网络请求和浏览器 DOM API。

脚本不要假设这些方法存在。后续版本增加接口时,本文档也会同步更新。

完整示例:监听设备并执行一次操作

下面的示例会监听设备列表变化,并在当前第一台设备上读取状态、启动设置应用和监听视频状态:

function printDeviceList(event) {
  console.info(
    "Current devices:",
    event.devices.map(device => device.serial)
  );
}

crscrcpy.on("deviceschange", printDeviceList);

(async () => {
  const devices = crscrcpy.getDevices();
  if (devices.length === 0) {
    console.warn("Connect an Android device first");
    return;
  }

  const device = crscrcpy.device(devices[0].serial);

  device.on("statechange", event => {
    console.info("Session state:", event.state);
  });

  device.on("videochange", event => {
    console.info(
      `Video: ${event.width}x${event.height}, ${event.videoCodec}`
    );
  });

  const state = await device.getState();
  console.log("Initial state:", state);

  const result = await device.startApp("com.android.settings");
  if (!result.accepted) {
    console.error("startApp was not accepted");
  }
})().catch(error => console.error(error));

该示例注册了长期事件监听器,因此会一直保持运行,直到用户点击 Stop。