@tapflowio/android-agent 0.6.0 → 0.7.0
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/bin/emulator-encoder +0 -0
- package/dist/AndroidAgent.d.ts +8 -0
- package/dist/AndroidAgent.d.ts.map +1 -1
- package/dist/AndroidAgent.js +209 -61
- package/dist/AndroidAgent.js.map +1 -1
- package/dist/EmulatorLauncher.d.ts +4 -1
- package/dist/EmulatorLauncher.d.ts.map +1 -1
- package/dist/EmulatorLauncher.js +8 -2
- package/dist/EmulatorLauncher.js.map +1 -1
- package/dist/emulator/EmulatorGrpcClient.d.ts +106 -0
- package/dist/emulator/EmulatorGrpcClient.d.ts.map +1 -0
- package/dist/emulator/EmulatorGrpcClient.js +103 -0
- package/dist/emulator/EmulatorGrpcClient.js.map +1 -0
- package/dist/emulator/EmulatorVideo.d.ts +75 -0
- package/dist/emulator/EmulatorVideo.d.ts.map +1 -0
- package/dist/emulator/EmulatorVideo.js +255 -0
- package/dist/emulator/EmulatorVideo.js.map +1 -0
- package/dist/scrcpy/ScrcpyVideo.d.ts.map +1 -1
- package/dist/scrcpy/ScrcpyVideo.js +12 -4
- package/dist/scrcpy/ScrcpyVideo.js.map +1 -1
- package/package.json +7 -4
- package/proto/emulator_controller.proto +1813 -0
|
Binary file
|
package/dist/AndroidAgent.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Device, DeviceAgent } from '@tapflowio/agent-core';
|
|
2
2
|
import { type SleepBlocker } from '@tapflowio/agent-core/utils';
|
|
3
3
|
import { AdbWrapper } from './AdbWrapper.js';
|
|
4
|
+
export declare function pickAndroidBackend(serial: string, env?: NodeJS.ProcessEnv): 'grpc' | 'scrcpy';
|
|
4
5
|
export interface AndroidAgentOptions {
|
|
5
6
|
fps?: number;
|
|
6
7
|
/** AVD name or emulator serial to expose. Omit to expose all detected devices. */
|
|
@@ -32,7 +33,14 @@ export declare class AndroidAgent implements DeviceAgent {
|
|
|
32
33
|
private reportResources;
|
|
33
34
|
private cleanupDeviceState;
|
|
34
35
|
private sendDeviceInfo;
|
|
36
|
+
private forceScrcpy;
|
|
37
|
+
private useGrpc;
|
|
38
|
+
private pointerControl;
|
|
39
|
+
private fire;
|
|
40
|
+
private grpcPort;
|
|
35
41
|
private startVideoStream;
|
|
42
|
+
private pumpVideo;
|
|
43
|
+
private startGrpcVideoStream;
|
|
36
44
|
private restartVideoStream;
|
|
37
45
|
private openStreamWs;
|
|
38
46
|
private handleDeviceBoot;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AndroidAgent.d.ts","sourceRoot":"","sources":["../src/AndroidAgent.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAiB,MAAM,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAE/E,OAAO,
|
|
1
|
+
{"version":3,"file":"AndroidAgent.d.ts","sourceRoot":"","sources":["../src/AndroidAgent.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAiB,MAAM,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAE/E,OAAO,EASL,KAAK,YAAY,EAKlB,MAAM,6BAA6B,CAAA;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAqI5C,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,GAAG,QAAQ,CAI1G;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IAC1B,2FAA2F;IAC3F,YAAY,CAAC,EAAE,YAAY,CAAA;CAC5B;AAED,qBAAa,YAAa,YAAW,WAAW;IAC9C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAY;IAChC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkB;IAC3C,OAAO,CAAC,EAAE,CAAyB;IACnC,OAAO,CAAC,YAAY,CAAiC;IAGrD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAc;IAC3C,OAAO,CAAC,QAAQ,CAAsB;IACtC,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0B;IACpD,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,eAAe,CAA6C;IACpE,OAAO,CAAC,iBAAiB,CAAI;IAE7B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAQ;IACtC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAU;gBAE9B,OAAO,GAAE,mBAAwB,EAAE,GAAG,CAAC,EAAE,UAAU;IAS/D,IAAI,SAAS,IAAI,MAAM,GAAG,IAAI,CAG7B;IAEK,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAoD9C,OAAO,CAAC,gBAAgB;IA2BxB,UAAU,IAAI,IAAI;IAclB,OAAO,CAAC,kBAAkB;IA0B1B,OAAO,CAAC,eAAe;IAkBvB,OAAO,CAAC,kBAAkB;IAe1B,OAAO,CAAC,cAAc;IAYtB,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,OAAO;IAMf,OAAO,CAAC,cAAc;IAOtB,OAAO,CAAC,IAAI;IAQZ,OAAO,CAAC,QAAQ;YAIF,gBAAgB;YA2DhB,SAAS;YAsDT,oBAAoB;YA6CpB,kBAAkB;YAuClB,YAAY;YAOZ,gBAAgB;YA6DhB,oBAAoB;IAsBlC,OAAO,CAAC,kBAAkB;YAiOZ,cAAc;IAqC5B,WAAW,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAE1B,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQlC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQtC,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO1C,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO7C,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC;IAOnC,MAAM,IAAI,cAAc,CAAC,MAAM,CAAC;IAWhC,UAAU,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI;IAKtC,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAM9C,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAMnB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAM1C"}
|
package/dist/AndroidAgent.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import os from 'os';
|
|
2
2
|
import { WebSocket } from 'ws';
|
|
3
3
|
import { createLogger, PlatformError, ValidationError } from '@tapflowio/agent-core';
|
|
4
|
-
import { createResourceSampler, registerStreamWs,
|
|
4
|
+
import { createResourceSampler, registerStreamWs, disableNagle, createKeyframeAwareSender, pickMaxSize, createRateLimitedDropWarn, createThroughputSampler, createSleepBlocker, DEFAULT_BACKPRESSURE_BYTES, writeEnvelopeHeader, rewriteLowLatencySpsInFrame, CODEC_H264, } from '@tapflowio/agent-core/utils';
|
|
5
5
|
import { AdbWrapper } from './AdbWrapper.js';
|
|
6
6
|
import { EmulatorLauncher } from './EmulatorLauncher.js';
|
|
7
7
|
import { AndroidTouchHelper } from './AndroidTouchHelper.js';
|
|
8
8
|
import { ScrcpySession } from './scrcpy/ScrcpySession.js';
|
|
9
|
+
import { EmulatorGrpcClient } from './emulator/EmulatorGrpcClient.js';
|
|
10
|
+
import { EmulatorVideo } from './emulator/EmulatorVideo.js';
|
|
9
11
|
const logger = createLogger('android-agent');
|
|
10
12
|
// Parse H.264 SPS NAL unit to extract frame dimensions.
|
|
11
13
|
// scrcpy sends a new SPS (inside an IDR keyframe) whenever the capture size changes —
|
|
@@ -110,6 +112,16 @@ const ANDROID_BUTTONS = [
|
|
|
110
112
|
{ name: 'volume_down', accessibilityTitle: 'Volume Down', keyCode: 25 },
|
|
111
113
|
{ name: 'power', accessibilityTitle: 'Power', keyCode: 26 },
|
|
112
114
|
];
|
|
115
|
+
// Video backend per device: emulators (serial `emulator-*`) default to the gRPC host-encode path
|
|
116
|
+
// (bypasses the guest SW H.264 encoder); real devices use scrcpy (their SoC has a HW encoder).
|
|
117
|
+
// `TAPFLOW_ANDROID_BACKEND=scrcpy|grpc` overrides either way.
|
|
118
|
+
export function pickAndroidBackend(serial, env = process.env) {
|
|
119
|
+
if (env.TAPFLOW_ANDROID_BACKEND === 'scrcpy')
|
|
120
|
+
return 'scrcpy';
|
|
121
|
+
if (env.TAPFLOW_ANDROID_BACKEND === 'grpc')
|
|
122
|
+
return 'grpc';
|
|
123
|
+
return serial.startsWith('emulator-') ? 'grpc' : 'scrcpy';
|
|
124
|
+
}
|
|
113
125
|
export class AndroidAgent {
|
|
114
126
|
adb;
|
|
115
127
|
launcher;
|
|
@@ -152,6 +164,7 @@ export class AndroidAgent {
|
|
|
152
164
|
return new Promise((resolve, reject) => {
|
|
153
165
|
const ws = new WebSocket(relayUrl);
|
|
154
166
|
ws.once('open', () => {
|
|
167
|
+
disableNagle(ws);
|
|
155
168
|
ws.send(JSON.stringify({
|
|
156
169
|
type: 'agent:register',
|
|
157
170
|
platform: 'android',
|
|
@@ -197,6 +210,11 @@ export class AndroidAgent {
|
|
|
197
210
|
touchHelper: null,
|
|
198
211
|
streamWs: null,
|
|
199
212
|
scrcpySession: null,
|
|
213
|
+
emulatorVideo: null,
|
|
214
|
+
grpcClient: null,
|
|
215
|
+
cornerRadius: 0,
|
|
216
|
+
secureContext: false,
|
|
217
|
+
external: false,
|
|
200
218
|
displayWidth: 0,
|
|
201
219
|
displayHeight: 0,
|
|
202
220
|
videoWidth: 0,
|
|
@@ -258,7 +276,7 @@ export class AndroidAgent {
|
|
|
258
276
|
reportResources() {
|
|
259
277
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
260
278
|
return;
|
|
261
|
-
const bootedCount = Array.from(this.deviceStates.values()).filter((s) => s.scrcpySession !== null).length;
|
|
279
|
+
const bootedCount = Array.from(this.deviceStates.values()).filter((s) => s.scrcpySession !== null || s.emulatorVideo !== null).length;
|
|
262
280
|
const slotsTotal = this.deviceStates.size;
|
|
263
281
|
const { memUsedMB, memTotalMB } = this.resources.getMemoryUsage();
|
|
264
282
|
this.ws.send(JSON.stringify({
|
|
@@ -278,6 +296,11 @@ export class AndroidAgent {
|
|
|
278
296
|
if (serial && state.scrcpySession)
|
|
279
297
|
state.scrcpySession.stop(serial);
|
|
280
298
|
state.scrcpySession = null;
|
|
299
|
+
state.emulatorVideo?.stop();
|
|
300
|
+
state.emulatorVideo = null;
|
|
301
|
+
state.grpcClient?.close();
|
|
302
|
+
state.grpcClient = null;
|
|
303
|
+
state.cornerRadius = 0;
|
|
281
304
|
state.touchHelper?.stop();
|
|
282
305
|
state.touchHelper = null;
|
|
283
306
|
state.streamWs?.close();
|
|
@@ -295,10 +318,55 @@ export class AndroidAgent {
|
|
|
295
318
|
},
|
|
296
319
|
}));
|
|
297
320
|
}
|
|
321
|
+
forceScrcpy() {
|
|
322
|
+
return process.env.TAPFLOW_ANDROID_BACKEND === 'scrcpy';
|
|
323
|
+
}
|
|
324
|
+
useGrpc(serial) {
|
|
325
|
+
return pickAndroidBackend(serial) === 'grpc';
|
|
326
|
+
}
|
|
327
|
+
// The active low-latency pointer backend (gRPC preferred), or null when only the ADB fallback
|
|
328
|
+
// (AndroidTouchHelper) is available. Coordinates go in state.videoWidth/Height px.
|
|
329
|
+
pointerControl(state) {
|
|
330
|
+
if (state.grpcClient)
|
|
331
|
+
return state.grpcClient;
|
|
332
|
+
if (state.scrcpySession)
|
|
333
|
+
return state.scrcpySession.control;
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
// Fire-and-forget a pointer call: gRPC methods are async (swallow rejection), scrcpy sync (no-op).
|
|
337
|
+
fire(r) {
|
|
338
|
+
if (r)
|
|
339
|
+
r.catch(() => { });
|
|
340
|
+
}
|
|
341
|
+
// Port to launch the emulator with (`-grpc <port>`, unsecured localhost). A plain `-grpc` endpoint
|
|
342
|
+
// is unsecured; without it the emulator opens its DEFAULT gRPC port with token auth, which our
|
|
343
|
+
// unauthenticated client can't use. Launches are always AVDs (emulators), so default to gRPC
|
|
344
|
+
// unless explicitly forced to scrcpy. undefined = don't open gRPC.
|
|
345
|
+
grpcPort() {
|
|
346
|
+
return this.forceScrcpy() ? undefined : (Number(process.env.TAPFLOW_ANDROID_GRPC_PORT) || 8554);
|
|
347
|
+
}
|
|
298
348
|
async startVideoStream(state, streamWs) {
|
|
299
349
|
const serial = this.adb.getSerial(state.deviceId);
|
|
300
350
|
if (!serial)
|
|
301
351
|
return;
|
|
352
|
+
// Emulator: capture via gRPC streamScreenshot + Mac VideoToolbox (bypasses the guest SW H.264
|
|
353
|
+
// encoder). On any failure (e.g. an externally-booted emulator without `-grpc`), fall back to
|
|
354
|
+
// scrcpy so streaming still works.
|
|
355
|
+
if (this.useGrpc(serial)) {
|
|
356
|
+
try {
|
|
357
|
+
await this.startGrpcVideoStream(state, streamWs, serial);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
logger.warn(`gRPC backend failed (${e.message}) — falling back to scrcpy`);
|
|
362
|
+
state.emulatorVideo?.stop();
|
|
363
|
+
state.emulatorVideo = null;
|
|
364
|
+
state.grpcClient?.close();
|
|
365
|
+
state.grpcClient = null;
|
|
366
|
+
state.touchHelper?.stop();
|
|
367
|
+
state.touchHelper = null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
302
370
|
const touchHelper = new AndroidTouchHelper(this.adb, serial);
|
|
303
371
|
touchHelper.start();
|
|
304
372
|
state.touchHelper = touchHelper;
|
|
@@ -310,14 +378,46 @@ export class AndroidAgent {
|
|
|
310
378
|
state.displayHeight = info.height;
|
|
311
379
|
state.videoWidth = info.width;
|
|
312
380
|
state.videoHeight = info.height;
|
|
313
|
-
const
|
|
314
|
-
|
|
381
|
+
const reader = session.video.start().getReader();
|
|
382
|
+
// Detect video size changes via H.264 SPS so ScrcpyControl.screenSize always matches what
|
|
383
|
+
// scrcpy is encoding (landscape-aware vs portrait-locked). The SPS leads the keyframe AU.
|
|
384
|
+
const onFrame = (value) => {
|
|
385
|
+
const parsed = parseSpsFromNal(value.payload);
|
|
386
|
+
if (parsed && (parsed.width !== state.videoWidth || parsed.height !== state.videoHeight)) {
|
|
387
|
+
state.videoWidth = parsed.width;
|
|
388
|
+
state.videoHeight = parsed.height;
|
|
389
|
+
state.scrcpySession?.control.updateScreenSize(parsed.width, parsed.height);
|
|
390
|
+
logger.info(`video size → ${parsed.width}×${parsed.height}`);
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
void this.pumpVideo(state, streamWs, reader, onFrame, () => session.control.resetVideo()).then(() => {
|
|
394
|
+
if (state.scrcpySession === session && !state.restarting) {
|
|
395
|
+
state.restarting = true;
|
|
396
|
+
void this.restartVideoStream(state);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
// Shared frame pump for both video backends: reads H.264 access units, wraps each in the TFFE
|
|
401
|
+
// envelope (codec + per-AU keyframe flag, so the relay's keyframe-aware backpressure preserves the
|
|
402
|
+
// reference chain), and sends with backpressure + optional throughput metrics. `onFrame` lets a
|
|
403
|
+
// backend inspect each frame (scrcpy parses SPS for size). Resolves when the source stream ends.
|
|
404
|
+
async pumpVideo(state, streamWs, reader, onFrame, requestIdr) {
|
|
315
405
|
const threshold = Number(process.env.TAPFLOW_WS_BACKPRESSURE_BYTES) || DEFAULT_BACKPRESSURE_BYTES;
|
|
316
406
|
const warnDrop = createRateLimitedDropWarn(logger, state.deviceId);
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
407
|
+
// Keyframe-aware backpressure: when the agent→relay socket fills, drop whole GOPs to the next
|
|
408
|
+
// keyframe (never forward an orphan P-frame whose reference was dropped — that decodes to a
|
|
409
|
+
// sheared/ghosted frame until the next IDR). On a drop with no keyframe, ask the encoder for an
|
|
410
|
+
// IDR (throttled) so the stream resyncs fast instead of waiting for the periodic one.
|
|
411
|
+
const dropper = createKeyframeAwareSender();
|
|
412
|
+
let lastIdrReq = 0;
|
|
413
|
+
const onWantKeyframe = requestIdr
|
|
414
|
+
? () => { const now = Date.now(); if (now - lastIdrReq >= 500) {
|
|
415
|
+
lastIdrReq = now;
|
|
416
|
+
requestIdr();
|
|
417
|
+
} }
|
|
418
|
+
: undefined;
|
|
419
|
+
// Opt-in throughput baseline (TAPFLOW_STREAM_METRICS=1): logs fps/KB·s/drop every 5s, so the
|
|
420
|
+
// Android source rate can be compared against the relay→browser drop logs and the iOS agent.
|
|
321
421
|
const metrics = process.env.TAPFLOW_STREAM_METRICS === '1' ? createThroughputSampler() : null;
|
|
322
422
|
const metricsTimer = metrics
|
|
323
423
|
? setInterval(() => {
|
|
@@ -327,41 +427,71 @@ export class AndroidAgent {
|
|
|
327
427
|
: undefined;
|
|
328
428
|
metricsTimer?.unref();
|
|
329
429
|
const onDrop = metrics ? () => { metrics.recordDropped(); warnDrop(); } : warnDrop;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
logger.info(`video size → ${parsed.width}×${parsed.height}`);
|
|
345
|
-
}
|
|
346
|
-
// Mark codec=H.264 + per-AU keyframe so the relay's keyframe-aware backpressure
|
|
347
|
-
// preserves the reference chain (drops to the next IDR instead of tearing).
|
|
348
|
-
const frame = writeEnvelopeHeader(value.payload, Date.now(), { codec: CODEC_H264, keyframe: value.keyframe });
|
|
349
|
-
const sent = sendBinaryWithBackpressure(streamWs, frame, threshold, onDrop);
|
|
350
|
-
if (sent)
|
|
351
|
-
metrics?.recordSent(value.payload.length);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
catch {
|
|
355
|
-
// stream cancelled or ws closed — expected on disconnect
|
|
430
|
+
try {
|
|
431
|
+
while (true) {
|
|
432
|
+
const { value, done } = await reader.read();
|
|
433
|
+
if (done)
|
|
434
|
+
break;
|
|
435
|
+
onFrame?.(value);
|
|
436
|
+
// Declare reorder=0 on the keyframe SPS so the decoder (WASM/WebCodecs) emits frames
|
|
437
|
+
// immediately instead of buffering the level's max DPB (~hundreds of ms of latency on every
|
|
438
|
+
// frame). The gRPC/VideoToolbox SPS omits bitstream_restriction; scrcpy's is a no-op.
|
|
439
|
+
const payload = value.keyframe ? rewriteLowLatencySpsInFrame(value.payload) : value.payload;
|
|
440
|
+
const frame = writeEnvelopeHeader(payload, Date.now(), { codec: CODEC_H264, keyframe: value.keyframe });
|
|
441
|
+
const sent = dropper.send(streamWs, frame, threshold, value.keyframe, onDrop, onWantKeyframe);
|
|
442
|
+
if (sent)
|
|
443
|
+
metrics?.recordSent(value.payload.length);
|
|
356
444
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// stream cancelled or ws closed — expected on disconnect
|
|
448
|
+
}
|
|
449
|
+
if (metricsTimer)
|
|
450
|
+
clearInterval(metricsTimer);
|
|
451
|
+
}
|
|
452
|
+
// gRPC emulator backend: capture via EmulatorVideo (gRPC streamScreenshot + Mac VT encode) through
|
|
453
|
+
// the shared pump. Input is routed to the gRPC client in handleRelayMessage. No auto-restart —
|
|
454
|
+
// the backend is torn down with the device state.
|
|
455
|
+
async startGrpcVideoStream(state, streamWs, serial) {
|
|
456
|
+
const port = this.grpcPort() ?? 8554;
|
|
457
|
+
// Downscale box (longest side), server-side resize. Per-session tier from the viewer context
|
|
458
|
+
// (secure→native / LAN-HTTP→1280 / external→1000); TAPFLOW_ANDROID_MAX_SIZE | TAPFLOW_MAX_SIZE
|
|
459
|
+
// is a hard override.
|
|
460
|
+
const maxSize = pickMaxSize({
|
|
461
|
+
secureContext: state.secureContext,
|
|
462
|
+
external: state.external,
|
|
463
|
+
override: process.env.TAPFLOW_ANDROID_MAX_SIZE ?? process.env.TAPFLOW_MAX_SIZE,
|
|
464
|
+
});
|
|
465
|
+
// Default 30fps (iOS parity) — caps source 60fps to halve decode/transport for LAN-HTTP.
|
|
466
|
+
const fps = Number(process.env.TAPFLOW_ANDROID_FPS) || 30;
|
|
467
|
+
const touchHelper = new AndroidTouchHelper(this.adb, serial);
|
|
468
|
+
touchHelper.start();
|
|
469
|
+
state.touchHelper = touchHelper;
|
|
470
|
+
const client = new EmulatorGrpcClient(`127.0.0.1:${port}`);
|
|
471
|
+
const video = new EmulatorVideo(client, { fps, ...(maxSize ? { maxWidth: maxSize, maxHeight: maxSize } : {}) });
|
|
472
|
+
// Assign before start() so the caller's fallback cleanup can tear these down on failure.
|
|
473
|
+
state.grpcClient = client;
|
|
474
|
+
state.emulatorVideo = video;
|
|
475
|
+
const info = await video.start();
|
|
476
|
+
// gRPC sendTouch coordinates are in the device's native display resolution — not the (possibly
|
|
477
|
+
// downscaled) video size — so query it for correct normalized→px touch mapping. The aspect
|
|
478
|
+
// ratio matches the downscaled video, so native dims also drive the dashboard chrome.
|
|
479
|
+
const native = await this.adb.getScreenSize(serial).catch(() => ({ width: info.width, height: info.height }));
|
|
480
|
+
state.landscape = false;
|
|
481
|
+
state.displayWidth = native.width;
|
|
482
|
+
state.displayHeight = native.height;
|
|
483
|
+
state.videoWidth = native.width;
|
|
484
|
+
state.videoHeight = native.height;
|
|
485
|
+
state.cornerRadius = info.cornerRadius;
|
|
486
|
+
const reader = video.frames().getReader();
|
|
487
|
+
// If the gRPC video ends unexpectedly (emulator crash / disconnect), restart the stream so the
|
|
488
|
+
// session recovers instead of going dead — mirrors the scrcpy pump's auto-restart.
|
|
489
|
+
void this.pumpVideo(state, streamWs, reader, undefined, () => video.requestIdr()).then(() => {
|
|
490
|
+
if (state.emulatorVideo === video && !state.restarting) {
|
|
360
491
|
state.restarting = true;
|
|
361
492
|
void this.restartVideoStream(state);
|
|
362
493
|
}
|
|
363
|
-
};
|
|
364
|
-
void pump();
|
|
494
|
+
});
|
|
365
495
|
}
|
|
366
496
|
async restartVideoStream(state) {
|
|
367
497
|
const serial = this.adb.getSerial(state.deviceId);
|
|
@@ -371,6 +501,10 @@ export class AndroidAgent {
|
|
|
371
501
|
}
|
|
372
502
|
state.scrcpySession?.stop(serial);
|
|
373
503
|
state.scrcpySession = null;
|
|
504
|
+
state.emulatorVideo?.stop();
|
|
505
|
+
state.emulatorVideo = null;
|
|
506
|
+
state.grpcClient?.close();
|
|
507
|
+
state.grpcClient = null;
|
|
374
508
|
state.touchHelper?.stop();
|
|
375
509
|
state.touchHelper = null;
|
|
376
510
|
const { streamWs } = state;
|
|
@@ -404,12 +538,16 @@ export class AndroidAgent {
|
|
|
404
538
|
await registerStreamWs(streamWs, state.sessionId);
|
|
405
539
|
return streamWs;
|
|
406
540
|
}
|
|
407
|
-
async handleDeviceBoot(sessionId, avdId) {
|
|
541
|
+
async handleDeviceBoot(sessionId, avdId, tier) {
|
|
408
542
|
const state = this.deviceStates.get(sessionId);
|
|
409
543
|
if (!state || !this.ws)
|
|
410
544
|
return;
|
|
411
545
|
const seq = ++state.bootSeq;
|
|
412
546
|
this.cleanupDeviceState(state);
|
|
547
|
+
if (tier) {
|
|
548
|
+
state.secureContext = tier.secureContext;
|
|
549
|
+
state.external = tier.external;
|
|
550
|
+
}
|
|
413
551
|
this.ws.send(JSON.stringify({ type: 'device:booting', sessionId }));
|
|
414
552
|
try {
|
|
415
553
|
const avdName = avdId.replace(/^avd:/, '');
|
|
@@ -420,7 +558,7 @@ export class AndroidAgent {
|
|
|
420
558
|
if (!target)
|
|
421
559
|
throw new PlatformError(`Device not found: ${avdId}`);
|
|
422
560
|
if (target.status !== 'booted') {
|
|
423
|
-
this.launcher.launch(avdName);
|
|
561
|
+
this.launcher.launch(avdName, this.grpcPort());
|
|
424
562
|
const serial = await this.launcher.findSerial(avdName);
|
|
425
563
|
if (seq !== state.bootSeq)
|
|
426
564
|
return;
|
|
@@ -450,6 +588,7 @@ export class AndroidAgent {
|
|
|
450
588
|
streamType: 'h264',
|
|
451
589
|
screenWidth: state.displayWidth,
|
|
452
590
|
screenHeight: state.displayHeight,
|
|
591
|
+
cornerRadius: state.cornerRadius,
|
|
453
592
|
},
|
|
454
593
|
}));
|
|
455
594
|
this.ws?.send(JSON.stringify({ type: 'device:ready', sessionId, payload: { deviceId: avdId } }));
|
|
@@ -485,8 +624,8 @@ export class AndroidAgent {
|
|
|
485
624
|
handleRelayMessage(msg) {
|
|
486
625
|
switch (msg.type) {
|
|
487
626
|
case 'device:boot': {
|
|
488
|
-
const { deviceId } = msg.payload;
|
|
489
|
-
this.handleDeviceBoot(msg.sessionId, deviceId)
|
|
627
|
+
const { deviceId, secureContext, external } = msg.payload;
|
|
628
|
+
this.handleDeviceBoot(msg.sessionId, deviceId, { secureContext: !!secureContext, external: !!external })
|
|
490
629
|
.catch((e) => logger.error('handleDeviceBoot failed:', e));
|
|
491
630
|
break;
|
|
492
631
|
}
|
|
@@ -548,11 +687,12 @@ export class AndroidAgent {
|
|
|
548
687
|
if (!state)
|
|
549
688
|
break;
|
|
550
689
|
const { x, y } = msg.payload;
|
|
551
|
-
|
|
690
|
+
const pc = this.pointerControl(state);
|
|
691
|
+
if (pc && state.videoWidth > 0) {
|
|
552
692
|
const px = Math.round(x * state.videoWidth);
|
|
553
693
|
const py = Math.round(y * state.videoHeight);
|
|
554
694
|
state.lastTouchPx = { x: px, y: py };
|
|
555
|
-
|
|
695
|
+
this.fire(pc.touchDown(0, px, py));
|
|
556
696
|
}
|
|
557
697
|
else {
|
|
558
698
|
state.touchHelper?.touchStart(x, y);
|
|
@@ -564,11 +704,12 @@ export class AndroidAgent {
|
|
|
564
704
|
if (!state)
|
|
565
705
|
break;
|
|
566
706
|
const { x, y } = msg.payload;
|
|
567
|
-
|
|
707
|
+
const pc = this.pointerControl(state);
|
|
708
|
+
if (pc && state.videoWidth > 0) {
|
|
568
709
|
const px = Math.round(x * state.videoWidth);
|
|
569
710
|
const py = Math.round(y * state.videoHeight);
|
|
570
711
|
state.lastTouchPx = { x: px, y: py };
|
|
571
|
-
|
|
712
|
+
this.fire(pc.touchMove(0, px, py));
|
|
572
713
|
}
|
|
573
714
|
else {
|
|
574
715
|
state.touchHelper?.touchMove(x, y);
|
|
@@ -579,8 +720,9 @@ export class AndroidAgent {
|
|
|
579
720
|
const state = this.deviceStates.get(msg.sessionId);
|
|
580
721
|
if (!state)
|
|
581
722
|
break;
|
|
582
|
-
|
|
583
|
-
|
|
723
|
+
const pc = this.pointerControl(state);
|
|
724
|
+
if (pc) {
|
|
725
|
+
this.fire(pc.touchUp(0, state.lastTouchPx.x, state.lastTouchPx.y));
|
|
584
726
|
}
|
|
585
727
|
else {
|
|
586
728
|
state.touchHelper?.touchEnd();
|
|
@@ -592,10 +734,11 @@ export class AndroidAgent {
|
|
|
592
734
|
if (!state)
|
|
593
735
|
break;
|
|
594
736
|
const { f0, f1 } = msg.payload;
|
|
595
|
-
|
|
737
|
+
const pc = this.pointerControl(state);
|
|
738
|
+
if (pc && state.videoWidth > 0) {
|
|
596
739
|
const px1 = Math.round(f0.x * state.videoWidth), py1 = Math.round(f0.y * state.videoHeight);
|
|
597
740
|
const px2 = Math.round(f1.x * state.videoWidth), py2 = Math.round(f1.y * state.videoHeight);
|
|
598
|
-
|
|
741
|
+
this.fire(pc.pinchStart(px1, py1, px2, py2));
|
|
599
742
|
}
|
|
600
743
|
else {
|
|
601
744
|
state.touchHelper?.pinchStart(f0.x, f0.y, f1.x, f1.y);
|
|
@@ -607,10 +750,11 @@ export class AndroidAgent {
|
|
|
607
750
|
if (!state)
|
|
608
751
|
break;
|
|
609
752
|
const { f0, f1 } = msg.payload;
|
|
610
|
-
|
|
753
|
+
const pc = this.pointerControl(state);
|
|
754
|
+
if (pc && state.videoWidth > 0) {
|
|
611
755
|
const px1 = Math.round(f0.x * state.videoWidth), py1 = Math.round(f0.y * state.videoHeight);
|
|
612
756
|
const px2 = Math.round(f1.x * state.videoWidth), py2 = Math.round(f1.y * state.videoHeight);
|
|
613
|
-
|
|
757
|
+
this.fire(pc.pinchMove(px1, py1, px2, py2));
|
|
614
758
|
}
|
|
615
759
|
else {
|
|
616
760
|
state.touchHelper?.pinchMove(f0.x, f0.y, f1.x, f1.y);
|
|
@@ -621,8 +765,9 @@ export class AndroidAgent {
|
|
|
621
765
|
const state = this.deviceStates.get(msg.sessionId);
|
|
622
766
|
if (!state)
|
|
623
767
|
break;
|
|
624
|
-
|
|
625
|
-
|
|
768
|
+
const pc = this.pointerControl(state);
|
|
769
|
+
if (pc) {
|
|
770
|
+
this.fire(pc.pinchEnd());
|
|
626
771
|
}
|
|
627
772
|
else {
|
|
628
773
|
state.touchHelper?.pinchEnd();
|
|
@@ -654,9 +799,11 @@ export class AndroidAgent {
|
|
|
654
799
|
break;
|
|
655
800
|
}
|
|
656
801
|
case 'stream:request-idr': {
|
|
657
|
-
// Relay drop-to-keyframe recovery: reset the encoder so it re-emits SPS/PPS + IDR,
|
|
802
|
+
// Relay drop-to-keyframe / join recovery: reset the encoder so it re-emits SPS/PPS + IDR,
|
|
658
803
|
// resyncing fast instead of waiting for the periodic IDR. Throttled by the relay.
|
|
659
|
-
this.deviceStates.get(msg.sessionId)
|
|
804
|
+
const st = this.deviceStates.get(msg.sessionId);
|
|
805
|
+
st?.scrcpySession?.control.resetVideo();
|
|
806
|
+
st?.emulatorVideo?.requestIdr();
|
|
660
807
|
break;
|
|
661
808
|
}
|
|
662
809
|
case 'open-url': {
|
|
@@ -793,11 +940,12 @@ export class AndroidAgent {
|
|
|
793
940
|
}
|
|
794
941
|
stream() {
|
|
795
942
|
const state = this.deviceStates.values().next().value;
|
|
796
|
-
|
|
797
|
-
|
|
943
|
+
// Works on either video backend (scrcpy for real devices, gRPC host-encode for emulators).
|
|
944
|
+
const frames = state?.scrcpySession?.video.start() ?? state?.emulatorVideo?.frames();
|
|
945
|
+
if (!frames)
|
|
946
|
+
throw new ValidationError('no active video stream — call connect() first');
|
|
798
947
|
// DeviceAgent.stream() is the platform-neutral Buffer contract; unwrap ScrcpyFrame payloads.
|
|
799
|
-
return
|
|
800
|
-
.pipeThrough(new TransformStream({
|
|
948
|
+
return frames.pipeThrough(new TransformStream({
|
|
801
949
|
transform(frame, controller) { controller.enqueue(frame.payload); },
|
|
802
950
|
}));
|
|
803
951
|
}
|