@u4/adbkit
Version:
A Typescript client for the Android Debug Bridge.
589 lines • 25.9 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const events_1 = __importDefault(require("events"));
const promise_duplex_1 = __importDefault(require("promise-duplex"));
const utils_1 = __importDefault(require("../../utils"));
const BufWrite_1 = require("../minicap/BufWrite");
const ThirdUtils_1 = __importDefault(require("../ThirdUtils"));
const fs_1 = __importDefault(require("fs"));
const sps_1 = require("./sps");
const assert_1 = __importDefault(require("assert"));
const debug = utils_1.default.debug('adb:scrcpy');
// const KEYFRAME_PTS = BigInt(1) << BigInt(62);
// from https://github.com/Genymobile/scrcpy/blob/master/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java
const PACKET_FLAG_CONFIG = BigInt(1) << BigInt(63);
const PACKET_FLAG_KEY_FRAME = BigInt(1) << BigInt(62);
/**
* How scrcpy works?
*
* Its a jar file that runs on an adb shell. It records the screen in h264 and offers it in a given tcp port.
*
* scrcpy params
* maxSize (integer, multiple of 8) 0
* bitRate (integer)
* tunnelForward (optional, bool) use "adb forward" instead of "adb tunnel"
* crop (optional, string) "width:height:x:y"
* sendFrameMeta (optional, bool)
*
* The video stream contains raw packets, without time information. If sendFrameMeta is enabled a meta header is added
* before each packet.
* The "meta" header length is 12 bytes
* [. . . . . . . .|. . . .]. . . . . . . . . . . . . . . ...
* <-------------> <-----> <-----------------------------...
* PTS size raw packet (of size len)
*
* WARNING:
* Need USB Debug checked in developper option for MIUI
*/
class Scrcpy extends events_1.default {
constructor(client, config = {}) {
super();
this.client = client;
/**
* closed had been call stop all new activity
*/
this.closed = false;
this.on = (event, listener) => super.on(event, listener);
this.off = (event, listener) => super.off(event, listener);
this.once = (event, listener) => super.once(event, listener);
this.emit = (event, ...args) => super.emit(event, ...args);
this.config = {
version: 24,
// port: 8099,
maxSize: 600,
maxFps: 0,
flip: false,
bitrate: 999999999,
lockedVideoOrientation: -1 /* Orientation.LOCK_VIDEO_ORIENTATION_UNLOCKED */,
tunnelForward: true,
tunnelDelay: 1000,
crop: '', //'9999:9999:0:0',
sendFrameMeta: true, // send PTS so that the client may record properly
control: true,
displayId: 0,
showTouches: false,
stayAwake: true,
codecOptions: '',
encoderName: '',
powerOffScreenOnClose: false,
// clipboardAutosync: true,
...config
};
this._name = new Promise((resolve) => this.setName = resolve);
this._width = new Promise((resolve) => this.setWidth = resolve);
this._height = new Promise((resolve) => this.setHeight = resolve);
this._onTermination = new Promise((resolve) => this.setFatalError = resolve);
this._firstFrame = new Promise((resolve) => this.setFirstFrame = resolve);
}
get name() { return this._name; }
get width() { return this._width; }
get height() { return this._height; }
/**
* Clever way to detect Termination.
* return the Ending message.
*/
get onTermination() { return this._onTermination; }
/**
* Promise to the first emited frame
* can be used to unsure that scrcpy propery start
*/
get firstFrame() { return this._firstFrame; }
/**
* emit scrcpyServer output as Error
* @param duplex
* @returns
*/
async throwsErrors(duplex) {
try {
const errors = [];
for (;;) {
await utils_1.default.waitforReadable(duplex, 0, 'wait for error');
const data = await duplex.read();
if (data) {
const msg = data.toString().trim();
errors.push(msg);
try {
this.emit('error', Error(msg));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
}
catch (e) {
// emit Error but to not want to Quit Yet
}
}
else {
this._setFatalError(errors.join('\n'));
break;
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
}
catch (e) {
//this.emit('error', e as Error);
//this.setError((e as Error).message);
}
}
_setFatalError(msg) {
if (this.setFatalError) {
this.setFatalError(msg);
this.setFatalError = undefined;
}
}
/**
* get last current video config
*/
get videoConfig() {
return this.lastConf;
}
/**
* Read a message from the contoler Duplex
*
* @param duplex only supoport clipboard
* @returns
*/
async readOneMessage(duplex) {
if (!duplex)
return '';
// const waitforReadable = () => new Promise<void>((resolve) => duplex.readable.stream.once('readable', resolve));
await utils_1.default.waitforReadable(duplex);
let chunk = (await duplex.read(1));
const type = chunk.readUInt8();
switch (type) {
case 0: // clipboard
await utils_1.default.waitforReadable(duplex);
chunk = (await duplex.read(4));
await utils_1.default.waitforReadable(duplex);
const len = chunk.readUint32BE();
await utils_1.default.waitforReadable(duplex);
chunk = (await duplex.read(len));
const text = chunk.toString('utf8');
return text;
default:
throw Error(`Unsupported message type:${type}`);
}
}
_getStartupLine(jarDest) {
const args = [];
const { maxSize, bitrate, maxFps, lockedVideoOrientation, tunnelForward, crop, sendFrameMeta, control, displayId, showTouches, stayAwake, codecOptions, encoderName, powerOffScreenOnClose, clipboardAutosync } = this.config;
args.push(`CLASSPATH=${jarDest}`);
args.push('app_process');
args.push('/');
args.push('com.genymobile.scrcpy.Server');
if (this.config.version <= 20) {
// Version 11 => 20
args.push(`1.${this.config.version}`); // arg 0 Scrcpy server version
args.push("info"); // Log level: info, verbose...
args.push(maxSize); // Max screen width (long side)
args.push(bitrate); // Bitrate of video
args.push(maxFps); // Max frame per second
args.push(lockedVideoOrientation); // Lock screen orientation: LOCK_SCREEN_ORIENTATION
args.push(tunnelForward); // Tunnel forward
args.push(crop || '-'); // Crop screen
args.push(sendFrameMeta); // Send frame rate to client
args.push(control); // Control enabled
args.push(displayId); // Display id
args.push(showTouches); // Show touches
args.push(stayAwake); // if self.stay_awake else "false", Stay awake
args.push(codecOptions || '-'); // Codec (video encoding) options
args.push(encoderName || '-'); // Encoder name
args.push(powerOffScreenOnClose); // Power off screen after server closed
}
else {
args.push(`1.${this.config.version}`); // arg 0 Scrcpy server version
args.push("log_level=info");
args.push(`max_size=${maxSize}`);
args.push(`bit_rate=${bitrate}`);
args.push(`max_fps=${maxFps}`);
args.push(`lock_video_orientation=${lockedVideoOrientation}`);
args.push(`tunnel_forward=${tunnelForward}`); // Tunnel forward
if (crop && crop !== '-')
args.push(`crop=${crop}`); // Crop screen
args.push(`send_frame_meta=${sendFrameMeta}`); // Send frame rate to client
args.push(`control=${control}`); // Control enabled
args.push(`display_id=${displayId}`); // Display id
args.push(`show_touches=${showTouches}`); // Show touches
args.push(`stay_awake=${stayAwake}`); // if self.stay_awake else "false", Stay awake
if (codecOptions && codecOptions !== '-')
args.push(`codec_options=${codecOptions}`); // Codec (video encoding) options
if (encoderName && encoderName !== '-')
args.push(`encoder_name=${encoderName}`); // Encoder name
args.push(`power_off_on_close=${powerOffScreenOnClose}`); // Power off screen after server closed
// args.push(`clipboard_autosync=${clipboardAutosync}`); // default is True
if (clipboardAutosync !== undefined)
args.push(`clipboard_autosync=${clipboardAutosync}`); // default is True
if (this.config.version >= 22) {
const { downsizeOnError, sendDeviceMeta, sendDummyByte, rawVideoStream } = this.config;
if (downsizeOnError !== undefined)
args.push(`downsize_on_error=${downsizeOnError}`);
if (sendDeviceMeta !== undefined)
args.push(`send_device_meta=${sendDeviceMeta}`);
if (sendDummyByte !== undefined)
args.push(`send_dummy_byte=${sendDummyByte}`);
if (rawVideoStream !== undefined)
args.push(`raw_video_stream=${rawVideoStream}`);
}
if (this.config.version >= 22) {
const { cleanup } = this.config;
if (cleanup !== undefined)
args.push(`raw_video_stream=${cleanup}`);
}
// check Server.java
}
return args.map(a => a.toString()).join(' ');
}
/**
* Will connect to the android device, send & run the server and return deviceName, width and height.
* After that data will be offered as a 'data' event.
*/
async start() {
if (this.closed) // can not start once stop called
return this;
const jarDest = '/data/local/tmp/scrcpy-server.jar';
// Transfer server...
const jar = ThirdUtils_1.default.getResourcePath(`scrcpy-server-v1.${this.config.version}.jar`);
const srcStat = await fs_1.default.promises.stat(jar).catch(() => null);
const dstStat = await this.client.stat(jarDest).catch(() => null);
if (!srcStat)
throw Error(`fail to get ressource ${jar}`);
if (!dstStat || srcStat.size !== dstStat.size) {
try {
debug(`pushing scrcpy-server.jar to ${this.client.serial}`);
const transfer = await this.client.push(jar, jarDest);
await transfer.waitForEnd();
}
catch (e) {
debug(`Impossible to transfer server scrcpy-server.jar to ${this.client.serial}`, e);
throw e;
}
}
else {
debug(`scrcpy-server.jar already present in ${this.client.serial}, keep it`);
}
// Start server
try {
const cmdLine = this._getStartupLine(jarDest);
if (this.closed) // can not start once stop called
return this;
const duplex = await this.client.shell(cmdLine);
this.scrcpyServer = new promise_duplex_1.default(duplex);
this.scrcpyServer.once("finish").then(() => {
debug(`scrcpyServer finished on device ${this.client.serial}`);
this.stop();
});
// debug only
// extraUtils.dumpReadable(this.scrcpyServer, 'scrcpyServer');
}
catch (e) {
debug('Impossible to run server:', e);
throw e;
}
let info = '';
for (;;) {
if (!await utils_1.default.waitforReadable(this.scrcpyServer, this.config.tunnelDelay, 'scrcpyServer stdout loading')) {
// const msg = `First line should be '[server] // INFO: Device: Name (Version), reveived:\n\n${info}`
const error = `Starting scrcpyServer failed, scrcpy stdout:${info}`;
this._setFatalError(error);
this.stop();
throw Error(error);
}
const srvOut = await this.scrcpyServer.read();
info += (srvOut) ? srvOut.toString() : '';
if (info.includes('[server] INFO: Device: '))
break;
}
this.throwsErrors(this.scrcpyServer);
// Wait 1 sec to forward to work
// await Util.delay(this.config.tunnelDelay);
if (this.closed) // can not start once stop called
return this;
// Connect videoSocket
await utils_1.default.delay(100);
this.videoSocket = await this.client.openLocal2('localabstract:scrcpy', 'first connection to scrcpy for video');
// Connect controlSocket
if (this.closed) {
this.stop();
return this;
}
this.controlSocket = await this.client.openLocal2('localabstract:scrcpy', 'second connection to scrcpy for control');
if (this.closed) {
this.stop();
return this;
}
// First chunk is 69 bytes length -> 1 dummy byte, 64 bytes for deviceName, 2 bytes for width & 2 bytes for height
try {
await utils_1.default.waitforReadable(this.videoSocket, 0, 'videoSocket 1st 1 bit chunk');
const firstChunk = await this.videoSocket.read(1);
if (!firstChunk) {
throw Error('fail to read firstChunk, inclease tunnelDelay for this device.');
}
// old protocol
const control = firstChunk.at(0);
if (firstChunk.at(0) !== 0) {
if (control)
throw Error(`Control code should be 0x00, receves: 0x${control.toString(16).padStart(2, '0')}`);
throw Error(`Control code should be 0x00, receves nothing.`);
}
}
catch (e) {
debug('Impossible to read first chunk:', e);
throw e;
}
if (this.config.sendFrameMeta) {
void this.startStreamWithMeta().catch(() => this.stop());
}
else {
this.startStreamRaw();
}
// wait the first chunk
await this.height;
return this;
}
stop() {
this.closed = true;
let close = false;
if (this.videoSocket) {
this.videoSocket.destroy();
this.videoSocket = undefined;
close = true;
}
if (this.controlSocket) {
this.controlSocket.destroy();
this.controlSocket = undefined;
close = true;
}
if (this.scrcpyServer)
this.scrcpyServer.destroy();
if (close) {
this.emit('disconnect');
this._setFatalError('stoped');
}
return close;
}
isRunning() {
return this.videoSocket !== null;
}
startStreamRaw() {
(0, assert_1.default)(this.videoSocket);
this.videoSocket.stream.on('data', d => this.emit('raw', d));
}
/**
* capture all video trafique in a loop
* get resolve once capture stop
*/
async startStreamWithMeta() {
(0, assert_1.default)(this.videoSocket);
this.videoSocket.stream.pause();
await utils_1.default.waitforReadable(this.videoSocket, 0, 'videoSocket header');
const chunk = this.videoSocket.stream.read(68);
const name = chunk.toString('utf8', 0, 64).trim();
this.setName(name);
const width = chunk.readUint16BE(64);
this.setWidth(width);
const height = chunk.readUint16BE(66);
this.setHeight(height);
// let header: Uint8Array | undefined;
let pts = BigInt(0); // Buffer.alloc(0);
for (;;) {
if (!this.videoSocket)
break;
await utils_1.default.waitforReadable(this.videoSocket, 0, 'videoSocket packet size');
let len = undefined;
if (this.config.sendFrameMeta) {
const frameMeta = this.videoSocket.stream.read(12);
if (!frameMeta) {
// regular end condition
return;
}
// console.log(frameMeta.toString('hex').replace(/(........)/g, '$1 '))
pts = frameMeta.readBigUint64BE();
len = frameMeta.readUInt32BE(8);
// else {bufferInfo.presentationTimeUs - ptsOrigin}
// debug(`\tHeader:PTS =`, pts);
// debug(`\tHeader:len =`, len);
}
const config = !!(pts & PACKET_FLAG_CONFIG);
let streamChunk = null;
while (streamChunk === null) {
await utils_1.default.waitforReadable(this.videoSocket, 0, 'videoSocket streamChunk');
streamChunk = this.videoSocket.stream.read(len);
if (streamChunk) {
if (config) { // non-media data packet len: 33
/**
* is a config package pts have PACKET_FLAG_CONFIG flag
*/
const sequenceParameterSet = (0, sps_1.parse_sequence_parameter_set)(streamChunk);
const { profile_idc: profileIndex, constraint_set: constraintSet, level_idc: levelIndex, pic_width_in_mbs_minus1, pic_height_in_map_units_minus1, frame_mbs_only_flag, frame_crop_left_offset, frame_crop_right_offset, frame_crop_top_offset, frame_crop_bottom_offset, } = sequenceParameterSet;
const encodedWidth = (pic_width_in_mbs_minus1 + 1) * 16;
const encodedHeight = (pic_height_in_map_units_minus1 + 1) * (2 - frame_mbs_only_flag) * 16;
const cropLeft = frame_crop_left_offset * 2;
const cropRight = frame_crop_right_offset * 2;
const cropTop = frame_crop_top_offset * 2;
const cropBottom = frame_crop_bottom_offset * 2;
const croppedWidth = encodedWidth - cropLeft - cropRight;
const croppedHeight = encodedHeight - cropTop - cropBottom;
const videoConf = {
profileIndex, constraintSet, levelIndex, encodedWidth, encodedHeight,
cropLeft, cropRight, cropTop, cropBottom, croppedWidth, croppedHeight,
data: streamChunk
};
this.lastConf = videoConf;
this.emit('config', videoConf);
}
else {
/**
* if pts have PACKET_FLAG_KEY_FRAME, this is a keyframe
*/
const keyframe = !!(pts & PACKET_FLAG_KEY_FRAME);
if (keyframe) {
pts &= ~PACKET_FLAG_KEY_FRAME;
}
const frame = { keyframe, pts, data: streamChunk, config: this.lastConf };
if (this.setFirstFrame) {
this.setFirstFrame();
this.setFirstFrame = undefined;
}
this.emit('frame', frame);
}
}
else {
// large chunk.
// console.log('fail to streamChunk len:', len);
await utils_1.default.delay(0);
}
}
}
}
// ControlMessages
// TYPE_INJECT_KEYCODE
/**
* // will be convert in a android.view.KeyEvent
* https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/KeyEvent.java
* @param action
* @param keyCode
* @param repeatCount
* @param metaState combinaison of KeyEventMeta
*/
async injectKeycodeEvent(action, keyCode, repeatCount, metaState) {
const chunk = new BufWrite_1.BufWrite(14);
chunk.writeUint8(0 /* ControlMessage.TYPE_INJECT_KEYCODE */);
chunk.writeUint8(action);
chunk.writeUint32BE(keyCode);
chunk.writeUint32BE(repeatCount);
chunk.writeUint32BE(metaState);
(0, assert_1.default)(this.controlSocket);
await this.controlSocket.write(chunk.buffer);
}
// TYPE_INJECT_TEXT
async injectText(text) {
const chunk = new BufWrite_1.BufWrite(5);
chunk.writeUint8(1 /* ControlMessage.TYPE_INJECT_TEXT */);
chunk.writeString(text);
(0, assert_1.default)(this.controlSocket);
await this.controlSocket.write(chunk.buffer);
}
/**
* android.view.MotionEvent;
* https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/MotionEvent.java
* @param action
* @param pointerId
* @param position
* @param screenSize
* @param pressure
*/
// usb.data_len == 28
async injectTouchEvent(action, pointerId, position, screenSize, pressure) {
const chunk = new BufWrite_1.BufWrite(28);
chunk.writeUint8(2 /* ControlMessage.TYPE_INJECT_TOUCH_EVENT */);
chunk.writeUint8(action);
if (pressure === undefined) {
if (action == 1 /* MotionEvent.ACTION_UP */)
pressure = 0x0;
else if (action == 0 /* MotionEvent.ACTION_DOWN */)
pressure = 0xffff;
else
pressure = 0xffff;
}
// Writes a long to the underlying output stream as eight bytes, high byte first.
chunk.writeBigUint64BE(pointerId);
chunk.writeUint32BE(position.x | 0);
chunk.writeUint32BE(position.y | 0);
chunk.writeUint16BE(screenSize.x | 0);
chunk.writeUint16BE(screenSize.y | 0);
chunk.writeUint16BE(pressure);
chunk.writeUint32BE(1 /* MotionEvent.BUTTON_PRIMARY */);
(0, assert_1.default)(this.controlSocket);
await this.controlSocket.write(chunk.buffer);
// console.log(chunk.buffer.toString('hex'))
}
async injectScrollEvent(position, screenSize, HScroll, VScroll) {
const chunk = new BufWrite_1.BufWrite(20);
chunk.writeUint8(3 /* ControlMessage.TYPE_INJECT_SCROLL_EVENT */);
// Writes a long to the underlying output stream as eight bytes, high byte first.
chunk.writeUint32BE(position.x | 0);
chunk.writeUint32BE(position.y | 0);
chunk.writeUint16BE(screenSize.x | 0);
chunk.writeUint16BE(screenSize.y | 0);
chunk.writeUint16BE(HScroll);
chunk.writeInt32BE(VScroll);
chunk.writeInt32BE(1 /* MotionEvent.BUTTON_PRIMARY */);
(0, assert_1.default)(this.controlSocket);
await this.controlSocket.write(chunk.buffer);
}
// TYPE_BACK_OR_SCREEN_ON
async injectBackOrScreenOn() {
const chunk = new BufWrite_1.BufWrite(2);
chunk.writeUint8(4 /* ControlMessage.TYPE_BACK_OR_SCREEN_ON */);
chunk.writeUint8(1 /* MotionEvent.ACTION_UP */);
(0, assert_1.default)(this.controlSocket);
await this.controlSocket.write(chunk.buffer);
}
// TYPE_EXPAND_NOTIFICATION_PANEL
async expandNotificationPanel() {
const chunk = Buffer.allocUnsafe(1);
chunk.writeUInt8(5 /* ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL */);
(0, assert_1.default)(this.controlSocket);
await this.controlSocket.write(chunk);
}
// TYPE_COLLAPSE_PANELS
async collapsePannels() {
const chunk = Buffer.allocUnsafe(1);
chunk.writeUInt8(6 /* ControlMessage.TYPE_EXPAND_SETTINGS_PANEL */);
(0, assert_1.default)(this.controlSocket);
await this.controlSocket.write(chunk);
}
// TYPE_GET_CLIPBOARD
async getClipboard() {
const chunk = Buffer.allocUnsafe(1);
chunk.writeUInt8(8 /* ControlMessage.TYPE_GET_CLIPBOARD */);
(0, assert_1.default)(this.controlSocket);
await this.controlSocket.write(chunk);
return this.readOneMessage(this.controlSocket);
}
// TYPE_SET_CLIPBOARD
async setClipboard(text) {
const chunk = new BufWrite_1.BufWrite(6);
chunk.writeUint8(9 /* ControlMessage.TYPE_SET_CLIPBOARD */);
chunk.writeUint8(1); // past
chunk.writeString(text);
(0, assert_1.default)(this.controlSocket);
await this.controlSocket.write(chunk.buffer);
}
// TYPE_SET_SCREEN_POWER_MODE
async setScreenPowerMode() {
const chunk = Buffer.allocUnsafe(1);
chunk.writeUInt8(10 /* ControlMessage.TYPE_SET_SCREEN_POWER_MODE */);
(0, assert_1.default)(this.controlSocket);
await this.controlSocket.write(chunk);
}
// TYPE_ROTATE_DEVICE
async rotateDevice() {
const chunk = Buffer.allocUnsafe(1);
chunk.writeUInt8(11 /* ControlMessage.TYPE_ROTATE_DEVICE */);
(0, assert_1.default)(this.controlSocket);
await this.controlSocket.write(chunk);
}
}
exports.default = Scrcpy;
//# sourceMappingURL=Scrcpy.js.map