UNPKG

@u4/adbkit

Version:

A Typescript client for the Android Debug Bridge.

589 lines 25.9 kB
"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