serve-emul 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/LICENSE +201 -0
- package/README.md +196 -0
- package/dist/ui/assets/index-Cm5-Tjhs.css +1 -0
- package/dist/ui/assets/index-CyUIa9dV.js +42 -0
- package/dist/ui/index.html +13 -0
- package/package.json +67 -0
- package/scripts/fetch-scrcpy.ts +28 -0
- package/scripts/release.ts +136 -0
- package/src/accessibility.ts +88 -0
- package/src/adb.ts +209 -0
- package/src/app-info.ts +114 -0
- package/src/app-management.ts +150 -0
- package/src/cli.ts +149 -0
- package/src/emulator.ts +229 -0
- package/src/input.ts +258 -0
- package/src/location.ts +135 -0
- package/src/route-playback.ts +359 -0
- package/src/scrcpy.ts +466 -0
- package/src/server.ts +1260 -0
- package/src/session-recorder.ts +149 -0
- package/src/ui/app.tsx +111 -0
- package/src/ui/components/accessibility-panel.tsx +113 -0
- package/src/ui/components/app-management-panel.tsx +256 -0
- package/src/ui/components/control-bar.tsx +24 -0
- package/src/ui/components/device-panel.tsx +532 -0
- package/src/ui/components/device-stream.tsx +142 -0
- package/src/ui/components/location-panel.tsx +584 -0
- package/src/ui/components/logcat-panel.tsx +100 -0
- package/src/ui/components/session-panel.tsx +127 -0
- package/src/ui/components/status-bar.tsx +19 -0
- package/src/ui/index.html +12 -0
- package/src/ui/lib/h264.ts +35 -0
- package/src/ui/lib/use-stream.ts +368 -0
- package/src/ui/main.tsx +7 -0
- package/src/ui/styles.css +708 -0
- package/src/ui/tsconfig.json +17 -0
- package/src/update-check.ts +93 -0
- package/vendor/scrcpy-server-v2.7 +0 -0
- package/vendor/scrcpy-server-v3.1 +0 -0
- package/vendor/scrcpy-server-v3.3.4 +0 -0
- package/vendor/scrcpy-server-v4.0 +0 -0
package/src/scrcpy.ts
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { createConnection, type Socket } from "node:net";
|
|
3
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
+
import { SCRCPY_VERSION, ensureScrcpyServer } from "../scripts/fetch-scrcpy.ts";
|
|
5
|
+
|
|
6
|
+
const DEVICE_JAR_PATH = "/data/local/tmp/scrcpy-server.jar";
|
|
7
|
+
|
|
8
|
+
export type ScrcpyMeta = {
|
|
9
|
+
deviceName: string;
|
|
10
|
+
codecId: string;
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type ScrcpyProtocol = 3 | 4;
|
|
16
|
+
|
|
17
|
+
export type ScrcpySession = {
|
|
18
|
+
transport: "scrcpy";
|
|
19
|
+
meta: ScrcpyMeta;
|
|
20
|
+
protocol: ScrcpyProtocol;
|
|
21
|
+
videoReader: FramedReader;
|
|
22
|
+
controlSocket: Socket;
|
|
23
|
+
proc: ChildProcess;
|
|
24
|
+
scid: string;
|
|
25
|
+
localPort: number;
|
|
26
|
+
serial: string;
|
|
27
|
+
readFrame: () => Promise<VideoPacket | null>;
|
|
28
|
+
close: () => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type StartOpts = {
|
|
32
|
+
serial: string;
|
|
33
|
+
maxFps?: number;
|
|
34
|
+
bitRate?: number;
|
|
35
|
+
maxSize?: number;
|
|
36
|
+
keyFrameInterval?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type VideoFrame = {
|
|
40
|
+
type: "frame";
|
|
41
|
+
data: Buffer;
|
|
42
|
+
pts: bigint;
|
|
43
|
+
isConfig: boolean;
|
|
44
|
+
isKey: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type VideoSession = {
|
|
48
|
+
type: "session";
|
|
49
|
+
width: number;
|
|
50
|
+
height: number;
|
|
51
|
+
clientResized: boolean;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type VideoPacket = VideoFrame | VideoSession;
|
|
55
|
+
|
|
56
|
+
function adb(serial: string, args: string[]) {
|
|
57
|
+
const r = spawnSync("adb", ["-s", serial, ...args], { encoding: "utf8" });
|
|
58
|
+
if (r.status !== 0)
|
|
59
|
+
throw new Error(`adb -s ${serial} ${args.join(" ")} failed: ${r.stderr}`);
|
|
60
|
+
return r.stdout;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pickPort(): number {
|
|
64
|
+
return 27200 + Math.floor(Math.random() * 2000);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function removeForward(serial: string, port: number): void {
|
|
68
|
+
const r = spawnSync("adb", ["-s", serial, "forward", "--remove", `tcp:${port}`], {
|
|
69
|
+
encoding: "utf8",
|
|
70
|
+
});
|
|
71
|
+
if (r.status !== 0 && !r.stderr.includes("cannot remove listener")) {
|
|
72
|
+
throw new Error(`adb -s ${serial} forward --remove tcp:${port} failed: ${r.stderr}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function forwardedPort(serial: string, target: string): number | null {
|
|
77
|
+
const r = spawnSync("adb", ["-s", serial, "forward", "--list"], { encoding: "utf8" });
|
|
78
|
+
if (r.status !== 0) return null;
|
|
79
|
+
for (const line of r.stdout.split("\n")) {
|
|
80
|
+
const match = line.match(/^(\S+)\s+tcp:(\d+)\s+(.+)$/);
|
|
81
|
+
if (!match) continue;
|
|
82
|
+
if (match[1] === serial && match[3] === target) return Number(match[2]);
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function forwardAbstractSocket(serial: string, scid: string): number {
|
|
88
|
+
const target = `localabstract:scrcpy_${scid}`;
|
|
89
|
+
const dynamic = spawnSync("adb", ["-s", serial, "forward", "tcp:0", target], {
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
});
|
|
92
|
+
if (dynamic.status === 0) {
|
|
93
|
+
const port = Number(dynamic.stdout.trim()) || forwardedPort(serial, target);
|
|
94
|
+
if (port && Number.isInteger(port)) return port;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let lastError = dynamic.stderr.trim() || "adb did not return a forwarded port";
|
|
98
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
99
|
+
const port = pickPort();
|
|
100
|
+
const fixed = spawnSync("adb", ["-s", serial, "forward", `tcp:${port}`, target], {
|
|
101
|
+
encoding: "utf8",
|
|
102
|
+
});
|
|
103
|
+
if (fixed.status === 0) return port;
|
|
104
|
+
lastError = fixed.stderr.trim() || lastError;
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`Failed to create adb forward for ${target}: ${lastError}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function randomScid(): string {
|
|
110
|
+
// scrcpy parses scid with Integer.parseInt(radix=16), which is a *signed*
|
|
111
|
+
// 32-bit value, so the high bit must stay clear (max 0x7FFFFFFF).
|
|
112
|
+
return Math.floor(Math.random() * 0x7fffffff)
|
|
113
|
+
.toString(16)
|
|
114
|
+
.padStart(8, "0");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const MAX_READER_BUFFER_BYTES = 32 * 1024 * 1024;
|
|
118
|
+
|
|
119
|
+
class FramedReader {
|
|
120
|
+
private chunks: Buffer[] = [];
|
|
121
|
+
private firstChunkOffset = 0;
|
|
122
|
+
private total = 0;
|
|
123
|
+
private waiters: { n: number; resolve: (b: Buffer) => void; reject: (e: Error) => void }[] = [];
|
|
124
|
+
private err: Error | null = null;
|
|
125
|
+
|
|
126
|
+
constructor(public readonly sock: Socket) {
|
|
127
|
+
sock.on("data", (d: Buffer) => {
|
|
128
|
+
if (this.total + d.length > MAX_READER_BUFFER_BYTES) {
|
|
129
|
+
this.err = new Error("scrcpy video reader buffer overflow");
|
|
130
|
+
while (this.waiters.length) this.waiters.shift()!.reject(this.err);
|
|
131
|
+
this.chunks.length = 0;
|
|
132
|
+
this.total = 0;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.chunks.push(d);
|
|
136
|
+
this.total += d.length;
|
|
137
|
+
this.flush();
|
|
138
|
+
});
|
|
139
|
+
const fail = (e: Error) => {
|
|
140
|
+
this.err = e;
|
|
141
|
+
while (this.waiters.length) this.waiters.shift()!.reject(e);
|
|
142
|
+
};
|
|
143
|
+
sock.on("error", fail);
|
|
144
|
+
sock.on("end", () => fail(new Error("scrcpy video socket ended")));
|
|
145
|
+
sock.on("close", () => fail(new Error("scrcpy video socket closed")));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
read(n: number): Promise<Buffer> {
|
|
149
|
+
if (this.err) return Promise.reject(this.err);
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
this.waiters.push({ n, resolve, reject });
|
|
152
|
+
this.flush();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
prepend(data: Buffer): void {
|
|
157
|
+
if (data.length === 0) return;
|
|
158
|
+
if (this.firstChunkOffset > 0 && this.chunks.length > 0) {
|
|
159
|
+
this.chunks[0] = this.chunks[0].subarray(this.firstChunkOffset);
|
|
160
|
+
this.firstChunkOffset = 0;
|
|
161
|
+
}
|
|
162
|
+
this.chunks.unshift(data);
|
|
163
|
+
this.total += data.length;
|
|
164
|
+
this.flush();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private consume(n: number): Buffer {
|
|
168
|
+
const first = this.chunks[0];
|
|
169
|
+
const firstAvailable = first.length - this.firstChunkOffset;
|
|
170
|
+
if (firstAvailable >= n) {
|
|
171
|
+
const out = first.subarray(this.firstChunkOffset, this.firstChunkOffset + n);
|
|
172
|
+
this.firstChunkOffset += n;
|
|
173
|
+
this.total -= n;
|
|
174
|
+
if (this.firstChunkOffset === first.length) {
|
|
175
|
+
this.chunks.shift();
|
|
176
|
+
this.firstChunkOffset = 0;
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const out = Buffer.allocUnsafe(n);
|
|
182
|
+
let written = 0;
|
|
183
|
+
while (written < n) {
|
|
184
|
+
const chunk = this.chunks[0];
|
|
185
|
+
const available = chunk.length - this.firstChunkOffset;
|
|
186
|
+
const take = Math.min(n - written, available);
|
|
187
|
+
chunk.copy(out, written, this.firstChunkOffset, this.firstChunkOffset + take);
|
|
188
|
+
written += take;
|
|
189
|
+
this.firstChunkOffset += take;
|
|
190
|
+
this.total -= take;
|
|
191
|
+
if (this.firstChunkOffset === chunk.length) {
|
|
192
|
+
this.chunks.shift();
|
|
193
|
+
this.firstChunkOffset = 0;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private flush() {
|
|
200
|
+
while (this.waiters.length && this.total >= this.waiters[0].n) {
|
|
201
|
+
const w = this.waiters.shift()!;
|
|
202
|
+
w.resolve(this.consume(w.n));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function waitForAbstractSocket(serial: string, name: string, timeoutMs = 30_000) {
|
|
208
|
+
const start = Date.now();
|
|
209
|
+
while (Date.now() - start < timeoutMs) {
|
|
210
|
+
const r = spawnSync("adb", ["-s", serial, "shell", "cat", "/proc/net/unix"], {
|
|
211
|
+
encoding: "utf8",
|
|
212
|
+
});
|
|
213
|
+
if (r.stdout && r.stdout.includes(`@${name}`)) return;
|
|
214
|
+
await sleep(100);
|
|
215
|
+
}
|
|
216
|
+
throw new Error(`Timed out waiting for scrcpy abstract socket @${name}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function connectOnce(port: number, timeoutMs = 3_000): Promise<Socket> {
|
|
220
|
+
return new Promise<Socket>((resolve, reject) => {
|
|
221
|
+
const s = createConnection({ host: "127.0.0.1", port });
|
|
222
|
+
const timeout = setTimeout(() => {
|
|
223
|
+
s.destroy();
|
|
224
|
+
reject(new Error(`Timed out connecting to adb forward tcp:${port}`));
|
|
225
|
+
}, timeoutMs);
|
|
226
|
+
const onError = (e: Error) => {
|
|
227
|
+
clearTimeout(timeout);
|
|
228
|
+
s.removeListener("connect", onConnect);
|
|
229
|
+
reject(e);
|
|
230
|
+
};
|
|
231
|
+
const onConnect = () => {
|
|
232
|
+
clearTimeout(timeout);
|
|
233
|
+
s.removeListener("error", onError);
|
|
234
|
+
resolve(s);
|
|
235
|
+
};
|
|
236
|
+
s.once("error", onError);
|
|
237
|
+
s.once("connect", onConnect);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const CODEC_NAMES: Record<number, string> = {
|
|
242
|
+
0x68323634: "h264",
|
|
243
|
+
0x68323635: "h265",
|
|
244
|
+
0x00617631: "av1",
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
function parseVideoPreamble(buf: Buffer): {
|
|
248
|
+
deviceName: string;
|
|
249
|
+
codecName: string;
|
|
250
|
+
width: number;
|
|
251
|
+
height: number;
|
|
252
|
+
protocol: ScrcpyProtocol;
|
|
253
|
+
extra: Buffer;
|
|
254
|
+
} {
|
|
255
|
+
for (const offset of [0, 1]) {
|
|
256
|
+
const streamMetaOffset = offset + 64;
|
|
257
|
+
|
|
258
|
+
if (streamMetaOffset + 16 <= buf.length) {
|
|
259
|
+
const codecId = buf.readUInt32BE(streamMetaOffset);
|
|
260
|
+
const sessionFlags = buf.readUInt32BE(streamMetaOffset + 4);
|
|
261
|
+
const width = buf.readUInt32BE(streamMetaOffset + 8);
|
|
262
|
+
const height = buf.readUInt32BE(streamMetaOffset + 12);
|
|
263
|
+
const codecName = CODEC_NAMES[codecId];
|
|
264
|
+
if (
|
|
265
|
+
codecName &&
|
|
266
|
+
(sessionFlags & 0x80000000) !== 0 &&
|
|
267
|
+
width >= 1 &&
|
|
268
|
+
height >= 1 &&
|
|
269
|
+
width <= 16_384 &&
|
|
270
|
+
height <= 16_384
|
|
271
|
+
) {
|
|
272
|
+
const nameBuf = buf.subarray(offset, offset + 64);
|
|
273
|
+
const deviceName = nameBuf.toString("utf8").replace(/\0+$/, "");
|
|
274
|
+
return {
|
|
275
|
+
deviceName,
|
|
276
|
+
codecName,
|
|
277
|
+
width,
|
|
278
|
+
height,
|
|
279
|
+
protocol: 4,
|
|
280
|
+
extra: buf.subarray(streamMetaOffset + 16),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (streamMetaOffset + 12 > buf.length) continue;
|
|
286
|
+
const codecId = buf.readUInt32BE(streamMetaOffset);
|
|
287
|
+
const width = buf.readUInt32BE(streamMetaOffset + 4);
|
|
288
|
+
const height = buf.readUInt32BE(streamMetaOffset + 8);
|
|
289
|
+
const codecName = CODEC_NAMES[codecId];
|
|
290
|
+
if (!codecName || width < 1 || height < 1 || width > 16_384 || height > 16_384) continue;
|
|
291
|
+
|
|
292
|
+
const nameBuf = buf.subarray(offset, offset + 64);
|
|
293
|
+
const deviceName = nameBuf.toString("utf8").replace(/\0+$/, "");
|
|
294
|
+
return {
|
|
295
|
+
deviceName,
|
|
296
|
+
codecName,
|
|
297
|
+
width,
|
|
298
|
+
height,
|
|
299
|
+
protocol: 3,
|
|
300
|
+
extra: buf.subarray(streamMetaOffset + 12),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
throw new Error(`Could not parse scrcpy video preamble: ${buf.toString("hex", 0, 24)}...`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function startScrcpy(opts: StartOpts): Promise<ScrcpySession> {
|
|
308
|
+
const jar = await ensureScrcpyServer();
|
|
309
|
+
const { serial } = opts;
|
|
310
|
+
const maxFps = opts.maxFps ?? 60;
|
|
311
|
+
const bitRate = opts.bitRate ?? 8_000_000;
|
|
312
|
+
const maxSize = opts.maxSize ?? 0;
|
|
313
|
+
const keyFrameInterval = opts.keyFrameInterval ?? 1;
|
|
314
|
+
const scid = randomScid();
|
|
315
|
+
let localPort: number | null = null;
|
|
316
|
+
let proc: ChildProcess | null = null;
|
|
317
|
+
let videoSock: Socket | null = null;
|
|
318
|
+
let controlSock: Socket | null = null;
|
|
319
|
+
let closed = false;
|
|
320
|
+
|
|
321
|
+
const close = () => {
|
|
322
|
+
if (closed) return;
|
|
323
|
+
closed = true;
|
|
324
|
+
try {
|
|
325
|
+
videoSock?.destroy();
|
|
326
|
+
} catch {}
|
|
327
|
+
try {
|
|
328
|
+
controlSock?.destroy();
|
|
329
|
+
} catch {}
|
|
330
|
+
try {
|
|
331
|
+
proc?.kill("SIGKILL");
|
|
332
|
+
} catch {}
|
|
333
|
+
if (localPort !== null) {
|
|
334
|
+
try {
|
|
335
|
+
removeForward(serial, localPort);
|
|
336
|
+
} catch {}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
adb(serial, ["push", jar, DEVICE_JAR_PATH]);
|
|
342
|
+
localPort = forwardAbstractSocket(serial, scid);
|
|
343
|
+
|
|
344
|
+
proc = spawn(
|
|
345
|
+
"adb",
|
|
346
|
+
[
|
|
347
|
+
"-s",
|
|
348
|
+
serial,
|
|
349
|
+
"shell",
|
|
350
|
+
`CLASSPATH=${DEVICE_JAR_PATH}`,
|
|
351
|
+
"app_process",
|
|
352
|
+
"/",
|
|
353
|
+
"com.genymobile.scrcpy.Server",
|
|
354
|
+
SCRCPY_VERSION,
|
|
355
|
+
`scid=${scid}`,
|
|
356
|
+
"log_level=info",
|
|
357
|
+
"audio=false",
|
|
358
|
+
"tunnel_forward=true",
|
|
359
|
+
"control=true",
|
|
360
|
+
"send_dummy_byte=true",
|
|
361
|
+
"send_stream_meta=true",
|
|
362
|
+
"send_frame_meta=true",
|
|
363
|
+
"send_device_meta=true",
|
|
364
|
+
`max_size=${maxSize}`,
|
|
365
|
+
`video_bit_rate=${bitRate}`,
|
|
366
|
+
`max_fps=${maxFps}`,
|
|
367
|
+
...(keyFrameInterval > 0 ? [`video_codec_options=i-frame-interval=${keyFrameInterval}`] : []),
|
|
368
|
+
"cleanup=true",
|
|
369
|
+
],
|
|
370
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
371
|
+
);
|
|
372
|
+
proc.stdout?.on("data", (b: Buffer) => process.stdout.write(`[scrcpy] ${b}`));
|
|
373
|
+
proc.stderr?.on("data", (b: Buffer) => process.stderr.write(`[scrcpy] ${b}`));
|
|
374
|
+
|
|
375
|
+
// Wait for the device-side abstract socket to appear before the host dials in;
|
|
376
|
+
// otherwise adb accepts the local connection, then closes it the moment the
|
|
377
|
+
// device-side connect fails, and the client sees a phantom EOF.
|
|
378
|
+
await waitForAbstractSocket(serial, `scrcpy_${scid}`);
|
|
379
|
+
|
|
380
|
+
// scrcpy in tunnel_forward mode waits for ALL configured sockets to be
|
|
381
|
+
// connected before it begins streaming. Open both, then read the video
|
|
382
|
+
// preamble.
|
|
383
|
+
videoSock = await connectOnce(localPort);
|
|
384
|
+
controlSock = await connectOnce(localPort);
|
|
385
|
+
|
|
386
|
+
// After dummy byte, scrcpy may push clipboard events on the control socket;
|
|
387
|
+
// drain them.
|
|
388
|
+
controlSock.on("data", () => {});
|
|
389
|
+
|
|
390
|
+
const reader = new FramedReader(videoSock);
|
|
391
|
+
// scrcpy variants disagree on whether the video socket includes the dummy
|
|
392
|
+
// byte, so detect the codec metadata alignment instead of blindly skipping.
|
|
393
|
+
const preamble = parseVideoPreamble(await reader.read(81));
|
|
394
|
+
reader.prepend(preamble.extra);
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
transport: "scrcpy",
|
|
398
|
+
meta: {
|
|
399
|
+
deviceName: preamble.deviceName,
|
|
400
|
+
codecId: preamble.codecName,
|
|
401
|
+
width: preamble.width,
|
|
402
|
+
height: preamble.height,
|
|
403
|
+
},
|
|
404
|
+
protocol: preamble.protocol,
|
|
405
|
+
videoReader: reader,
|
|
406
|
+
controlSocket: controlSock,
|
|
407
|
+
proc,
|
|
408
|
+
scid,
|
|
409
|
+
localPort,
|
|
410
|
+
serial,
|
|
411
|
+
readFrame: () => readFrame(reader, preamble.protocol),
|
|
412
|
+
close,
|
|
413
|
+
};
|
|
414
|
+
} catch (err) {
|
|
415
|
+
close();
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Read one frame from the scrcpy video stream.
|
|
422
|
+
* Returns null when the stream ends. `isConfig` marks SPS/PPS bundles.
|
|
423
|
+
*/
|
|
424
|
+
const PACKET_FLAG_CONFIG = 1n << 63n;
|
|
425
|
+
const PACKET_FLAG_KEY_FRAME = 1n << 62n;
|
|
426
|
+
const PACKET_V4_FLAG_SESSION = 1n << 63n;
|
|
427
|
+
const PACKET_V4_FLAG_CONFIG = 1n << 62n;
|
|
428
|
+
const PACKET_V4_FLAG_KEY_FRAME = 1n << 61n;
|
|
429
|
+
const PACKET_V3_FLAGS = PACKET_FLAG_CONFIG | PACKET_FLAG_KEY_FRAME;
|
|
430
|
+
const PACKET_V4_FLAGS = PACKET_V4_FLAG_SESSION | PACKET_V4_FLAG_CONFIG | PACKET_V4_FLAG_KEY_FRAME;
|
|
431
|
+
|
|
432
|
+
export async function readFrame(
|
|
433
|
+
reader: FramedReader,
|
|
434
|
+
protocol: ScrcpyProtocol,
|
|
435
|
+
): Promise<VideoPacket | null> {
|
|
436
|
+
try {
|
|
437
|
+
const header = await reader.read(12);
|
|
438
|
+
const ptsRaw = header.readBigUInt64BE(0);
|
|
439
|
+
if (protocol === 4 && (ptsRaw & PACKET_V4_FLAG_SESSION) !== 0n) {
|
|
440
|
+
return {
|
|
441
|
+
type: "session",
|
|
442
|
+
clientResized: (header.readUInt32BE(0) & 1) !== 0,
|
|
443
|
+
width: header.readUInt32BE(4),
|
|
444
|
+
height: header.readUInt32BE(8),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const size = header.readUInt32BE(8);
|
|
449
|
+
if (size === 0 || size > 16 * 1024 * 1024) {
|
|
450
|
+
throw new Error(`invalid scrcpy frame size: ${size}`);
|
|
451
|
+
}
|
|
452
|
+
const isConfig =
|
|
453
|
+
protocol === 4
|
|
454
|
+
? (ptsRaw & PACKET_V4_FLAG_CONFIG) !== 0n
|
|
455
|
+
: (ptsRaw & PACKET_FLAG_CONFIG) !== 0n;
|
|
456
|
+
const isKey =
|
|
457
|
+
protocol === 4
|
|
458
|
+
? (ptsRaw & PACKET_V4_FLAG_KEY_FRAME) !== 0n
|
|
459
|
+
: (ptsRaw & PACKET_FLAG_KEY_FRAME) !== 0n;
|
|
460
|
+
const pts = ptsRaw & ~(protocol === 4 ? PACKET_V4_FLAGS : PACKET_V3_FLAGS);
|
|
461
|
+
const data = await reader.read(size);
|
|
462
|
+
return { type: "frame", data, pts, isConfig, isKey };
|
|
463
|
+
} catch {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
}
|