@aiscene/android 1.3.5 → 1.6.0-cache
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/README.md +3 -7
- package/bin/.yadb-version +1 -0
- package/bin/midscene-android +2 -0
- package/bin/scrcpy-server +0 -0
- package/bin/scrcpy-server.version +1 -0
- package/bin/yadb +0 -0
- package/dist/es/cli.mjs +1872 -0
- package/dist/es/index.mjs +128 -71
- package/dist/es/mcp-server.mjs +73 -71
- package/dist/lib/cli.js +1892 -0
- package/dist/lib/index.js +133 -69
- package/dist/lib/mcp-server.js +73 -70
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/index.d.ts +204 -0
- package/package.json +12 -13
package/dist/lib/mcp-server.js
CHANGED
|
@@ -24,24 +24,26 @@ var __webpack_modules__ = {
|
|
|
24
24
|
return obj;
|
|
25
25
|
}
|
|
26
26
|
const debugScrcpy = (0, _midscene_shared_logger__rspack_import_3.getDebug)('android:scrcpy');
|
|
27
|
+
const warnScrcpy = (0, _midscene_shared_logger__rspack_import_3.getDebug)('android:scrcpy', {
|
|
28
|
+
console: true
|
|
29
|
+
});
|
|
27
30
|
const NAL_TYPE_IDR = 5;
|
|
28
31
|
const NAL_TYPE_SPS = 7;
|
|
29
32
|
const NAL_TYPE_PPS = 8;
|
|
30
33
|
const NAL_TYPE_MASK = 0x1f;
|
|
31
|
-
const START_CODE_4_BYTE = Buffer.from([
|
|
32
|
-
0x00,
|
|
33
|
-
0x00,
|
|
34
|
-
0x00,
|
|
35
|
-
0x01
|
|
36
|
-
]);
|
|
37
34
|
const DEFAULT_MAX_SIZE = 0;
|
|
38
|
-
const DEFAULT_VIDEO_BIT_RATE =
|
|
35
|
+
const DEFAULT_VIDEO_BIT_RATE = 100000000;
|
|
36
|
+
const MAX_VIDEO_BIT_RATE = 100000000;
|
|
39
37
|
const DEFAULT_IDLE_TIMEOUT_MS = 30000;
|
|
40
38
|
const MAX_KEYFRAME_WAIT_MS = 5000;
|
|
41
39
|
const FRESH_FRAME_TIMEOUT_MS = 300;
|
|
42
40
|
const KEYFRAME_POLL_INTERVAL_MS = 200;
|
|
43
41
|
const MAX_SCAN_BYTES = 1000;
|
|
44
42
|
const CONNECTION_WAIT_MS = 1000;
|
|
43
|
+
const BUSY_LOOP_WINDOW_MS = 1000;
|
|
44
|
+
const BUSY_LOOP_MAX_READS = 500;
|
|
45
|
+
const BUSY_LOOP_COOLDOWN_MS = 50;
|
|
46
|
+
const BUSY_LOOP_WARN_INTERVAL_MS = 5000;
|
|
45
47
|
const DEFAULT_SCRCPY_CONFIG = {
|
|
46
48
|
enabled: false,
|
|
47
49
|
maxSize: DEFAULT_MAX_SIZE,
|
|
@@ -81,21 +83,21 @@ var __webpack_modules__ = {
|
|
|
81
83
|
try {
|
|
82
84
|
this.isConnecting = true;
|
|
83
85
|
debugScrcpy('Starting scrcpy connection...');
|
|
84
|
-
const { AdbScrcpyClient,
|
|
86
|
+
const { AdbScrcpyClient, AdbScrcpyOptions3_3_3 } = await import("@yume-chan/adb-scrcpy");
|
|
85
87
|
const { ReadableStream } = await import("@yume-chan/stream-extra");
|
|
86
|
-
const {
|
|
87
|
-
this.h264SearchConfigFn = h264SearchConfiguration;
|
|
88
|
+
const { DefaultServerPath } = await import("@yume-chan/scrcpy");
|
|
88
89
|
const serverBinPath = this.resolveServerBinPath();
|
|
89
90
|
await AdbScrcpyClient.pushServer(this.adb, ReadableStream.from((0, node_fs__rspack_import_0.createReadStream)(serverBinPath)));
|
|
90
|
-
const scrcpyOptions = new
|
|
91
|
+
const scrcpyOptions = new AdbScrcpyOptions3_3_3({
|
|
91
92
|
audio: false,
|
|
92
93
|
control: false,
|
|
93
94
|
maxSize: this.options.maxSize,
|
|
94
95
|
videoBitRate: this.options.videoBitRate,
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
maxFps: 10,
|
|
97
|
+
sendFrameMeta: true,
|
|
98
|
+
videoCodecOptions: 'i-frame-interval=0,bitrate-mode=2'
|
|
97
99
|
});
|
|
98
|
-
this.scrcpyClient = await AdbScrcpyClient.start(this.adb, DefaultServerPath,
|
|
100
|
+
this.scrcpyClient = await AdbScrcpyClient.start(this.adb, DefaultServerPath, scrcpyOptions);
|
|
99
101
|
const videoStreamPromise = this.scrcpyClient.videoStream;
|
|
100
102
|
if (!videoStreamPromise) throw new Error('Scrcpy client did not provide video stream');
|
|
101
103
|
this.videoStream = await videoStreamPromise;
|
|
@@ -118,12 +120,13 @@ var __webpack_modules__ = {
|
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
122
|
resolveServerBinPath() {
|
|
121
|
-
const androidPkgJson = (0, node_module__rspack_import_1.createRequire)(__rslib_import_meta_url__).resolve('@
|
|
123
|
+
const androidPkgJson = (0, node_module__rspack_import_1.createRequire)(__rslib_import_meta_url__).resolve('@aiscene/android/package.json');
|
|
122
124
|
return node_path__rspack_import_2_default().join(node_path__rspack_import_2_default().dirname(androidPkgJson), 'bin', 'scrcpy-server');
|
|
123
125
|
}
|
|
124
126
|
getFfmpegPath() {
|
|
125
127
|
try {
|
|
126
|
-
const
|
|
128
|
+
const dynamicRequire = (0, node_module__rspack_import_1.createRequire)(__rslib_import_meta_url__);
|
|
129
|
+
const ffmpegInstaller = dynamicRequire('@ffmpeg-installer/ffmpeg');
|
|
127
130
|
debugScrcpy(`Using ffmpeg from npm package: ${ffmpegInstaller.path}`);
|
|
128
131
|
return ffmpegInstaller.path;
|
|
129
132
|
} catch (error) {
|
|
@@ -138,22 +141,47 @@ var __webpack_modules__ = {
|
|
|
138
141
|
this.consumeFramesLoop(reader);
|
|
139
142
|
}
|
|
140
143
|
async consumeFramesLoop(reader) {
|
|
144
|
+
let readCount = 0;
|
|
145
|
+
let windowStart = Date.now();
|
|
146
|
+
let lastBusyWarn = 0;
|
|
147
|
+
let totalReads = 0;
|
|
141
148
|
try {
|
|
142
149
|
while(true){
|
|
143
150
|
const { done, value } = await reader.read();
|
|
144
151
|
if (done) break;
|
|
152
|
+
totalReads++;
|
|
153
|
+
readCount++;
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const elapsed = now - windowStart;
|
|
156
|
+
if (elapsed >= BUSY_LOOP_WINDOW_MS) {
|
|
157
|
+
const readsPerSec = readCount / elapsed * 1000;
|
|
158
|
+
if (readCount > BUSY_LOOP_MAX_READS) {
|
|
159
|
+
if (now - lastBusyWarn >= BUSY_LOOP_WARN_INTERVAL_MS) {
|
|
160
|
+
warnScrcpy(`[CPU-DIAG] Possible busy loop detected! ${readCount} reads in ${elapsed}ms (${readsPerSec.toFixed(0)} reads/sec). Total reads: ${totalReads}. Throttling with ${BUSY_LOOP_COOLDOWN_MS}ms delay.`);
|
|
161
|
+
lastBusyWarn = now;
|
|
162
|
+
}
|
|
163
|
+
await new Promise((resolve)=>setTimeout(resolve, BUSY_LOOP_COOLDOWN_MS));
|
|
164
|
+
} else debugScrcpy(`[CPU-DIAG] Frame loop stats: ${readCount} reads in ${elapsed}ms (${readsPerSec.toFixed(1)} reads/sec), total: ${totalReads}`);
|
|
165
|
+
readCount = 0;
|
|
166
|
+
windowStart = Date.now();
|
|
167
|
+
}
|
|
145
168
|
this.processFrame(value);
|
|
146
169
|
}
|
|
147
170
|
} catch (error) {
|
|
148
|
-
debugScrcpy(`Frame consumer error: ${error}`);
|
|
171
|
+
debugScrcpy(`Frame consumer error (total reads: ${totalReads}): ${error}`);
|
|
149
172
|
await this.disconnect();
|
|
150
173
|
}
|
|
174
|
+
debugScrcpy(`Frame consumer loop ended normally (total reads: ${totalReads})`);
|
|
151
175
|
}
|
|
152
176
|
processFrame(packet) {
|
|
177
|
+
if ('configuration' === packet.type) {
|
|
178
|
+
this.spsHeader = Buffer.from(packet.data);
|
|
179
|
+
debugScrcpy(`Received SPS/PPS configuration: ${this.spsHeader.length}B`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
153
182
|
const frameBuffer = Buffer.from(packet.data);
|
|
154
|
-
const
|
|
155
|
-
if (
|
|
156
|
-
if (actualKeyFrame && this.spsHeader) {
|
|
183
|
+
const isKeyFrame = detectH264KeyFrame(frameBuffer);
|
|
184
|
+
if (isKeyFrame && this.spsHeader) {
|
|
157
185
|
this.lastRawKeyframe = frameBuffer;
|
|
158
186
|
if (this.keyframeResolvers.length > 0) {
|
|
159
187
|
const combined = Buffer.concat([
|
|
@@ -164,23 +192,7 @@ var __webpack_modules__ = {
|
|
|
164
192
|
}
|
|
165
193
|
}
|
|
166
194
|
}
|
|
167
|
-
|
|
168
|
-
if (!this.h264SearchConfigFn) return;
|
|
169
|
-
try {
|
|
170
|
-
const config = this.h264SearchConfigFn(new Uint8Array(frameBuffer));
|
|
171
|
-
if (!config.sequenceParameterSet || !config.pictureParameterSet) return;
|
|
172
|
-
this.spsHeader = Buffer.concat([
|
|
173
|
-
START_CODE_4_BYTE,
|
|
174
|
-
Buffer.from(config.sequenceParameterSet),
|
|
175
|
-
START_CODE_4_BYTE,
|
|
176
|
-
Buffer.from(config.pictureParameterSet)
|
|
177
|
-
]);
|
|
178
|
-
debugScrcpy(`Extracted SPS/PPS: SPS=${config.sequenceParameterSet.length}B, PPS=${config.pictureParameterSet.length}B, total=${this.spsHeader.length}B`);
|
|
179
|
-
} catch (error) {
|
|
180
|
-
debugScrcpy(`Failed to extract SPS/PPS from keyframe: ${error}`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
async getScreenshotPng() {
|
|
195
|
+
async getScreenshotJpeg() {
|
|
184
196
|
const perfStart = Date.now();
|
|
185
197
|
const t1 = Date.now();
|
|
186
198
|
await this.ensureConnected();
|
|
@@ -210,7 +222,7 @@ var __webpack_modules__ = {
|
|
|
210
222
|
this.resetIdleTimer();
|
|
211
223
|
debugScrcpy(`Decoding H.264 stream: ${keyframeBuffer.length} bytes (${frameSource})`);
|
|
212
224
|
const t4 = Date.now();
|
|
213
|
-
const result = await this.
|
|
225
|
+
const result = await this.decodeH264ToJpeg(keyframeBuffer);
|
|
214
226
|
const decodeTime = Date.now() - t4;
|
|
215
227
|
const totalTime = Date.now() - perfStart;
|
|
216
228
|
debugScrcpy(`Performance: total=${totalTime}ms (connect=${connectTime}ms, spsWait=${spsWaitTime}ms, frameWait=${frameWaitTime}ms[${frameSource}], decode=${decodeTime}ms)`);
|
|
@@ -273,7 +285,7 @@ var __webpack_modules__ = {
|
|
|
273
285
|
return false;
|
|
274
286
|
}
|
|
275
287
|
}
|
|
276
|
-
async
|
|
288
|
+
async decodeH264ToJpeg(h264Buffer) {
|
|
277
289
|
const { spawn } = await import("node:child_process");
|
|
278
290
|
return new Promise((resolve, reject)=>{
|
|
279
291
|
const ffmpegArgs = [
|
|
@@ -286,7 +298,9 @@ var __webpack_modules__ = {
|
|
|
286
298
|
'-f',
|
|
287
299
|
'image2pipe',
|
|
288
300
|
'-vcodec',
|
|
289
|
-
'
|
|
301
|
+
'mjpeg',
|
|
302
|
+
'-q:v',
|
|
303
|
+
'5',
|
|
290
304
|
'-loglevel',
|
|
291
305
|
'error',
|
|
292
306
|
'pipe:1'
|
|
@@ -309,13 +323,13 @@ var __webpack_modules__ = {
|
|
|
309
323
|
});
|
|
310
324
|
ffmpeg.on('close', (code)=>{
|
|
311
325
|
if (0 === code && chunks.length > 0) {
|
|
312
|
-
const
|
|
313
|
-
debugScrcpy(`FFmpeg decode successful,
|
|
314
|
-
resolve(
|
|
326
|
+
const jpegBuffer = Buffer.concat(chunks);
|
|
327
|
+
debugScrcpy(`FFmpeg decode successful, JPEG size: ${jpegBuffer.length} bytes`);
|
|
328
|
+
resolve(jpegBuffer);
|
|
315
329
|
} else {
|
|
316
330
|
const errorMsg = stderrOutput || `FFmpeg exited with code ${code}`;
|
|
317
331
|
debugScrcpy(`FFmpeg decode failed: ${errorMsg}`);
|
|
318
|
-
reject(new Error(`H.264 to
|
|
332
|
+
reject(new Error(`H.264 to JPEG decode failed: ${errorMsg}`));
|
|
319
333
|
}
|
|
320
334
|
});
|
|
321
335
|
ffmpeg.on('error', (error)=>{
|
|
@@ -347,7 +361,6 @@ var __webpack_modules__ = {
|
|
|
347
361
|
this.spsHeader = null;
|
|
348
362
|
this.lastRawKeyframe = null;
|
|
349
363
|
this.isInitialized = false;
|
|
350
|
-
this.h264SearchConfigFn = null;
|
|
351
364
|
this.keyframeResolvers = [];
|
|
352
365
|
if (reader) try {
|
|
353
366
|
reader.cancel();
|
|
@@ -375,20 +388,19 @@ var __webpack_modules__ = {
|
|
|
375
388
|
_define_property(this, "keyframeResolvers", []);
|
|
376
389
|
_define_property(this, "lastRawKeyframe", null);
|
|
377
390
|
_define_property(this, "videoResolution", null);
|
|
378
|
-
_define_property(this, "h264SearchConfigFn", null);
|
|
379
391
|
_define_property(this, "streamReader", null);
|
|
380
392
|
this.adb = adb;
|
|
393
|
+
const requestedBitRate = options.videoBitRate ?? DEFAULT_VIDEO_BIT_RATE;
|
|
394
|
+
const clampedBitRate = Math.min(requestedBitRate, MAX_VIDEO_BIT_RATE);
|
|
395
|
+
if (requestedBitRate > MAX_VIDEO_BIT_RATE) warnScrcpy(`videoBitRate ${requestedBitRate} exceeds maximum ${MAX_VIDEO_BIT_RATE}, clamped to ${clampedBitRate}`);
|
|
381
396
|
this.options = {
|
|
382
397
|
maxSize: options.maxSize ?? DEFAULT_MAX_SIZE,
|
|
383
|
-
videoBitRate:
|
|
398
|
+
videoBitRate: clampedBitRate,
|
|
384
399
|
idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
|
|
385
400
|
};
|
|
386
401
|
}
|
|
387
402
|
}
|
|
388
403
|
},
|
|
389
|
-
"@ffmpeg-installer/ffmpeg" (module) {
|
|
390
|
-
module.exports = require("@ffmpeg-installer/ffmpeg");
|
|
391
|
-
},
|
|
392
404
|
"@midscene/shared/logger" (module) {
|
|
393
405
|
module.exports = require("@midscene/shared/logger");
|
|
394
406
|
},
|
|
@@ -592,18 +604,13 @@ var __webpack_exports__ = {};
|
|
|
592
604
|
resolveConfig(deviceInfo) {
|
|
593
605
|
if (this.resolvedConfig) return this.resolvedConfig;
|
|
594
606
|
const config = this.scrcpyConfig;
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
const physicalMax = Math.max(deviceInfo.physicalWidth, deviceInfo.physicalHeight);
|
|
598
|
-
const scale = this.screenshotResizeScale ?? 1 / deviceInfo.dpr;
|
|
599
|
-
maxSize = Math.round(physicalMax * scale);
|
|
600
|
-
debugAdapter(`Auto-calculated maxSize: ${maxSize} (physical=${physicalMax}, scale=${scale.toFixed(3)}, ${void 0 !== this.screenshotResizeScale ? 'from screenshotResizeScale' : 'from 1/dpr'})`);
|
|
601
|
-
}
|
|
607
|
+
const maxSize = config?.maxSize ?? scrcpy_manager.o.maxSize;
|
|
608
|
+
const videoBitRate = config?.videoBitRate ?? scrcpy_manager.o.videoBitRate;
|
|
602
609
|
this.resolvedConfig = {
|
|
603
610
|
enabled: this.isEnabled(),
|
|
604
611
|
maxSize,
|
|
605
612
|
idleTimeoutMs: config?.idleTimeoutMs ?? scrcpy_manager.o.idleTimeoutMs,
|
|
606
|
-
videoBitRate
|
|
613
|
+
videoBitRate
|
|
607
614
|
};
|
|
608
615
|
return this.resolvedConfig;
|
|
609
616
|
}
|
|
@@ -638,8 +645,8 @@ var __webpack_exports__ = {};
|
|
|
638
645
|
}
|
|
639
646
|
async screenshotBase64(deviceInfo) {
|
|
640
647
|
const manager = await this.ensureManager(deviceInfo);
|
|
641
|
-
const screenshotBuffer = await manager.
|
|
642
|
-
return (0, img_namespaceObject.createImgBase64ByFormat)('
|
|
648
|
+
const screenshotBuffer = await manager.getScreenshotJpeg();
|
|
649
|
+
return (0, img_namespaceObject.createImgBase64ByFormat)('jpeg', screenshotBuffer.toString('base64'));
|
|
643
650
|
}
|
|
644
651
|
getResolution() {
|
|
645
652
|
return this.manager?.getResolution() ?? null;
|
|
@@ -650,8 +657,7 @@ var __webpack_exports__ = {};
|
|
|
650
657
|
debugAdapter(`Using scrcpy resolution: ${resolution.width}x${resolution.height}`);
|
|
651
658
|
return {
|
|
652
659
|
width: resolution.width,
|
|
653
|
-
height: resolution.height
|
|
654
|
-
dpr: deviceInfo.dpr
|
|
660
|
+
height: resolution.height
|
|
655
661
|
};
|
|
656
662
|
}
|
|
657
663
|
getScalingRatio(physicalWidth) {
|
|
@@ -670,16 +676,14 @@ var __webpack_exports__ = {};
|
|
|
670
676
|
}
|
|
671
677
|
this.resolvedConfig = null;
|
|
672
678
|
}
|
|
673
|
-
constructor(deviceId, scrcpyConfig
|
|
679
|
+
constructor(deviceId, scrcpyConfig){
|
|
674
680
|
_define_property(this, "deviceId", void 0);
|
|
675
681
|
_define_property(this, "scrcpyConfig", void 0);
|
|
676
|
-
_define_property(this, "screenshotResizeScale", void 0);
|
|
677
682
|
_define_property(this, "manager", void 0);
|
|
678
683
|
_define_property(this, "resolvedConfig", void 0);
|
|
679
684
|
_define_property(this, "initFailed", void 0);
|
|
680
685
|
this.deviceId = deviceId;
|
|
681
686
|
this.scrcpyConfig = scrcpyConfig;
|
|
682
|
-
this.screenshotResizeScale = screenshotResizeScale;
|
|
683
687
|
this.manager = null;
|
|
684
688
|
this.resolvedConfig = null;
|
|
685
689
|
this.initFailed = false;
|
|
@@ -906,7 +910,7 @@ ${Object.keys(size).filter((key)=>size[key]).map((key)=>` ${key} size: ${size[k
|
|
|
906
910
|
});
|
|
907
911
|
}
|
|
908
912
|
getScrcpyAdapter() {
|
|
909
|
-
if (!this.scrcpyAdapter) this.scrcpyAdapter = new ScrcpyDeviceAdapter(this.deviceId, this.options?.scrcpyConfig
|
|
913
|
+
if (!this.scrcpyAdapter) this.scrcpyAdapter = new ScrcpyDeviceAdapter(this.deviceId, this.options?.scrcpyConfig);
|
|
910
914
|
return this.scrcpyAdapter;
|
|
911
915
|
}
|
|
912
916
|
async getDevicePhysicalInfo() {
|
|
@@ -1152,8 +1156,7 @@ ${Object.keys(size).filter((key)=>size[key]).map((key)=>` ${key} size: ${size[k
|
|
|
1152
1156
|
const logicalHeight = Math.round(height * scale);
|
|
1153
1157
|
return {
|
|
1154
1158
|
width: logicalWidth,
|
|
1155
|
-
height: logicalHeight
|
|
1156
|
-
dpr: this.devicePixelRatio
|
|
1159
|
+
height: logicalHeight
|
|
1157
1160
|
};
|
|
1158
1161
|
}
|
|
1159
1162
|
async cacheFeatureForPoint(center, options) {
|
|
@@ -1439,7 +1442,7 @@ ${Object.keys(size).filter((key)=>size[key]).map((key)=>` ${key} size: ${size[k
|
|
|
1439
1442
|
async ensureYadb() {
|
|
1440
1443
|
if (!this.yadbPushed) {
|
|
1441
1444
|
const adb = await this.getAdb();
|
|
1442
|
-
const androidPkgJson = (0, external_node_module_.createRequire)(__rslib_import_meta_url__).resolve('@
|
|
1445
|
+
const androidPkgJson = (0, external_node_module_.createRequire)(__rslib_import_meta_url__).resolve('@aiscene/android/package.json');
|
|
1443
1446
|
const yadbBin = external_node_path_default().join(external_node_path_default().dirname(androidPkgJson), 'bin', 'yadb');
|
|
1444
1447
|
await adb.push(yadbBin, '/data/local/tmp');
|
|
1445
1448
|
this.yadbPushed = true;
|
|
@@ -1896,7 +1899,7 @@ ${Object.keys(size).filter((key)=>size[key]).map((key)=>` ${key} size: ${size[k
|
|
|
1896
1899
|
constructor(toolsManager){
|
|
1897
1900
|
super({
|
|
1898
1901
|
name: '@midscene/android-mcp',
|
|
1899
|
-
version:
|
|
1902
|
+
version: "1.6.0-cache",
|
|
1900
1903
|
description: 'Control the Android device using natural language commands'
|
|
1901
1904
|
}, toolsManager);
|
|
1902
1905
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { }
|
package/dist/types/index.d.ts
CHANGED
|
@@ -2,10 +2,12 @@ import { AbstractInterface } from '@midscene/core/device';
|
|
|
2
2
|
import type { ActionParam } from '@midscene/core';
|
|
3
3
|
import type { ActionReturn } from '@midscene/core';
|
|
4
4
|
import { ADB } from 'appium-adb';
|
|
5
|
+
import type { Adb } from '@yume-chan/adb';
|
|
5
6
|
import { Agent } from '@midscene/core/agent';
|
|
6
7
|
import { AgentOpt } from '@midscene/core/agent';
|
|
7
8
|
import { AndroidDeviceInputOpt } from '@midscene/core/device';
|
|
8
9
|
import { AndroidDeviceOpt } from '@midscene/core/device';
|
|
10
|
+
import { BaseMidsceneTools } from '@midscene/shared/mcp';
|
|
9
11
|
import { Device } from 'appium-adb';
|
|
10
12
|
import { DeviceAction } from '@midscene/core';
|
|
11
13
|
import { ElementCacheFeature } from '@midscene/core';
|
|
@@ -16,6 +18,7 @@ import { overrideAIConfig } from '@midscene/shared/env';
|
|
|
16
18
|
import { Point } from '@midscene/core';
|
|
17
19
|
import { Rect } from '@midscene/core';
|
|
18
20
|
import { Size } from '@midscene/core';
|
|
21
|
+
import { ToolDefinition } from '@midscene/shared/mcp';
|
|
19
22
|
|
|
20
23
|
declare type ActionArgs<T extends DeviceAction> = [ActionParam<T>] extends [undefined] ? [] : [ActionParam<T>];
|
|
21
24
|
|
|
@@ -207,16 +210,217 @@ export declare class AndroidDevice implements AbstractInterface {
|
|
|
207
210
|
hideKeyboard(options?: AndroidDeviceInputOpt, timeoutMs?: number): Promise<boolean>;
|
|
208
211
|
}
|
|
209
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Android-specific tools manager
|
|
215
|
+
* Extends BaseMidsceneTools to provide Android ADB device connection tools
|
|
216
|
+
*/
|
|
217
|
+
export declare class AndroidMidsceneTools extends BaseMidsceneTools<AndroidAgent> {
|
|
218
|
+
protected createTemporaryDevice(): AndroidDevice;
|
|
219
|
+
protected ensureAgent(deviceId?: string): Promise<AndroidAgent>;
|
|
220
|
+
/**
|
|
221
|
+
* Provide Android-specific platform tools
|
|
222
|
+
*/
|
|
223
|
+
protected preparePlatformTools(): ToolDefinition[];
|
|
224
|
+
}
|
|
225
|
+
|
|
210
226
|
declare type DeviceActionAndroidBackButton = DeviceAction<undefined, void>;
|
|
211
227
|
|
|
212
228
|
declare type DeviceActionAndroidHomeButton = DeviceAction<undefined, void>;
|
|
213
229
|
|
|
214
230
|
declare type DeviceActionAndroidRecentAppsButton = DeviceAction<undefined, void>;
|
|
215
231
|
|
|
232
|
+
declare interface DevicePhysicalInfo {
|
|
233
|
+
physicalWidth: number;
|
|
234
|
+
physicalHeight: number;
|
|
235
|
+
dpr: number;
|
|
236
|
+
orientation: number;
|
|
237
|
+
isCurrentOrientation?: boolean;
|
|
238
|
+
}
|
|
239
|
+
|
|
216
240
|
export declare function getConnectedDevices(): Promise<Device[]>;
|
|
217
241
|
|
|
218
242
|
export { overrideAIConfig }
|
|
219
243
|
|
|
244
|
+
declare interface ResolvedScrcpyConfig {
|
|
245
|
+
enabled: boolean;
|
|
246
|
+
maxSize: number;
|
|
247
|
+
videoBitRate: number;
|
|
248
|
+
idleTimeoutMs: number;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
declare interface ScrcpyConfig {
|
|
252
|
+
enabled?: boolean;
|
|
253
|
+
maxSize?: number;
|
|
254
|
+
videoBitRate?: number;
|
|
255
|
+
idleTimeoutMs?: number;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Adapter that encapsulates all scrcpy-related logic for AndroidDevice.
|
|
260
|
+
* Handles config normalization, manager lifecycle, screenshot, and resolution.
|
|
261
|
+
*/
|
|
262
|
+
export declare class ScrcpyDeviceAdapter {
|
|
263
|
+
private deviceId;
|
|
264
|
+
private scrcpyConfig;
|
|
265
|
+
private manager;
|
|
266
|
+
private resolvedConfig;
|
|
267
|
+
private initFailed;
|
|
268
|
+
constructor(deviceId: string, scrcpyConfig: ScrcpyConfig | undefined);
|
|
269
|
+
isEnabled(): boolean;
|
|
270
|
+
/**
|
|
271
|
+
* Initialize scrcpy connection. Called once during device.connect().
|
|
272
|
+
* If initialization fails, marks scrcpy as permanently disabled (no further retries).
|
|
273
|
+
*/
|
|
274
|
+
initialize(deviceInfo: DevicePhysicalInfo): Promise<void>;
|
|
275
|
+
/**
|
|
276
|
+
* Resolve scrcpy config.
|
|
277
|
+
* maxSize defaults to 0 (no scaling, full physical resolution) so the Agent layer
|
|
278
|
+
* receives the highest quality image for AI processing.
|
|
279
|
+
* videoBitRate is auto-scaled based on physical pixel count to ensure
|
|
280
|
+
* sufficient quality for all-I-frame H.264 encoding.
|
|
281
|
+
*/
|
|
282
|
+
resolveConfig(deviceInfo: DevicePhysicalInfo): ResolvedScrcpyConfig;
|
|
283
|
+
/**
|
|
284
|
+
* Get or create the ScrcpyScreenshotManager.
|
|
285
|
+
* Uses dynamic import for @yume-chan packages (ESM-only, must use await import in CJS builds).
|
|
286
|
+
*/
|
|
287
|
+
ensureManager(deviceInfo: DevicePhysicalInfo): Promise<ScrcpyScreenshotManager>;
|
|
288
|
+
/**
|
|
289
|
+
* Take a screenshot via scrcpy, returns base64 string.
|
|
290
|
+
* Throws on failure (caller should fallback to ADB).
|
|
291
|
+
*/
|
|
292
|
+
screenshotBase64(deviceInfo: DevicePhysicalInfo): Promise<string>;
|
|
293
|
+
/**
|
|
294
|
+
* Get scrcpy's actual video resolution.
|
|
295
|
+
* Returns null if scrcpy is not connected yet.
|
|
296
|
+
*/
|
|
297
|
+
getResolution(): {
|
|
298
|
+
width: number;
|
|
299
|
+
height: number;
|
|
300
|
+
} | null;
|
|
301
|
+
/**
|
|
302
|
+
* Compute size from scrcpy resolution.
|
|
303
|
+
* Returns null if scrcpy is not connected.
|
|
304
|
+
*/
|
|
305
|
+
getSize(deviceInfo: DevicePhysicalInfo): Size | null;
|
|
306
|
+
/**
|
|
307
|
+
* Calculate the scaling ratio from physical to scrcpy resolution.
|
|
308
|
+
*/
|
|
309
|
+
getScalingRatio(physicalWidth: number): number | null;
|
|
310
|
+
disconnect(): Promise<void>;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
declare class ScrcpyScreenshotManager {
|
|
314
|
+
private adb;
|
|
315
|
+
private scrcpyClient;
|
|
316
|
+
private videoStream;
|
|
317
|
+
private spsHeader;
|
|
318
|
+
private idleTimer;
|
|
319
|
+
private isConnecting;
|
|
320
|
+
private isInitialized;
|
|
321
|
+
private options;
|
|
322
|
+
private ffmpegAvailable;
|
|
323
|
+
private keyframeResolvers;
|
|
324
|
+
private lastRawKeyframe;
|
|
325
|
+
private videoResolution;
|
|
326
|
+
private streamReader;
|
|
327
|
+
constructor(adb: Adb, options?: ScrcpyScreenshotOptions);
|
|
328
|
+
/**
|
|
329
|
+
* Validate environment prerequisites (ffmpeg, scrcpy-server, etc.)
|
|
330
|
+
* Must be called once after construction, before any screenshot operations.
|
|
331
|
+
* Throws if prerequisites are not met.
|
|
332
|
+
*/
|
|
333
|
+
validateEnvironment(): Promise<void>;
|
|
334
|
+
/**
|
|
335
|
+
* Ensure scrcpy connection is active
|
|
336
|
+
*/
|
|
337
|
+
ensureConnected(): Promise<void>;
|
|
338
|
+
/**
|
|
339
|
+
* Resolve path to scrcpy server binary
|
|
340
|
+
*/
|
|
341
|
+
private resolveServerBinPath;
|
|
342
|
+
/**
|
|
343
|
+
* Get ffmpeg executable path
|
|
344
|
+
* Priority: @ffmpeg-installer/ffmpeg > system ffmpeg
|
|
345
|
+
*/
|
|
346
|
+
private getFfmpegPath;
|
|
347
|
+
/**
|
|
348
|
+
* Consume video frames and keep latest frame
|
|
349
|
+
*/
|
|
350
|
+
private startFrameConsumer;
|
|
351
|
+
/**
|
|
352
|
+
* Main frame consumption loop
|
|
353
|
+
* Includes busy-loop detection: if reader.read() resolves too fast
|
|
354
|
+
* (e.g. broken stream returning immediately), we throttle to prevent 100% CPU.
|
|
355
|
+
*/
|
|
356
|
+
private consumeFramesLoop;
|
|
357
|
+
/**
|
|
358
|
+
* Process a single video packet from the scrcpy stream.
|
|
359
|
+
* With sendFrameMeta: true, the stream emits properly framed packets:
|
|
360
|
+
* - "configuration" packets contain SPS/PPS header data
|
|
361
|
+
* - "data" packets contain complete video frames with correct boundaries
|
|
362
|
+
* This avoids the frame-splitting issue that occurs with sendFrameMeta: false
|
|
363
|
+
* at high resolutions where raw chunks may not align with frame boundaries.
|
|
364
|
+
*/
|
|
365
|
+
private processFrame;
|
|
366
|
+
/**
|
|
367
|
+
* Get screenshot as JPEG.
|
|
368
|
+
* Tries to get a fresh frame within a short timeout. If the screen is static
|
|
369
|
+
* (no new frames arrive), falls back to the latest cached keyframe.
|
|
370
|
+
*/
|
|
371
|
+
getScreenshotJpeg(): Promise<Buffer>;
|
|
372
|
+
/**
|
|
373
|
+
* Get the actual video stream resolution
|
|
374
|
+
* Returns null if scrcpy is not connected yet
|
|
375
|
+
*/
|
|
376
|
+
getResolution(): {
|
|
377
|
+
width: number;
|
|
378
|
+
height: number;
|
|
379
|
+
} | null;
|
|
380
|
+
/**
|
|
381
|
+
* Notify all pending keyframe waiters
|
|
382
|
+
*/
|
|
383
|
+
private notifyKeyframeWaiters;
|
|
384
|
+
/**
|
|
385
|
+
* Wait for the next keyframe to arrive
|
|
386
|
+
*/
|
|
387
|
+
private waitForNextKeyframe;
|
|
388
|
+
/**
|
|
389
|
+
* Ensure ffmpeg is available for PNG conversion
|
|
390
|
+
*/
|
|
391
|
+
private ensureFfmpegAvailable;
|
|
392
|
+
/**
|
|
393
|
+
* Wait for first keyframe with SPS/PPS header
|
|
394
|
+
*/
|
|
395
|
+
private waitForKeyframe;
|
|
396
|
+
/**
|
|
397
|
+
* Check if ffmpeg is available in the system
|
|
398
|
+
*/
|
|
399
|
+
private checkFfmpegAvailable;
|
|
400
|
+
/**
|
|
401
|
+
* Decode H.264 data to JPEG using ffmpeg
|
|
402
|
+
*/
|
|
403
|
+
private decodeH264ToJpeg;
|
|
404
|
+
/**
|
|
405
|
+
* Reset idle timeout timer
|
|
406
|
+
*/
|
|
407
|
+
private resetIdleTimer;
|
|
408
|
+
/**
|
|
409
|
+
* Disconnect scrcpy
|
|
410
|
+
*/
|
|
411
|
+
disconnect(): Promise<void>;
|
|
412
|
+
/**
|
|
413
|
+
* Check if scrcpy is initialized and connected
|
|
414
|
+
*/
|
|
415
|
+
isConnected(): boolean;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
declare interface ScrcpyScreenshotOptions {
|
|
419
|
+
maxSize?: number;
|
|
420
|
+
videoBitRate?: number;
|
|
421
|
+
idleTimeoutMs?: number;
|
|
422
|
+
}
|
|
423
|
+
|
|
220
424
|
/**
|
|
221
425
|
* Helper type to convert DeviceAction to wrapped method signature
|
|
222
426
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiscene/android",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0-cache",
|
|
4
4
|
"description": "Android automation library for Midscene",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Android UI automation",
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"main": "./dist/lib/index.js",
|
|
13
13
|
"module": "./dist/es/index.mjs",
|
|
14
14
|
"types": "./dist/types/index.d.ts",
|
|
15
|
+
"bin": {
|
|
16
|
+
"midscene-android": "./bin/midscene-android"
|
|
17
|
+
},
|
|
15
18
|
"files": ["bin", "dist", "README.md"],
|
|
16
19
|
"exports": {
|
|
17
20
|
".": {
|
|
@@ -39,13 +42,13 @@
|
|
|
39
42
|
"test:ai:cache": "MIDSCENE_CACHE=true AI_TEST_TYPE=android npm run test"
|
|
40
43
|
},
|
|
41
44
|
"dependencies": {
|
|
42
|
-
"@midscene/core": "
|
|
43
|
-
"@midscene/shared": "
|
|
44
|
-
"@yume-chan/adb": "
|
|
45
|
-
"@yume-chan/adb-scrcpy": "
|
|
46
|
-
"@yume-chan/adb-server-node-tcp": "
|
|
47
|
-
"@yume-chan/scrcpy": "
|
|
48
|
-
"@yume-chan/stream-extra": "
|
|
45
|
+
"@midscene/core": "workspace:*",
|
|
46
|
+
"@midscene/shared": "workspace:*",
|
|
47
|
+
"@yume-chan/adb": "2.5.1",
|
|
48
|
+
"@yume-chan/adb-scrcpy": "2.3.2",
|
|
49
|
+
"@yume-chan/adb-server-node-tcp": "2.5.2",
|
|
50
|
+
"@yume-chan/scrcpy": "2.3.0",
|
|
51
|
+
"@yume-chan/stream-extra": "2.1.0",
|
|
49
52
|
"appium-adb": "12.12.1",
|
|
50
53
|
"sharp": "^0.34.3"
|
|
51
54
|
},
|
|
@@ -63,9 +66,5 @@
|
|
|
63
66
|
"vitest": "3.0.5",
|
|
64
67
|
"zod": "3.24.3"
|
|
65
68
|
},
|
|
66
|
-
"license": "MIT"
|
|
67
|
-
"publishConfig": {
|
|
68
|
-
"access": "public",
|
|
69
|
-
"registry": "https://registry.npmjs.org"
|
|
70
|
-
}
|
|
69
|
+
"license": "MIT"
|
|
71
70
|
}
|