feat: migrate scrcpy to streams

This commit is contained in:
Simon Chan 2022-02-16 23:43:41 +08:00
parent 8b78c9c331
commit b7725567a6
21 changed files with 308 additions and 460 deletions

View file

@ -13074,13 +13074,20 @@ packages:
dev: false
file:projects/adb-backend-ws.tgz:
resolution: {integrity: sha512-FCm/eOTkUVJ+hsthYXzX2GrLsrapfStzJ/aXIjv/gRGG1sE9bxL1YOGyJl28K7PldM0oiLXW8zedvB7Ry57w9g==, tarball: file:projects/adb-backend-ws.tgz}
resolution: {integrity: sha512-68/Hp311ik7D+hvQyWS3p3SJah0w7ypo1ZFcPMtX+6W443UF2J+0qr6BHMwD/3kXvIvUI3nIeBvfuQFFpXwP0w==, tarball: file:projects/adb-backend-ws.tgz}
name: '@rush-temp/adb-backend-ws'
version: 0.0.0
dependencies:
'@yume-chan/async': 2.1.4
jest: 26.6.3
tslib: 2.3.1
typescript: 4.5.5
transitivePeerDependencies:
- bufferutil
- canvas
- supports-color
- ts-node
- utf-8-validate
dev: false
file:projects/adb-credential-web.tgz:
@ -13094,7 +13101,7 @@ packages:
dev: false
file:projects/adb.tgz:
resolution: {integrity: sha512-g2UseeMAhatJMFHZFEzJLFPricrQdJeHKXH5KE9XBDIoNla+2aB5UlZRLC+a/WKIa+WBQBD6bWT/3Ydwm5iexA==, tarball: file:projects/adb.tgz}
resolution: {integrity: sha512-ERJiC9hMtOFRZhGBqQ76HXFVsjsGHajyWrPoHqOrAW4LqTdHf8jJueqhzPt6e9asmTuxcOynqz4MzbYnahcTtA==, tarball: file:projects/adb.tgz}
name: '@rush-temp/adb'
version: 0.0.0
dependencies:

View file

@ -1,5 +1,4 @@
import { AdbBackend, ReadableStream, TransformStream, WritableStream } from '@yume-chan/adb';
import { EventEmitter } from '@yume-chan/event';
import { AdbBackend, ReadableStream, ReadableWritablePair, TransformStream, WritableStream } from '@yume-chan/adb';
declare global {
interface TCPSocket {
@ -30,20 +29,8 @@ declare global {
}
}
export default class AdbDirectSocketsBackend implements AdbBackend {
public static isSupported(): boolean {
return typeof window !== 'undefined' && !!window.navigator?.openTCPSocket;
}
public readonly serial: string;
public readonly address: string;
public readonly port: number;
public name: string | undefined;
private socket: TCPSocket | undefined;
export class AdbDirectSocketsBackendStreams implements ReadableWritablePair<ArrayBuffer, ArrayBuffer>{
private socket: TCPSocket;
private _readableTransformStream = new TransformStream<Uint8Array, ArrayBuffer>({
transform(chunk, controller) {
@ -56,44 +43,43 @@ export default class AdbDirectSocketsBackend implements AdbBackend {
return this._readableTransformStream.readable;
}
public get writable(): WritableStream<ArrayBuffer> | undefined {
return this.socket?.writable;
public get writable(): WritableStream<ArrayBuffer> {
return this.socket.writable;
}
private _connected = false;
public get connected() { return this._connected; }
constructor(socket: TCPSocket) {
this.socket = socket;
this.socket.readable.pipeTo(this._readableTransformStream.writable);
}
}
private readonly disconnectEvent = new EventEmitter<void>();
public readonly onDisconnected = this.disconnectEvent.event;
export default class AdbDirectSocketsBackend implements AdbBackend {
public static isSupported(): boolean {
return typeof window !== 'undefined' && !!window.navigator?.openTCPSocket;
}
public constructor(address: string, port: number = 5555, name?: string) {
this.address = address;
public readonly serial: string;
public readonly host: string;
public readonly port: number;
public name: string | undefined;
public constructor(host: string, port: number = 5555, name?: string) {
this.host = host;
this.port = port;
this.serial = `${address}:${port}`;
this.serial = `${host}:${port}`;
this.name = name;
}
public async connect() {
const socket = await navigator.openTCPSocket({
remoteAddress: this.address,
remoteAddress: this.host,
remotePort: this.port,
noDelay: true,
});
this.socket = socket;
this.socket.readable
.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
flush: () => {
this.disconnectEvent.fire();
},
}))
.pipeTo(this._readableTransformStream.writable);
this._connected = true;
}
public dispose(): void | Promise<void> {
this.socket?.close();
this._connected = false;
return new AdbDirectSocketsBackendStreams(socket);
}
}

View file

@ -1,5 +1,4 @@
import { AdbBackend, ReadableStream } from '@yume-chan/adb';
import { EventEmitter } from '@yume-chan/event';
import { AdbBackend, ReadableStream, ReadableWritablePair } from '@yume-chan/adb';
export const WebUsbDeviceFilter: USBDeviceFilter = {
classCode: 0xFF,
@ -7,6 +6,49 @@ export const WebUsbDeviceFilter: USBDeviceFilter = {
protocolCode: 1,
};
export class AdbWebUsbBackendStream implements ReadableWritablePair<ArrayBuffer, ArrayBuffer>{
private _readable: ReadableStream<ArrayBuffer>;
public get readable() { return this._readable; }
private _writable: WritableStream<ArrayBuffer>;
public get writable() { return this._writable; }
public constructor(device: USBDevice, inEndpoint: USBEndpoint, outEndpoint: USBEndpoint) {
this._readable = new ReadableStream({
pull: async (controller) => {
let result = await device.transferIn(inEndpoint.endpointNumber, inEndpoint.packetSize);
if (result.status === 'stall') {
// https://android.googlesource.com/platform/packages/modules/adb/+/79010dc6d5ca7490c493df800d4421730f5466ca/client/usb_osx.cpp#543
await device.clearHalt('in', inEndpoint.endpointNumber);
result = await device.transferIn(inEndpoint.endpointNumber, inEndpoint.packetSize);
}
const { buffer } = result.data!;
controller.enqueue(buffer);
},
cancel: async () => {
await device.close();
},
}, {
highWaterMark: 16 * 1024,
size(chunk) { return chunk.byteLength; },
});
this._writable = new WritableStream({
write: async (chunk) => {
await device.transferOut(outEndpoint.endpointNumber, chunk);
},
close: async () => {
await device.close();
},
}, {
highWaterMark: 16 * 1024,
size(chunk) { return chunk.byteLength; },
});
}
}
export class AdbWebUsbBackend implements AdbBackend {
public static isSupported(): boolean {
return !!window.navigator?.usb;
@ -37,31 +79,11 @@ export class AdbWebUsbBackend implements AdbBackend {
public get name(): string { return this._device.productName!; }
private _connected = false;
public get connected() { return this._connected; }
private readonly disconnectEvent = new EventEmitter<void>();
public readonly onDisconnected = this.disconnectEvent.event;
private _readable: ReadableStream<ArrayBuffer> | undefined;
public get readable() { return this._readable; }
private _writable: WritableStream<ArrayBuffer> | undefined;
public get writable() { return this._writable; }
public constructor(device: USBDevice) {
this._device = device;
window.navigator.usb.addEventListener('disconnect', this.handleDisconnect);
}
private handleDisconnect = (e: USBConnectionEvent) => {
if (e.device === this._device) {
this._connected = false;
this.disconnectEvent.fire();
}
};
public async connect(): Promise<void> {
public async connect() {
if (!this._device.opened) {
await this._device.open();
}
@ -84,49 +106,21 @@ export class AdbWebUsbBackend implements AdbBackend {
await this._device.selectAlternateInterface(interface_.interfaceNumber, alternate.alternateSetting);
}
let inEndpoint: USBEndpoint | undefined;
let outEndpoint: USBEndpoint | undefined;
for (const endpoint of alternate.endpoints) {
switch (endpoint.direction) {
case 'in':
this._readable = new ReadableStream({
pull: async (controller) => {
let result = await this._device.transferIn(endpoint.endpointNumber, endpoint.packetSize);
if (result.status === 'stall') {
// https://android.googlesource.com/platform/packages/modules/adb/+/79010dc6d5ca7490c493df800d4421730f5466ca/client/usb_osx.cpp#543
await this._device.clearHalt('in', endpoint.endpointNumber);
result = await this._device.transferIn(endpoint.endpointNumber, endpoint.packetSize);
}
const { buffer } = result.data!;
controller.enqueue(buffer);
},
cancel: () => {
this.dispose();
},
}, {
highWaterMark: 16 * 1024,
size(chunk) { return chunk.byteLength; },
});
if (this._writable !== undefined) {
this._connected = true;
return;
inEndpoint = endpoint;
if (outEndpoint) {
return new AdbWebUsbBackendStream(this._device, inEndpoint, outEndpoint);
}
break;
case 'out':
this._writable = new WritableStream({
write: async (chunk) => {
await this._device.transferOut(endpoint.endpointNumber, chunk);
},
close: () => {
this.dispose();
},
}, {
highWaterMark: 16 * 1024,
size(chunk) { return chunk.byteLength; },
});
if (this.readable !== undefined) {
this._connected = true;
return;
outEndpoint = endpoint;
if (inEndpoint) {
return new AdbWebUsbBackendStream(this._device, inEndpoint, outEndpoint);
}
break;
}
@ -138,11 +132,4 @@ export class AdbWebUsbBackend implements AdbBackend {
throw new Error('Unknown error');
}
public async dispose() {
this._connected = false;
window.navigator.usb.removeEventListener('disconnect', this.handleDisconnect);
this.disconnectEvent.dispose();
await this._device.close();
}
}

View file

@ -1,7 +1,6 @@
{
"extends": "./node_modules/@yume-chan/ts-package-builder/tsconfig.base.json",
"compilerOptions": {
"target": "ES2016",
"lib": [
"ESNext",
"DOM"

View file

@ -30,6 +30,7 @@
"build:watch": "build-ts-package --incremental"
},
"devDependencies": {
"jest": "^26.6.3",
"typescript": "^4.5.5",
"@yume-chan/ts-package-builder": "^1.0.0"
},

View file

@ -1,37 +1,11 @@
import { AdbBackend, ReadableStream, WritableStream } from '@yume-chan/adb';
import { PromiseResolver } from '@yume-chan/async';
import { EventEmitter } from '@yume-chan/event';
const Utf8Encoder = new TextEncoder();
const Utf8Decoder = new TextDecoder();
export function encodeUtf8(input: string): ArrayBuffer {
return Utf8Encoder.encode(input).buffer;
}
export function decodeUtf8(buffer: ArrayBuffer): string {
return Utf8Decoder.decode(buffer);
}
export default class AdbWsBackend implements AdbBackend {
public readonly serial: string;
public name: string | undefined;
private socket: WebSocket | undefined;
private _readable: ReadableStream<ArrayBuffer> | undefined;
public get readable() { return this._readable; }
private _writable: WritableStream<ArrayBuffer> | undefined;
public get writable() { return this._writable; }
private _connected = false;
public get connected() { return this._connected; }
private readonly disconnectEvent = new EventEmitter<void>();
public readonly onDisconnected = this.disconnectEvent.event;
public constructor(url: string, name?: string) {
this.serial = url;
this.name = name;
@ -48,22 +22,21 @@ export default class AdbWsBackend implements AdbBackend {
};
await resolver.promise;
this._readable = new ReadableStream({
const readable = new ReadableStream({
start: (controller) => {
socket.onmessage = ({ data }: { data: ArrayBuffer; }) => {
controller.enqueue(data);
};
socket.onclose = () => {
controller.close();
this._connected = false;
this.disconnectEvent.fire();
};
}
}, {
highWaterMark: 16 * 1024,
size(chunk) { return chunk.byteLength; },
});
this._writable = new WritableStream({
const writable = new WritableStream({
write: (chunk) => {
socket.send(chunk);
},
@ -72,23 +45,6 @@ export default class AdbWsBackend implements AdbBackend {
size(chunk) { return chunk.byteLength; },
});
this.socket = socket;
this._connected = true;
}
public encodeUtf8(input: string): ArrayBuffer {
return encodeUtf8(input);
}
public decodeUtf8(buffer: ArrayBuffer): string {
return decodeUtf8(buffer);
}
public write(buffer: ArrayBuffer): void | Promise<void> {
this.socket?.send(buffer);
}
public dispose(): void | Promise<void> {
this.socket?.close();
return { readable, writable };
}
}

View file

@ -2,11 +2,11 @@ import { PromiseResolver } from '@yume-chan/async';
import { DisposableList } from '@yume-chan/event';
import { AdbAuthenticationHandler, AdbCredentialStore, AdbDefaultAuthenticators } from './auth';
import { AdbBackend } from './backend';
import { AdbSubprocess, AdbFrameBuffer, AdbPower, AdbReverseCommand, AdbSync, AdbTcpIpCommand, escapeArg, framebuffer, install } from './commands';
import { AdbFrameBuffer, AdbPower, AdbReverseCommand, AdbSubprocess, AdbSync, AdbTcpIpCommand, escapeArg, framebuffer, install } from './commands';
import { AdbFeatures } from './features';
import { AdbCommand } from './packet';
import { AdbLogger, AdbPacketDispatcher, AdbSocket } from './socket';
import { decodeUtf8, ReadableStream } from "./utils";
import { decodeUtf8, ReadableStream, WritableStream } from "./utils";
export enum AdbPropKey {
Product = 'ro.product.name',
@ -16,17 +16,17 @@ export enum AdbPropKey {
}
export class Adb {
public static async connect(backend: AdbBackend, logger?: AdbLogger) {
const { readable, writable } = await backend.connect();
return new Adb(backend, readable, writable, logger);
}
private readonly _backend: AdbBackend;
public get backend(): AdbBackend { return this._backend; }
private readonly packetDispatcher: AdbPacketDispatcher;
public get onDisconnected() { return this.backend.onDisconnected; }
private _connected = false;
public get connected() { return this._connected; }
public get name() { return this.backend.name; }
private _protocolVersion: number | undefined;
@ -49,28 +49,28 @@ export class Adb {
public readonly reverse: AdbReverseCommand;
public readonly tcpip: AdbTcpIpCommand;
public constructor(backend: AdbBackend, logger?: AdbLogger) {
public constructor(
backend: AdbBackend,
readable: ReadableStream<ArrayBuffer>,
writable: WritableStream<ArrayBuffer>,
logger?: AdbLogger
) {
this._backend = backend;
this.packetDispatcher = new AdbPacketDispatcher(backend, logger);
this.packetDispatcher = new AdbPacketDispatcher(readable, writable, logger);
this.subprocess = new AdbSubprocess(this);
this.power = new AdbPower(this);
this.reverse = new AdbReverseCommand(this.packetDispatcher);
this.tcpip = new AdbTcpIpCommand(this);
backend.onDisconnected(this.dispose, this);
}
public async connect(
public async authenticate(
credentialStore: AdbCredentialStore,
authenticators = AdbDefaultAuthenticators
): Promise<void> {
await this.backend.connect?.();
this.packetDispatcher.maxPayloadSize = 0x1000;
// TODO: Adb: properly set `calculateChecksum`
// this.packetDispatcher.calculateChecksum = true;
this.packetDispatcher.calculateChecksum = true;
this.packetDispatcher.appendNullToServiceString = true;
this.packetDispatcher.start();
const version = 0x01000001;
const versionNoChecksum = 0x01000001;
@ -140,20 +140,17 @@ export class Adb {
resolver.reject(e);
}));
// Android prior 9.0.0 requires the null character
// Newer versions can also handle the null character
// The terminating `;` is required in formal definition
// But ADB daemon can also work without it
await this.packetDispatcher.sendPacket(
AdbCommand.Connect,
version,
maxPayloadSize,
`host::features=${features};\0`
// The terminating `;` is required in formal definition
// But ADB daemon can also work without it
`host::features=${features};`
);
try {
await resolver.promise;
this._connected = true;
} finally {
disposableList.dispose();
}
@ -236,6 +233,5 @@ export class Adb {
public async dispose(): Promise<void> {
this.packetDispatcher.dispose();
await this.backend.dispose();
}
}

View file

@ -1,21 +1,10 @@
import type { Event } from '@yume-chan/event';
import type { ValueOrPromise } from '@yume-chan/struct';
import type { ReadableStream, WritableStream } from "./utils";
import type { ReadableWritablePair } from "./utils";
export interface AdbBackend {
readonly serial: string;
readonly name: string | undefined;
readonly connected: boolean;
readonly onDisconnected: Event<void>;
readonly readable: ReadableStream<ArrayBuffer> | undefined;
readonly writable: WritableStream<ArrayBuffer> | undefined;
connect?(): ValueOrPromise<void>;
dispose(): ValueOrPromise<void>;
connect(): ValueOrPromise<ReadableWritablePair<ArrayBuffer, ArrayBuffer>>;
}

View file

@ -1,14 +1,10 @@
import { AutoDisposable } from '@yume-chan/event';
import { AdbBackend } from '../backend';
import { AdbCommand } from '../packet';
import { AutoResetEvent, chunkArrayLike, TransformStream, WritableStream, WritableStreamDefaultWriter } from '../utils';
import { AdbPacketDispatcher } from './dispatcher';
export interface AdbSocketInfo {
backend: AdbBackend;
localId: number;
remoteId: number;
localCreated: boolean;
@ -35,7 +31,6 @@ export class AdbSocketController extends AutoDisposable implements AdbSocketInfo
private readonly writeLock = this.addDisposable(new AutoResetEvent());
private readonly dispatcher!: AdbPacketDispatcher;
public get backend() { return this.dispatcher.backend; }
public readonly localId!: number;
public readonly remoteId!: number;

View file

@ -1,8 +1,7 @@
import { AsyncOperationManager } from '@yume-chan/async';
import { AutoDisposable, EventEmitter } from '@yume-chan/event';
import { AdbBackend } from '../backend';
import { AdbCommand, AdbPacket, AdbPacketInit, AdbPacketSerializeStream } from '../packet';
import { AbortController, decodeUtf8, encodeUtf8, StructDeserializeStream, WritableStream, WritableStreamDefaultWriter } from '../utils';
import { AbortController, decodeUtf8, encodeUtf8, ReadableStream, StructDeserializeStream, TransformStream, WritableStream, WritableStreamDefaultWriter } from '../utils';
import { AdbSocketController } from './controller';
import { AdbLogger } from './logger';
import { AdbSocket } from './socket';
@ -32,7 +31,6 @@ export class AdbPacketDispatcher extends AutoDisposable {
private readonly sockets = new Map<number, AdbSocketController>();
private readonly logger: AdbLogger | undefined;
public readonly backend: AdbBackend;
private _packetSerializeStream!: AdbPacketSerializeStream;
private _packetSerializeStreamWriter!: WritableStreamDefaultWriter<AdbPacketInit>;
@ -50,15 +48,62 @@ export class AdbPacketDispatcher extends AutoDisposable {
private readonly errorEvent = this.addDisposable(new EventEmitter<Error>());
public get onError() { return this.errorEvent.event; }
private _running = false;
public get running() { return this._running; }
private _runningAbortController!: AbortController;
private _abortController = new AbortController();
public constructor(backend: AdbBackend, logger?: AdbLogger) {
public constructor(readable: ReadableStream<ArrayBuffer>, writable: WritableStream<ArrayBuffer>, logger?: AdbLogger) {
super();
this.backend = backend;
this.logger = logger;
readable
.pipeThrough(new TransformStream(), { signal: this._abortController.signal })
.pipeThrough(new StructDeserializeStream(AdbPacket))
.pipeTo(new WritableStream({
write: async (packet) => {
try {
this.logger?.onIncomingPacket?.(packet);
switch (packet.command) {
case AdbCommand.OK:
this.handleOk(packet);
return;
case AdbCommand.Close:
await this.handleClose(packet);
return;
case AdbCommand.Write:
if (this.sockets.has(packet.arg1)) {
await this.sockets.get(packet.arg1)!.enqueue(packet.payload!);
await this.sendPacket(AdbCommand.OK, packet.arg1, packet.arg0);
}
// Maybe the device is responding to a packet of last connection
// Just ignore it
return;
case AdbCommand.Open:
await this.handleOpen(packet);
return;
}
const args: AdbPacketReceivedEventArgs = {
handled: false,
packet,
};
this.packetEvent.fire(args);
if (!args.handled) {
this.dispose();
throw new Error(`Unhandled packet with command '${packet.command}'`);
}
} catch (e) {
readable.cancel(e);
writable.abort(e);
this.errorEvent.fire(e as Error);
}
}
}));
this._packetSerializeStream = new AdbPacketSerializeStream();
this._packetSerializeStream.readable.pipeTo(writable, { signal: this._abortController.signal });
this._packetSerializeStreamWriter = this._packetSerializeStream.writable.getWriter();
}
private handleOk(packet: AdbPacket) {
@ -143,70 +188,6 @@ export class AdbPacketDispatcher extends AutoDisposable {
}
}
public start() {
this._running = true;
this._runningAbortController = new AbortController();
this.backend.readable!
.pipeThrough(
new StructDeserializeStream(AdbPacket),
{
preventAbort: true,
preventCancel: true,
signal: this._runningAbortController.signal,
}
)
.pipeTo(new WritableStream({
write: async (packet) => {
try {
this.logger?.onIncomingPacket?.(packet);
switch (packet.command) {
case AdbCommand.OK:
this.handleOk(packet);
return;
case AdbCommand.Close:
await this.handleClose(packet);
return;
case AdbCommand.Write:
if (this.sockets.has(packet.arg1)) {
await this.sockets.get(packet.arg1)!.enqueue(packet.payload!);
await this.sendPacket(AdbCommand.OK, packet.arg1, packet.arg0);
}
// Maybe the device is responding to a packet of last connection
// Just ignore it
return;
case AdbCommand.Open:
await this.handleOpen(packet);
return;
}
const args: AdbPacketReceivedEventArgs = {
handled: false,
packet,
};
this.packetEvent.fire(args);
if (!args.handled) {
this.dispose();
throw new Error(`Unhandled packet with command '${packet.command}'`);
}
} catch (e) {
if (!this._running) {
// ignore error when not running
return;
}
this.errorEvent.fire(e as Error);
}
}
}));
this._packetSerializeStream = new AdbPacketSerializeStream();
this._packetSerializeStream.readable.pipeTo(this.backend.writable!);
this._packetSerializeStreamWriter = this._packetSerializeStream.writable.getWriter();
}
public async createSocket(serviceString: string): Promise<AdbSocket> {
if (this.appendNullToServiceString) {
serviceString += '\0';
@ -263,14 +244,12 @@ export class AdbPacketDispatcher extends AutoDisposable {
}
public override dispose() {
this._running = false;
this._runningAbortController.abort();
for (const socket of this.sockets.values()) {
socket.dispose();
}
this.sockets.clear();
this._abortController.abort();
super.dispose();
}
}

View file

@ -4,7 +4,6 @@ import { AdbSocketController, AdbSocketInfo } from './controller';
export class AdbSocket implements AdbSocketInfo {
private readonly controller: AdbSocketController;
public get backend() { return this.controller.backend; }
public get localId() { return this.controller.localId; }
public get remoteId() { return this.controller.remoteId; }
public get localCreated() { return this.controller.localCreated; }

View file

@ -104,7 +104,6 @@ export class AdbBufferedStream
implements AdbSocketInfo, StructAsyncDeserializeStream {
protected readonly socket: AdbSocket;
public get backend() { return this.socket.backend; }
public get localId() { return this.socket.localId; }
public get remoteId() { return this.socket.remoteId; }
public get localCreated() { return this.socket.localCreated; }

View file

@ -1,11 +1,9 @@
import { Adb, AdbBufferedStream, AdbNoneSubprocessProtocol, AdbSubprocessProtocol, ReadableStream, TransformStream } from '@yume-chan/adb';
import { Adb, AdbBufferedStream, AdbNoneSubprocessProtocol, AdbSocket, AdbSubprocessProtocol, DecodeUtf8Stream, ReadableStream, TransformStream, WritableStreamDefaultWriter } from '@yume-chan/adb';
import { EventEmitter } from '@yume-chan/event';
import Struct from '@yume-chan/struct';
import type { H264EncodingInfo } from "./decoder";
import { AndroidMotionEventAction, ScrcpyControlMessageType, ScrcpyInjectKeyCodeControlMessage, ScrcpyInjectTextControlMessage, ScrcpyInjectTouchControlMessage, type AndroidKeyEventAction } from './message';
import type { ScrcpyInjectScrollControlMessage1_22, ScrcpyOptions } from "./options";
import type { ScrcpyInjectScrollControlMessage1_22, ScrcpyOptions, VideoStreamPacket } from "./options";
import { pushServer, PushServerOptions } from "./push-server";
import { decodeUtf8 } from "./utils";
function* splitLines(text: string): Generator<string, void, void> {
let start = 0;
@ -49,10 +47,9 @@ export class ScrcpyClient {
// Disable control for faster connection in 1.22+
options.value.control = false;
const client = new ScrcpyClient(device);
// Scrcpy server will open connections, before initializing encoder
// Thus although an invalid encoder name is given, the start process will success
await client.start(path, version, options);
const client = await ScrcpyClient.start(device, path, version, options);
const encoderNameRegex = options.getOutputEncoderNameRegex();
const encoders: string[] = [];
@ -68,70 +65,19 @@ export class ScrcpyClient {
return encoders;
}
private readonly device: Adb;
public get backend() { return this.device.backend; }
private process: AdbSubprocessProtocol | undefined;
private controlStream: AdbBufferedStream | undefined;
private _stdout: TransformStream<ArrayBuffer, string>;
public get stdout() { return this._stdout.readable; }
public get exit() { return this.process?.exit; }
private _running = false;
public get running() { return this._running; }
private _screenWidth: number | undefined;
public get screenWidth() { return this._screenWidth; }
private _screenHeight: number | undefined;
public get screenHeight() { return this._screenHeight; }
private readonly encodingChangedEvent = new EventEmitter<H264EncodingInfo>();
public get onEncodingChanged() { return this.encodingChangedEvent.event; }
private _videoStream: ReadableStream<ArrayBuffer> | undefined;
public get onVideoData() { return this._videoStream; }
private readonly clipboardChangeEvent = new EventEmitter<string>();
public get onClipboardChange() { return this.clipboardChangeEvent.event; }
private options: ScrcpyOptions<any> | undefined;
private sendingTouchMessage = false;
public constructor(device: Adb) {
this.device = device;
this._stdout = new TransformStream<ArrayBuffer, string>({
transform(chunk, controller) {
const text = decodeUtf8(chunk);
for (const line of splitLines(text)) {
if (line === '') {
continue;
}
controller.enqueue(line);
}
},
});
}
public async start(
public static async start(
device: Adb,
path: string,
version: string,
options: ScrcpyOptions<any>
) {
this.options = options;
const connection = options.createConnection(this.device);
const connection = options.createConnection(device);
let process: AdbSubprocessProtocol | undefined;
try {
await connection.initialize();
process = await this.device.subprocess.spawn(
process = await device.subprocess.spawn(
[
// cspell: disable-next-line
`CLASSPATH=${path}`,
@ -148,8 +94,6 @@ export class ScrcpyClient {
}
);
process.stdout.pipeThrough(this._stdout);
const result = await Promise.race([
process.exit,
connection.getStreams(),
@ -160,15 +104,7 @@ export class ScrcpyClient {
}
const [videoStream, controlStream] = result;
this.process = process;
this.process.exit.then(() => this.handleProcessClosed());
this.videoStream = videoStream;
this.controlStream = controlStream;
this._running = true;
this.receiveVideo();
this.receiveControl();
return new ScrcpyClient(device, options, process, videoStream, controlStream);
} catch (e) {
await process?.kill();
throw e;
@ -177,69 +113,92 @@ export class ScrcpyClient {
}
}
private handleProcessClosed() {
this._running = false;
}
private options: ScrcpyOptions<any>;
private process: AdbSubprocessProtocol;
private async receiveVideo() {
if (!this.videoStream) {
throw new Error('receiveVideo started before initialization');
}
private _stdout: TransformStream<string, string>;
public get stdout() { return this._stdout.readable; }
try {
while (this._running) {
const { encodingInfo, videoData } = await this.options!.parseVideoStream(this.videoStream);
if (encodingInfo) {
this._screenWidth = encodingInfo.croppedWidth;
this._screenHeight = encodingInfo.croppedHeight;
this.encodingChangedEvent.fire(encodingInfo);
}
if (videoData) {
this.videoDataEvent.fire(videoData);
}
}
} catch (e) {
if (!this._running) {
return;
}
}
}
public get exit() { return this.process.exit; }
private async receiveControl() {
if (!this.controlStream) {
// control disabled
return;
}
private _screenWidth: number | undefined;
public get screenWidth() { return this._screenWidth; }
try {
private _screenHeight: number | undefined;
public get screenHeight() { return this._screenHeight; }
private _videoStream: TransformStream<VideoStreamPacket, VideoStreamPacket>;
public get videoStream() { return this._videoStream; }
private _controlStreamWriter: WritableStreamDefaultWriter<ArrayBuffer> | undefined;
private readonly clipboardChangeEvent = new EventEmitter<string>();
public get onClipboardChange() { return this.clipboardChangeEvent.event; }
private sendingTouchMessage = false;
public constructor(
device: Adb,
options: ScrcpyOptions<any>,
process: AdbSubprocessProtocol,
videoStream: AdbBufferedStream,
controlStream: AdbSocket | undefined,
) {
this.options = options;
this.process = process;
this._stdout = new TransformStream({
transform(chunk, controller) {
for (const line of splitLines(chunk)) {
if (line === '') {
continue;
}
controller.enqueue(line);
}
},
});
process.stdout
.pipeThrough(new DecodeUtf8Stream())
.pipeThrough(this._stdout);
this._videoStream = new TransformStream();
const videoStreamWriter = this._videoStream.writable.getWriter();
(async () => {
while (true) {
const type = await this.controlStream.read(1);
const packet = await options.parseVideoStream(videoStream);
if (packet.type === 'configuration') {
this._screenWidth = packet.data.croppedWidth;
this._screenHeight = packet.data.croppedHeight;
}
videoStreamWriter.write(packet);
}
})();
if (controlStream) {
const buffered = new AdbBufferedStream(controlStream);
this._controlStreamWriter = controlStream.writable.getWriter();
(async () => {
while (true) {
const type = await buffered.read(1);
switch (new Uint8Array(type)[0]) {
case 0:
const { content } = await ClipboardMessage.deserialize(this.controlStream);
const { content } = await ClipboardMessage.deserialize(buffered);
this.clipboardChangeEvent.fire(content!);
break;
default:
throw new Error('unknown control message type');
}
}
} catch (e) {
if (!this._running) {
return;
}
})();
}
}
private checkControlStream(caller: string) {
if (!this._running) {
throw new Error(`${caller} called before start`);
}
if (!this.controlStream) {
if (!this._controlStreamWriter) {
throw new Error(`${caller} called with control disabled`);
}
return this.controlStream;
return this._controlStreamWriter;
}
public async injectKeyCode(message: Omit<ScrcpyInjectKeyCodeControlMessage, 'type'>) {
@ -314,18 +273,7 @@ export class ScrcpyClient {
}
public async close() {
if (!this._running) {
return;
}
this._running = false;
this.videoStream?.close();
this.videoStream = undefined;
this.controlStream?.close();
this.controlStream = undefined;
this._controlStreamWriter?.close();
await this.process?.kill();
}
}

View file

@ -29,17 +29,17 @@ export abstract class ScrcpyClientConnection implements Disposable {
public initialize(): ValueOrPromise<void> { }
public abstract getStreams(): ValueOrPromise<[videoSteam: AdbBufferedStream, controlStream: AdbBufferedStream | undefined]>;
public abstract getStreams(): ValueOrPromise<[videoSteam: AdbBufferedStream, controlStream: AdbSocket | undefined]>;
public dispose(): void { }
}
export class ScrcpyClientForwardConnection extends ScrcpyClientConnection {
private async connect(): Promise<AdbBufferedStream> {
return new AdbBufferedStream(await this.device.createSocket('localabstract:scrcpy'));
private async connect(): Promise<AdbSocket> {
return await this.device.createSocket('localabstract:scrcpy');
}
private async connectAndRetry(): Promise<AdbBufferedStream> {
private async connectAndRetry(): Promise<AdbSocket> {
for (let i = 0; i < 100; i++) {
try {
return await this.connect();
@ -51,7 +51,8 @@ export class ScrcpyClientForwardConnection extends ScrcpyClientConnection {
}
private async connectVideoStream(): Promise<AdbBufferedStream> {
const stream = await this.connectAndRetry();
const socket = await this.connectAndRetry();
const stream = new AdbBufferedStream(socket);
if (this.options.sendDummyByte) {
// server will write a `0` to signal connection success
await stream.read(1);
@ -59,9 +60,9 @@ export class ScrcpyClientForwardConnection extends ScrcpyClientConnection {
return stream;
}
public async getStreams(): Promise<[videoSteam: AdbBufferedStream, controlStream: AdbBufferedStream | undefined]> {
public async getStreams(): Promise<[videoSteam: AdbBufferedStream, controlStream: AdbSocket | undefined]> {
const videoStream = await this.connectVideoStream();
let controlStream: AdbBufferedStream | undefined;
let controlStream: AdbSocket | undefined;
if (this.options.control) {
controlStream = await this.connectAndRetry();
}
@ -96,13 +97,13 @@ export class ScrcpyClientReverseConnection extends ScrcpyClientConnection {
});
}
private async accept(): Promise<AdbBufferedStream> {
return new AdbBufferedStream((await this.streams.read()).value!);
private async accept(): Promise<AdbSocket> {
return (await this.streams.read()).value!;
}
public async getStreams(): Promise<[videoSteam: AdbBufferedStream, controlStream: AdbBufferedStream | undefined]> {
const videoStream = await this.accept();
let controlStream: AdbBufferedStream | undefined;
public async getStreams(): Promise<[videoSteam: AdbBufferedStream, controlStream: AdbSocket | undefined]> {
const videoStream = new AdbBufferedStream(await this.accept());
let controlStream: AdbSocket | undefined;
if (this.options.control) {
controlStream = await this.accept();
}

View file

@ -1,8 +1,8 @@
import type { Disposable } from "@yume-chan/event";
import type { WritableStream } from '@yume-chan/adb';
import type { Disposable } from "@yume-chan/event";
import type { AndroidCodecLevel, AndroidCodecProfile } from "../codec";
export interface H264EncodingInfo {
export interface H264Configuration {
profileIndex: number;
constraintSet: number;
levelIndex: number;
@ -29,7 +29,7 @@ export interface H264Decoder extends Disposable {
readonly writable: WritableStream<ArrayBuffer>;
changeEncoding(size: H264EncodingInfo): void;
configure(config: H264Configuration): void;
}
export interface H264DecoderConstructor {

View file

@ -1,7 +1,7 @@
import { WritableStream } from "@yume-chan/adb";
import { PromiseResolver } from "@yume-chan/async";
import { AndroidCodecLevel, AndroidCodecProfile } from "../../codec";
import type { H264Decoder, H264EncodingInfo } from '../common';
import type { H264Configuration, H264Decoder } from '../common';
import { createTinyH264Wrapper, TinyH264Wrapper } from "./wrapper";
let cachedInitializePromise: Promise<{ YuvBuffer: typeof import('yuv-buffer'), YuvCanvas: typeof import('yuv-canvas').default; }> | undefined;
@ -45,7 +45,7 @@ export class TinyH264Decoder implements H264Decoder {
this._renderer = document.createElement('canvas');
}
public async changeEncoding(size: H264EncodingInfo) {
public async configure(config: H264Configuration) {
this.dispose();
this._initializer = new PromiseResolver<TinyH264Wrapper>();
@ -55,23 +55,23 @@ export class TinyH264Decoder implements H264Decoder {
this._yuvCanvas = YuvCanvas.attach(this._renderer);;
}
const { encodedWidth, encodedHeight } = size;
const { encodedWidth, encodedHeight } = config;
const chromaWidth = encodedWidth / 2;
const chromaHeight = encodedHeight / 2;
this._renderer.width = size.croppedWidth;
this._renderer.height = size.croppedHeight;
this._renderer.width = config.croppedWidth;
this._renderer.height = config.croppedHeight;
const format = YuvBuffer.format({
width: encodedWidth,
height: encodedHeight,
chromaWidth,
chromaHeight,
cropLeft: size.cropLeft,
cropTop: size.cropTop,
cropWidth: size.croppedWidth,
cropHeight: size.croppedHeight,
displayWidth: size.croppedWidth,
displayHeight: size.croppedHeight,
cropLeft: config.cropLeft,
cropTop: config.cropTop,
cropWidth: config.croppedWidth,
cropHeight: config.croppedHeight,
displayWidth: config.croppedWidth,
displayHeight: config.croppedHeight,
});
const wrapper = await createTinyH264Wrapper();

View file

@ -1,6 +1,6 @@
import type { ValueOrPromise } from "@yume-chan/struct";
import { AndroidCodecLevel, AndroidCodecProfile } from "../../codec";
import type { H264Decoder, H264EncodingInfo } from "../common";
import type { H264Configuration, H264Decoder } from "../common";
function toHex(value: number) {
return value.toString(16).padStart(2, '0').toUpperCase();
@ -41,11 +41,11 @@ export class WebCodecsDecoder implements H264Decoder {
});
}
public changeEncoding(encoding: H264EncodingInfo): ValueOrPromise<void> {
const { profileIndex, constraintSet, levelIndex } = encoding;
public configure(config: H264Configuration): ValueOrPromise<void> {
const { profileIndex, constraintSet, levelIndex } = config;
this._renderer.width = encoding.croppedWidth;
this._renderer.height = encoding.croppedHeight;
this._renderer.width = config.croppedWidth;
this._renderer.height = config.croppedHeight;
// https://www.rfc-editor.org/rfc/rfc6381#section-3.3
// ISO Base Media File Format Name Space

View file

@ -5,7 +5,7 @@ import { ScrcpyClientConnection, ScrcpyClientConnectionOptions, ScrcpyClientForw
import { AndroidKeyEventAction, ScrcpyControlMessageType } from "../../message";
import type { ScrcpyBackOrScreenOnEvent1_18 } from "../1_18";
import type { ScrcpyInjectScrollControlMessage1_22 } from "../1_22";
import { type ScrcpyOptionValue, toScrcpyOptionValue, VideoStreamPacket, type ScrcpyOptions } from "../common";
import { toScrcpyOptionValue, type VideoStreamPacket, type ScrcpyOptions, type ScrcpyOptionValue } from "../common";
import { parse_sequence_parameter_set } from "./sps";
export enum ScrcpyLogLevel {
@ -216,15 +216,12 @@ export class ScrcpyOptions1_16<T extends ScrcpyOptionsInit1_16 = ScrcpyOptionsIn
public async parseVideoStream(stream: AdbBufferedStream): Promise<VideoStreamPacket> {
if (this.value.sendFrameMeta === false) {
return {
videoData: await stream.read(1 * 1024 * 1024, true),
type: 'frame',
data: await stream.read(1 * 1024 * 1024, true),
};
}
const { pts, data } = await VideoPacket.deserialize(stream);
if (!data || data.byteLength === 0) {
return {};
}
if (pts === NoPts) {
const sequenceParameterSet = parse_sequence_parameter_set(data.slice(0));
@ -253,7 +250,8 @@ export class ScrcpyOptions1_16<T extends ScrcpyOptionsInit1_16 = ScrcpyOptionsIn
this._streamHeader = data;
return {
encodingInfo: {
type: 'configuration',
data: {
profileIndex,
constraintSet,
levelIndex,
@ -269,18 +267,20 @@ export class ScrcpyOptions1_16<T extends ScrcpyOptionsInit1_16 = ScrcpyOptionsIn
};
}
let array: Uint8Array;
let frameData: ArrayBuffer;
if (this._streamHeader) {
array = new Uint8Array(this._streamHeader.byteLength + data!.byteLength);
frameData = new ArrayBuffer(this._streamHeader.byteLength + data.byteLength);
const array = new Uint8Array(frameData);
array.set(new Uint8Array(this._streamHeader));
array.set(new Uint8Array(data!), this._streamHeader.byteLength);
this._streamHeader = undefined;
} else {
array = new Uint8Array(data!);
frameData = data;
}
return {
videoData: array.buffer,
type: 'frame',
data: frameData,
};
}

View file

@ -1,6 +1,6 @@
// cspell: ignore autosync
import { ScrcpyOptions1_18, ScrcpyOptionsInit1_18 } from './1_18';
import { ScrcpyOptions1_18, type ScrcpyOptionsInit1_18 } from './1_18';
import { toScrcpyOptionValue } from "./common";
export interface ScrcpyOptionsInit1_21 extends ScrcpyOptionsInit1_18 {

View file

@ -1,6 +1,6 @@
import { Adb } from "@yume-chan/adb";
import type { Adb } from "@yume-chan/adb";
import Struct from "@yume-chan/struct";
import { ScrcpyClientConnection, ScrcpyClientConnectionOptions, ScrcpyClientForwardConnection, ScrcpyClientReverseConnection } from "../connection";
import { type ScrcpyClientConnection, ScrcpyClientConnectionOptions, ScrcpyClientForwardConnection, ScrcpyClientReverseConnection } from "../connection";
import { ScrcpyInjectScrollControlMessage1_16 } from "./1_16";
import { ScrcpyOptions1_21, type ScrcpyOptionsInit1_21 } from "./1_21";

View file

@ -1,6 +1,6 @@
import type { Adb, AdbBufferedStream } from "@yume-chan/adb";
import type { ScrcpyClientConnection } from "../connection";
import type { H264EncodingInfo } from "../decoder";
import type { H264Configuration } from "../decoder";
import type { ScrcpyBackOrScreenOnEvent1_18 } from "./1_18";
import type { ScrcpyInjectScrollControlMessage1_22 } from "./1_22";
@ -28,12 +28,18 @@ export function toScrcpyOptionValue<T>(value: any, empty: T): string | T {
return `${value}`;
}
export interface VideoStreamPacket {
encodingInfo?: H264EncodingInfo | undefined;
videoData?: ArrayBuffer | undefined;
export interface VideoStreamConfigurationPacket {
type: 'configuration';
data: H264Configuration;
}
export interface VideoStreamFramePacket {
type: 'frame';
data: ArrayBuffer;
}
export type VideoStreamPacket = VideoStreamConfigurationPacket | VideoStreamFramePacket;
export interface ScrcpyOptions<T> {
value: Partial<T>;