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.getDevices(): DeviceInfo[]for (const device of crscrcpy.getDevices()) {
console.log(device.serial, device.model, device.state);
}返回的数组不会自动更新。需要跟踪设备变化时,请同时监听全局设备事件。
根据序列号创建或取得一个设备控制对象。
crscrcpy.device(serial: string): ScrcpyDeviceconst device = crscrcpy.device("192.168.1.20:5555");
console.log(device.serial);该接口只要求非空序列号,不保证设备当前在线。通常应从 getDevices() 的结果中选择序列号。
创建批量设备控制对象。最多接受 64 个序列号,重复序列号会被去重。
crscrcpy.devices(serials: string[]): DeviceCollectionconst 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(): DeviceGroupInfo[]for (const group of crscrcpy.getDeviceGroups()) {
console.log(group.name, group.serials);
}根据组 ID 取得设备组对象。分组不存在时返回 null。
crscrcpy.group(id: string): DeviceGroup | nullconst info = crscrcpy.getDeviceGroups()[0];
if (info) {
const group = crscrcpy.group(info.id);
const results = await group.backOrScreenOn();
console.log(results);
}支持以下事件:
deviceadded:发现新设备。deviceremoved:设备从列表中移除。deviceschange:设备列表发生变化。
持续订阅事件,成功时返回 true。
function onDeviceAdded(event) {
console.info("Added:", event.device.serial);
}
crscrcpy.on("deviceadded", onDeviceAdded);只监听下一次事件,回调执行前会自动取消订阅。
crscrcpy.once("deviceremoved", event => {
console.warn("Removed:", event.device.serial);
});取消订阅。必须传入注册时的同一个函数对象;成功删除至少一个回调时返回 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));
});以下读取接口只存在于单设备对象上,不存在于批量对象或设备组对象上。
读取 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 状态快照尚未建立。
读取当前显示相关信息。
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 当前缓存的显示电源状态,不是设备端对本次读取请求的实时确认。
读取当前视频流和解码器信息。
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);
}请求并返回设备剪贴板文本。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[]>。
要求 crScrcpy 重新连接设备 Session。
const result = await device.reconnect();
console.log(result.accepted);发送触摸事件。
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);以指定坐标为中心发送滚动事件。水平和垂直滚动量必须是有限数值,范围为 -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);发送 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);向当前输入焦点发送文本。
await device.inputText("Hello from crScrcpy");设置设备剪贴板并立即触发粘贴。
await device.paste("Text to paste");只同步设备剪贴板,不触发粘贴动作。
await device.setClipboard("Clipboard text");请求设备剪贴板,并将结果复制到主机剪贴板。该方法的 Promise 只表示请求是否被接受;如果脚本需要取得文本,应使用 getClipboard()。
await device.copyClipboard();设备亮屏时执行返回操作;设备息屏时请求亮屏。
await device.backOrScreenOn();开启或关闭设备显示。
await device.setDisplayPower(false);
await new Promise(resolve => setTimeout(resolve, 1000));
await device.setDisplayPower(true);展开通知面板。
await device.expandNotifications();展开快速设置面板。
await device.expandQuickSettings();收起通知和快速设置面板。
await device.collapsePanels();通过应用名称或包名启动应用。建议将参数控制在 255 UTF-8 字节以内。
await device.startApp("com.android.settings");请求设备端重置视频流,可用于视频画面异常后的恢复。
await device.resetVideo();单设备对象支持以下事件:
statechangeclipboardchangedisplaypowerchangevideochangeerror
持续订阅设备事件,成功时返回 true。
function onStateChange(event) {
console.log("State:", event.state);
}
device.on("statechange", onStateChange);只监听下一次指定事件。
device.once("clipboardchange", event => {
console.log("Next clipboard value:", event.text);
});取消订阅。必须传入注册时的同一个函数对象。成功移除回调时返回 true。
function onVideoChange(event) {
console.log(event.width, event.height, event.videoCodec);
}
device.on("videochange", onVideoChange);
device.off("videochange", onVideoChange);事件回调保持注册时,脚本会继续处于运行状态。长期运行的脚本应在不再需要事件时调用 off(),或者使用 once()。
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"
);
});interface ClipboardEvent {
type: "clipboardchange";
serial: string;
text: string;
}device.on("clipboardchange", event => {
console.log("Clipboard changed:", event.text);
});interface DisplayPowerEvent {
type: "displaypowerchange";
serial: string;
powerOn: boolean;
}device.on("displaypowerchange", event => {
console.log(event.powerOn ? "Display on" : "Display off");
});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)
);
}
})();设备组对象提供以下成员:
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 接口与浏览器中的常用形式相似,并支持向回调传递额外参数。
创建一次性 Timer,返回整数 ID。延迟单位为毫秒,范围会限制在 0 到 24 小时。
const timerId = setTimeout((message) => {
console.log(message);
}, 500, "Executed after 500 ms");创建重复 Timer。最小间隔为 10 毫秒。
let count = 0;
const timerId = setInterval(() => {
console.log("Tick", ++count);
if (count >= 3) {
clearInterval(timerId);
}
}, 1000);取消 Timer。两个清理函数行为相同,可以清理任意一种 Timer。
const timerId = setTimeout(() => console.log("Not executed"), 5000);
clearTimeout(timerId);存在活动 Timer 时,脚本会保持运行。重复 Timer 应在不再需要时清理。
支持四个输出级别:
console.log("normal message");
console.info("information", { connected: true });
console.warn("warning");
console.error("error");多个参数会以空格连接。对象和数组会优先使用 JSON 格式输出。
原生读取、控制方法以及 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。
运行状态包括:
startingrunningstoppedcompletedfailed
同步代码执行完毕后,如果没有 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。