mirror of
https://github.com/yume-chan/ya-webadb.git
synced 2025-10-03 01:39:21 +02:00
411 lines
12 KiB
TypeScript
411 lines
12 KiB
TypeScript
import type { Adb, AdbSubprocessProtocol } from "@yume-chan/adb";
|
|
import {
|
|
AdbReverseNotSupportedError,
|
|
AdbSubprocessNoneProtocol,
|
|
} from "@yume-chan/adb";
|
|
import type {
|
|
ScrcpyAudioStreamDisabledMetadata,
|
|
ScrcpyAudioStreamErroredMetadata,
|
|
ScrcpyAudioStreamSuccessMetadata,
|
|
ScrcpyDeviceMessage,
|
|
ScrcpyDisplay,
|
|
ScrcpyEncoder,
|
|
ScrcpyMediaStreamPacket,
|
|
ScrcpyVideoStreamMetadata,
|
|
} from "@yume-chan/scrcpy";
|
|
import {
|
|
DEFAULT_SERVER_PATH,
|
|
ScrcpyControlMessageWriter,
|
|
ScrcpyDeviceMessageDeserializeStream,
|
|
ScrcpyVideoCodecId,
|
|
h264ParseConfiguration,
|
|
h265ParseConfiguration,
|
|
} from "@yume-chan/scrcpy";
|
|
import type {
|
|
Consumable,
|
|
MaybeConsumable,
|
|
ReadableStream,
|
|
ReadableWritablePair,
|
|
} from "@yume-chan/stream-extra";
|
|
import {
|
|
AbortController,
|
|
DecodeUtf8Stream,
|
|
InspectStream,
|
|
PushReadableStream,
|
|
SplitStringStream,
|
|
WritableStream,
|
|
} from "@yume-chan/stream-extra";
|
|
|
|
import type { AdbScrcpyConnection } from "./connection.js";
|
|
import type { AdbScrcpyOptions } from "./options/index.js";
|
|
|
|
function arrayToStream<T>(array: T[]): ReadableStream<T> {
|
|
return new PushReadableStream(async (controller) => {
|
|
for (const item of array) {
|
|
await controller.enqueue(item);
|
|
}
|
|
});
|
|
}
|
|
|
|
function concatStreams<T>(...streams: ReadableStream<T>[]): ReadableStream<T> {
|
|
return new PushReadableStream(async (controller) => {
|
|
for (const stream of streams) {
|
|
const reader = stream.getReader();
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
await controller.enqueue(value);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export class AdbScrcpyExitedError extends Error {
|
|
output: string[];
|
|
|
|
constructor(output: string[]) {
|
|
super("scrcpy server exited prematurely");
|
|
this.output = output;
|
|
}
|
|
}
|
|
|
|
interface AdbScrcpyClientInit {
|
|
options: AdbScrcpyOptions<object>;
|
|
process: AdbSubprocessProtocol;
|
|
stdout: ReadableStream<string>;
|
|
|
|
videoStream: ReadableStream<Uint8Array> | undefined;
|
|
audioStream: ReadableStream<Uint8Array> | undefined;
|
|
controlStream:
|
|
| ReadableWritablePair<Uint8Array, Consumable<Uint8Array>>
|
|
| undefined;
|
|
}
|
|
|
|
export interface AdbScrcpyVideoStream {
|
|
stream: ReadableStream<ScrcpyMediaStreamPacket>;
|
|
metadata: ScrcpyVideoStreamMetadata;
|
|
}
|
|
|
|
export interface AdbScrcpyAudioStreamSuccessMetadata
|
|
extends Omit<ScrcpyAudioStreamSuccessMetadata, "stream"> {
|
|
readonly stream: ReadableStream<ScrcpyMediaStreamPacket>;
|
|
}
|
|
|
|
export type AdbScrcpyAudioStreamMetadata =
|
|
| ScrcpyAudioStreamDisabledMetadata
|
|
| ScrcpyAudioStreamErroredMetadata
|
|
| AdbScrcpyAudioStreamSuccessMetadata;
|
|
|
|
export class AdbScrcpyClient {
|
|
static async pushServer(
|
|
adb: Adb,
|
|
file: ReadableStream<MaybeConsumable<Uint8Array>>,
|
|
filename = DEFAULT_SERVER_PATH,
|
|
) {
|
|
const sync = await adb.sync();
|
|
try {
|
|
await sync.write({
|
|
filename,
|
|
file,
|
|
});
|
|
} finally {
|
|
await sync.dispose();
|
|
}
|
|
}
|
|
|
|
static async start(
|
|
adb: Adb,
|
|
path: string,
|
|
version: string,
|
|
options: AdbScrcpyOptions<object>,
|
|
) {
|
|
let connection: AdbScrcpyConnection | undefined;
|
|
let process: AdbSubprocessProtocol | undefined;
|
|
|
|
try {
|
|
try {
|
|
connection = options.createConnection(adb);
|
|
await connection.initialize();
|
|
} catch (e) {
|
|
if (e instanceof AdbReverseNotSupportedError) {
|
|
// When reverse tunnel is not supported, try forward tunnel.
|
|
options.tunnelForwardOverride = true;
|
|
connection = options.createConnection(adb);
|
|
await connection.initialize();
|
|
} else {
|
|
connection = undefined;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
process = await adb.subprocess.spawn(
|
|
[
|
|
// cspell: disable-next-line
|
|
`CLASSPATH=${path}`,
|
|
"app_process",
|
|
/* unused */ "/",
|
|
"com.genymobile.scrcpy.Server",
|
|
version,
|
|
...options.serialize(),
|
|
],
|
|
{
|
|
// Scrcpy server doesn't use stderr,
|
|
// so disable Shell Protocol to simplify processing
|
|
protocols: [AdbSubprocessNoneProtocol],
|
|
},
|
|
);
|
|
|
|
const stdout = process.stdout
|
|
.pipeThrough(new DecodeUtf8Stream())
|
|
.pipeThrough(new SplitStringStream("\n"));
|
|
|
|
// Must read all streams, otherwise the whole connection will be blocked.
|
|
const output: string[] = [];
|
|
const abortController = new AbortController();
|
|
const pipe = stdout
|
|
.pipeTo(
|
|
new WritableStream({
|
|
write(chunk) {
|
|
output.push(chunk);
|
|
},
|
|
}),
|
|
{
|
|
signal: abortController.signal,
|
|
preventCancel: true,
|
|
},
|
|
)
|
|
.catch((e) => {
|
|
if (abortController.signal.aborted) {
|
|
return;
|
|
}
|
|
|
|
throw e;
|
|
});
|
|
|
|
const streams = await Promise.race([
|
|
process.exit.then(() => {
|
|
throw new AdbScrcpyExitedError(output);
|
|
}),
|
|
connection.getStreams(),
|
|
]);
|
|
|
|
abortController.abort();
|
|
await pipe;
|
|
|
|
return new AdbScrcpyClient({
|
|
options,
|
|
process,
|
|
stdout: concatStreams(arrayToStream(output), stdout),
|
|
videoStream: streams.video,
|
|
audioStream: streams.audio,
|
|
controlStream: streams.control,
|
|
});
|
|
} catch (e) {
|
|
await process?.kill();
|
|
throw e;
|
|
} finally {
|
|
connection?.dispose();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This method will modify the given `options`,
|
|
* so don't reuse it elsewhere.
|
|
*/
|
|
static async getEncoders(
|
|
adb: Adb,
|
|
path: string,
|
|
version: string,
|
|
options: AdbScrcpyOptions<object>,
|
|
): Promise<ScrcpyEncoder[]> {
|
|
options.setListEncoders();
|
|
return await options.getEncoders(adb, path, version);
|
|
}
|
|
|
|
/**
|
|
* This method will modify the given `options`,
|
|
* so don't reuse it elsewhere.
|
|
*/
|
|
static async getDisplays(
|
|
adb: Adb,
|
|
path: string,
|
|
version: string,
|
|
options: AdbScrcpyOptions<object>,
|
|
): Promise<ScrcpyDisplay[]> {
|
|
options.setListDisplays();
|
|
return await options.getDisplays(adb, path, version);
|
|
}
|
|
|
|
#options: AdbScrcpyOptions<object>;
|
|
#process: AdbSubprocessProtocol;
|
|
|
|
#stdout: ReadableStream<string>;
|
|
get stdout() {
|
|
return this.#stdout;
|
|
}
|
|
|
|
get exit() {
|
|
return this.#process.exit;
|
|
}
|
|
|
|
#screenWidth: number | undefined;
|
|
get screenWidth() {
|
|
return this.#screenWidth;
|
|
}
|
|
|
|
#screenHeight: number | undefined;
|
|
get screenHeight() {
|
|
return this.#screenHeight;
|
|
}
|
|
|
|
#videoStream: Promise<AdbScrcpyVideoStream> | undefined;
|
|
/**
|
|
* Gets a `Promise` that resolves to the parsed video stream.
|
|
*
|
|
* On server version 2.1 and above, it will be `undefined` if
|
|
* video is disabled by `options.video: false`.
|
|
*
|
|
* Note: if it's not `undefined`, it must be consumed to prevent
|
|
* the connection from being blocked.
|
|
*/
|
|
get videoStream() {
|
|
return this.#videoStream;
|
|
}
|
|
|
|
#audioStream: Promise<AdbScrcpyAudioStreamMetadata> | undefined;
|
|
/**
|
|
* Gets a `Promise` that resolves to the parsed audio stream.
|
|
*
|
|
* On server versions before 2.0, it will always be `undefined`.
|
|
* On server version 2.0 and above, it will be `undefined` if
|
|
* audio is disabled by `options.audio: false`.
|
|
*
|
|
* Note: if it's not `undefined`, it must be consumed to prevent
|
|
* the connection from being blocked.
|
|
*/
|
|
get audioStream() {
|
|
return this.#audioStream;
|
|
}
|
|
|
|
#controlMessageWriter: ScrcpyControlMessageWriter | undefined;
|
|
/**
|
|
* Gets the control message writer.
|
|
*
|
|
* On server version 1.22 and above, it will be `undefined` if
|
|
* control is disabled by `options.control: false`.
|
|
*/
|
|
get controlMessageWriter() {
|
|
return this.#controlMessageWriter;
|
|
}
|
|
|
|
#deviceMessageStream: ReadableStream<ScrcpyDeviceMessage> | undefined;
|
|
/**
|
|
* Gets the device message stream.
|
|
*
|
|
* On server version 1.22 and above, it will be `undefined` if
|
|
* control is disabled by `options.control: false`.
|
|
*
|
|
* Note: it must be consumed to prevent the connection from being blocked.
|
|
*/
|
|
get deviceMessageStream() {
|
|
return this.#deviceMessageStream;
|
|
}
|
|
|
|
constructor({
|
|
options,
|
|
process,
|
|
stdout,
|
|
videoStream,
|
|
audioStream,
|
|
controlStream,
|
|
}: AdbScrcpyClientInit) {
|
|
this.#options = options;
|
|
this.#process = process;
|
|
this.#stdout = stdout;
|
|
|
|
this.#videoStream = videoStream
|
|
? this.#createVideoStream(videoStream)
|
|
: undefined;
|
|
|
|
this.#audioStream = audioStream
|
|
? this.#createAudioStream(audioStream)
|
|
: undefined;
|
|
|
|
if (controlStream) {
|
|
this.#controlMessageWriter = new ScrcpyControlMessageWriter(
|
|
controlStream.writable.getWriter(),
|
|
options,
|
|
);
|
|
this.#deviceMessageStream = controlStream.readable.pipeThrough(
|
|
new ScrcpyDeviceMessageDeserializeStream(),
|
|
);
|
|
}
|
|
}
|
|
|
|
async #createVideoStream(initialStream: ReadableStream<Uint8Array>) {
|
|
const { stream, metadata } =
|
|
await this.#options.parseVideoStreamMetadata(initialStream);
|
|
|
|
return {
|
|
stream: stream
|
|
.pipeThrough(this.#options.createMediaStreamTransformer())
|
|
.pipeThrough(
|
|
new InspectStream((packet) => {
|
|
if (packet.type === "configuration") {
|
|
switch (metadata.codec) {
|
|
case ScrcpyVideoCodecId.H264: {
|
|
const { croppedWidth, croppedHeight } =
|
|
h264ParseConfiguration(packet.data);
|
|
|
|
this.#screenWidth = croppedWidth;
|
|
this.#screenHeight = croppedHeight;
|
|
break;
|
|
}
|
|
case ScrcpyVideoCodecId.H265: {
|
|
const { croppedWidth, croppedHeight } =
|
|
h265ParseConfiguration(packet.data);
|
|
|
|
this.#screenWidth = croppedWidth;
|
|
this.#screenHeight = croppedHeight;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
),
|
|
metadata,
|
|
};
|
|
}
|
|
|
|
async #createAudioStream(
|
|
initialStream: ReadableStream<Uint8Array>,
|
|
): Promise<AdbScrcpyAudioStreamMetadata> {
|
|
const metadata =
|
|
await this.#options.parseAudioStreamMetadata(initialStream);
|
|
|
|
switch (metadata.type) {
|
|
case "disabled":
|
|
case "errored":
|
|
return metadata;
|
|
case "success":
|
|
return {
|
|
...metadata,
|
|
stream: metadata.stream.pipeThrough(
|
|
this.#options.createMediaStreamTransformer(),
|
|
),
|
|
};
|
|
default:
|
|
throw new Error(
|
|
`Unexpected audio metadata type ${
|
|
metadata["type"] as unknown as string
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async close() {
|
|
await this.#process.kill();
|
|
}
|
|
}
|