import net from "node:net";
import { spawn, ChildProcess } from "node:child_process";
import { randomBytes } from "node:crypto";
import { createServer } from "node:net";
import { adbExec, adbShell } from "./adb.js";
import { JpegFrameExtractor } from "./jpegParser.js";
import {
encodeTap,
encodeSwipe,
encodeLongPressStart,
encodeLongPressEnd,
encodeInjectText,
encodeKeyPress,
encodeInjectScrollEvent,
encodeBackOrScreenOn,
encodeSetScreenPowerMode,
AKEY_EVENT_ACTION_DOWN,
AKEY_EVENT_ACTION_UP,
SCREEN_POWER_MODE_OFF,
SCREEN_POWER_MODE_NORMAL,
} from "./scrcpyControl.js";
export type StreamOptions = {
maxSize: number;
maxFps: number;
frameFps: number;
socketPrefix: string;
rawStreamArg: "raw_stream" | "raw_video_stream";
scrcpyServerPath: string;
scrcpyServerVersion: string;
adbPath: string;
ffmpegPath: string;
};
export type StreamFrame = {
jpeg: Buffer;
ts: number; // epoch ms
};
async function getFreeTcpPort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = createServer();
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
srv.close();
if (typeof addr === "object" && addr?.port) resolve(addr.port);
else reject(new Error("Unable to allocate a free TCP port"));
});
srv.on("error", reject);
});
}
function genScid(): string {
// scrcpy "scid" is an int32 in the protocol; here we just use a decimal string.
// Use 31 bits to stay positive.
const b = randomBytes(4);
const n = (b.readUInt32BE(0) & 0x7fffffff) >>> 0;
return String(n);
}
async function sleep(ms: number) {
await new Promise((r) => setTimeout(r, ms));
}
export class ScrcpySession {
public readonly serial: string;
public readonly sessionId: string;
public readonly resourceUri: string;
private opts: StreamOptions;
private localPort: number | null = null;
private scid: string | null = null;
private serverProc: ChildProcess | null = null;
private ffmpegProc: ChildProcess | null = null;
private tcpSocket: net.Socket | null = null;
private controlSocket: net.Socket | null = null;
private extractor = new JpegFrameExtractor();
private _latest: StreamFrame | null = null;
private onFrameCb: ((frame: StreamFrame) => void) | null = null;
// Screen dimensions read from device (needed for control protocol)
private _screenWidth: number = 1080;
private _screenHeight: number = 1920;
private _controlReady: boolean = false;
constructor(serial: string, sessionId: string, opts: StreamOptions) {
this.serial = serial;
this.sessionId = sessionId;
this.opts = opts;
this.resourceUri = `android://device/${encodeURIComponent(serial)}/frame/latest.jpg`;
}
get latest(): StreamFrame | null {
return this._latest;
}
get screenWidth(): number {
return this._screenWidth;
}
get screenHeight(): number {
return this._screenHeight;
}
get controlReady(): boolean {
return this._controlReady;
}
onFrame(cb: (frame: StreamFrame) => void) {
this.onFrameCb = cb;
}
async start(): Promise<void> {
this.scid = genScid();
this.localPort = await getFreeTcpPort();
// 0) Get screen dimensions for control protocol
await this.fetchScreenDimensions();
// 1) Push server to device (idempotent-ish; scrcpy guide uses /data/local/tmp)
const remotePath = "/data/local/tmp/scrcpy-server.jar";
const push = await adbExec(this.opts.adbPath, ["-s", this.serial, "push", this.opts.scrcpyServerPath, remotePath]);
if (push.code !== 0) {
throw new Error(`adb push scrcpy-server failed: ${push.stderr || push.stdout}`);
}
// 2) Forward TCP to abstract socket
const socketName = `${this.opts.socketPrefix}_${this.scid}`;
const fwd = await adbExec(this.opts.adbPath, [
"-s",
this.serial,
"forward",
`tcp:${this.localPort}`,
`localabstract:${socketName}`,
]);
if (fwd.code !== 0) {
throw new Error(`adb forward failed: ${fwd.stderr || fwd.stdout}`);
}
// 3) Start scrcpy server in "standalone" mode (video + control, raw stream)
// Important args:
// - tunnel_forward=true because we use adb forward
// - control=true to enable the control socket for fast input
// - audio=false to avoid audio socket
const serverArgs = [
`CLASSPATH=${remotePath}`,
"app_process",
"/",
"com.genymobile.scrcpy.Server",
this.opts.scrcpyServerVersion,
`scid=${this.scid}`,
"tunnel_forward=true",
"control=true",
"audio=false",
`${this.opts.rawStreamArg}=true`,
`max_size=${this.opts.maxSize}`,
`max_fps=${this.opts.maxFps}`,
"cleanup=true",
];
this.serverProc = spawn(this.opts.adbPath, ["-s", this.serial, "shell", ...serverArgs], {
stdio: ["ignore", "pipe", "pipe"],
});
// If server prints to stderr, keep for debugging (but do not spam MCP client).
this.serverProc.stderr?.on("data", () => { /* ignore */ });
// 4) Connect to the forwarded TCP port and stream to ffmpeg
await this.connectAndDecode();
}
private async connectAndDecode(): Promise<void> {
if (this.localPort == null) throw new Error("Session not initialized");
// With control=true, scrcpy expects TWO connections:
// 1. First connection = video socket
// 2. Second connection = control socket
// retry loop: server may take a moment to boot
let videoSocket: net.Socket | null = null;
let lastErr: unknown = null;
for (let i = 0; i < 20; i++) {
try {
videoSocket = await new Promise<net.Socket>((resolve, reject) => {
const s = net.createConnection({ host: "127.0.0.1", port: this.localPort! }, () => resolve(s));
s.once("error", reject);
});
break;
} catch (e) {
lastErr = e;
await sleep(100);
}
}
if (!videoSocket) throw new Error(`Failed to connect to scrcpy video socket: ${String(lastErr)}`);
this.tcpSocket = videoSocket;
// Connect the control socket (second connection)
await sleep(50); // Small delay to ensure scrcpy is ready for second connection
let controlSocket: net.Socket | null = null;
for (let i = 0; i < 10; i++) {
try {
controlSocket = await new Promise<net.Socket>((resolve, reject) => {
const s = net.createConnection({ host: "127.0.0.1", port: this.localPort! }, () => resolve(s));
s.once("error", reject);
});
break;
} catch (e) {
lastErr = e;
await sleep(100);
}
}
if (controlSocket) {
this.controlSocket = controlSocket;
this._controlReady = true;
controlSocket.on("error", () => {
this._controlReady = false;
});
controlSocket.on("close", () => {
this._controlReady = false;
});
}
const socket = videoSocket;
const ffmpegArgs = [
"-hide_banner",
"-loglevel",
"error",
"-fflags",
"nobuffer",
"-flags",
"low_delay",
"-analyzeduration",
"0",
"-probesize",
"32",
"-f",
"h264",
"-i",
"pipe:0",
"-vf",
`fps=${this.opts.frameFps}`,
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"-q:v",
"5",
"pipe:1",
];
const ff = spawn(this.opts.ffmpegPath, ffmpegArgs, { stdio: ["pipe", "pipe", "pipe"] });
this.ffmpegProc = ff;
socket.pipe(ff.stdin);
ff.stdout.on("data", (chunk: Buffer) => {
for (const jpeg of this.extractor.push(chunk)) {
const frame = { jpeg, ts: Date.now() };
this._latest = frame;
this.onFrameCb?.(frame);
}
});
ff.on("close", () => {
// If ffmpeg dies, stop the session
void this.stop().catch(() => {});
});
socket.on("close", () => {
void this.stop().catch(() => {});
});
socket.on("error", () => {
void this.stop().catch(() => {});
});
}
async stop(): Promise<void> {
this._controlReady = false;
// Best-effort: close sockets first
try {
this.controlSocket?.destroy();
} catch {}
this.controlSocket = null;
try {
this.tcpSocket?.destroy();
} catch {}
this.tcpSocket = null;
try {
this.ffmpegProc?.kill();
} catch {}
this.ffmpegProc = null;
try {
this.serverProc?.kill();
} catch {}
this.serverProc = null;
if (this.localPort != null) {
try {
await adbExec(this.opts.adbPath, ["-s", this.serial, "forward", "--remove", `tcp:${this.localPort}`]);
} catch {}
}
this.localPort = null;
this.scid = null;
}
async health(): Promise<{ ok: boolean; reason?: string }> {
if (!this.serverProc) return { ok: false, reason: "serverProc not running" };
if (!this.ffmpegProc) return { ok: false, reason: "ffmpegProc not running" };
if (!this.tcpSocket) return { ok: false, reason: "tcpSocket not connected" };
return { ok: true };
}
// ============================================================================
// Screen dimension fetching
// ============================================================================
private async fetchScreenDimensions(): Promise<void> {
try {
const res = await adbShell(this.opts.adbPath, this.serial, ["wm", "size"]);
if (res.code === 0) {
// Output format: "Physical size: 1080x1920" or "Override size: 1080x1920"
const match = res.stdout.match(/(\d+)x(\d+)/);
if (match) {
this._screenWidth = parseInt(match[1], 10);
this._screenHeight = parseInt(match[2], 10);
}
}
} catch {
// Use defaults if we can't get screen size
}
}
// ============================================================================
// Fast input methods via scrcpy control protocol
// These are ~10-20x faster than adb shell input commands
// ============================================================================
private sendControl(data: Buffer): boolean {
if (!this.controlSocket || !this._controlReady) {
return false;
}
try {
this.controlSocket.write(data);
return true;
} catch {
this._controlReady = false;
return false;
}
}
/**
* Fast tap at coordinates via scrcpy control protocol.
* Returns true if sent successfully, false if control not available.
*/
fastTap(x: number, y: number): boolean {
const messages = encodeTap(x, y, this._screenWidth, this._screenHeight);
for (const msg of messages) {
if (!this.sendControl(msg)) return false;
}
return true;
}
/**
* Fast swipe via scrcpy control protocol.
* @param steps Number of intermediate move events (higher = smoother, default 20)
* @param delayMs Optional delay between steps in milliseconds (default 0)
*/
async fastSwipe(
x1: number,
y1: number,
x2: number,
y2: number,
steps: number = 20,
delayMs: number = 0
): Promise<boolean> {
const messages = encodeSwipe(x1, y1, x2, y2, this._screenWidth, this._screenHeight, steps);
for (const msg of messages) {
if (!this.sendControl(msg)) return false;
if (delayMs > 0) {
await sleep(delayMs);
}
}
return true;
}
/**
* Fast long press via scrcpy control protocol.
* @param durationMs Duration to hold in milliseconds
*/
async fastLongPress(x: number, y: number, durationMs: number = 1000): Promise<boolean> {
const down = encodeLongPressStart(x, y, this._screenWidth, this._screenHeight);
if (!this.sendControl(down)) return false;
await sleep(durationMs);
const up = encodeLongPressEnd(x, y, this._screenWidth, this._screenHeight);
return this.sendControl(up);
}
/**
* Fast text input via scrcpy control protocol.
* Much faster for multi-character strings than individual key events.
*/
fastText(text: string): boolean {
const msg = encodeInjectText(text);
return this.sendControl(msg);
}
/**
* Fast key event via scrcpy control protocol.
* @param keycode Android keycode (e.g., 3 for HOME, 4 for BACK)
*/
fastKey(keycode: number): boolean {
const messages = encodeKeyPress(keycode);
for (const msg of messages) {
if (!this.sendControl(msg)) return false;
}
return true;
}
/**
* Fast scroll event via scrcpy control protocol.
* @param hscroll Horizontal scroll amount (negative = left, positive = right)
* @param vscroll Vertical scroll amount (negative = up, positive = down)
*/
fastScroll(x: number, y: number, hscroll: number, vscroll: number): boolean {
const msg = encodeInjectScrollEvent(x, y, this._screenWidth, this._screenHeight, hscroll, vscroll);
return this.sendControl(msg);
}
/**
* Fast back button via scrcpy control protocol.
* Also wakes the screen if it's off.
*/
fastBack(): boolean {
const down = encodeBackOrScreenOn(AKEY_EVENT_ACTION_DOWN);
const up = encodeBackOrScreenOn(AKEY_EVENT_ACTION_UP);
return this.sendControl(down) && this.sendControl(up);
}
/**
* Set screen power mode via scrcpy control protocol.
* @param on true to turn on, false to turn off
*/
fastScreenPower(on: boolean): boolean {
const msg = encodeSetScreenPowerMode(on ? SCREEN_POWER_MODE_NORMAL : SCREEN_POWER_MODE_OFF);
return this.sendControl(msg);
}
}