@ph-qa/midscene-android 1.6.1
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 +5 -0
- 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 +2087 -0
- package/dist/es/index.mjs +2146 -0
- package/dist/es/mcp-server.mjs +2109 -0
- package/dist/lib/cli.js +2107 -0
- package/dist/lib/index.js +2203 -0
- package/dist/lib/mcp-server.js +2151 -0
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/index.d.ts +454 -0
- package/dist/types/mcp-server.d.ts +279 -0
- package/package.json +76 -0
|
@@ -0,0 +1,2146 @@
|
|
|
1
|
+
import * as __rspack_external__midscene_shared_logger_b1dc2426 from "@midscene/shared/logger";
|
|
2
|
+
import * as __rspack_external_node_fs_5ea92f0c from "node:fs";
|
|
3
|
+
import * as __rspack_external_node_module_ab9f2194 from "node:module";
|
|
4
|
+
import * as __rspack_external_node_path_c5b9b54f from "node:path";
|
|
5
|
+
import node_assert from "node:assert";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { request } from "node:http";
|
|
8
|
+
import { createServer } from "node:net";
|
|
9
|
+
import { getMidsceneLocationSchema, z } from "@midscene/core";
|
|
10
|
+
import { defineAction, defineActionClearInput, defineActionCursorMove, defineActionDoubleClick, defineActionDragAndDrop, defineActionKeyboardPress, defineActionPinch, defineActionScroll, defineActionSwipe, defineActionTap, normalizeMobileSwipeParam, normalizePinchParam } from "@midscene/core/device";
|
|
11
|
+
import { getTmpFile, sleep } from "@midscene/core/utils";
|
|
12
|
+
import { MIDSCENE_ADB_PATH, MIDSCENE_ADB_REMOTE_HOST, MIDSCENE_ADB_REMOTE_PORT, MIDSCENE_ANDROID_IME_STRATEGY, globalConfigManager, overrideAIConfig } from "@midscene/shared/env";
|
|
13
|
+
import { createImgBase64ByFormat, isValidImageBuffer } from "@midscene/shared/img";
|
|
14
|
+
import { mergeAndNormalizeAppNameMapping, normalizeForComparison, repeat } from "@midscene/shared/utils";
|
|
15
|
+
import { ADB } from "appium-adb";
|
|
16
|
+
import { Agent } from "@midscene/core/agent";
|
|
17
|
+
import { BaseMidsceneTools } from "@midscene/shared/mcp";
|
|
18
|
+
var __webpack_modules__ = {
|
|
19
|
+
"./src/scrcpy-manager.ts" (__unused_rspack_module, __webpack_exports__, __webpack_require__) {
|
|
20
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
21
|
+
ScrcpyScreenshotManager: ()=>ScrcpyScreenshotManager,
|
|
22
|
+
o: ()=>DEFAULT_SCRCPY_CONFIG
|
|
23
|
+
});
|
|
24
|
+
var node_fs__rspack_import_0 = __webpack_require__("node:fs");
|
|
25
|
+
var node_module__rspack_import_1 = __webpack_require__("node:module");
|
|
26
|
+
var node_path__rspack_import_2 = __webpack_require__("node:path");
|
|
27
|
+
var _midscene_shared_logger__rspack_import_3 = __webpack_require__("@midscene/shared/logger");
|
|
28
|
+
function _define_property(obj, key, value) {
|
|
29
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
30
|
+
value: value,
|
|
31
|
+
enumerable: true,
|
|
32
|
+
configurable: true,
|
|
33
|
+
writable: true
|
|
34
|
+
});
|
|
35
|
+
else obj[key] = value;
|
|
36
|
+
return obj;
|
|
37
|
+
}
|
|
38
|
+
const debugScrcpy = (0, _midscene_shared_logger__rspack_import_3.getDebug)('android:scrcpy');
|
|
39
|
+
const warnScrcpy = (0, _midscene_shared_logger__rspack_import_3.getDebug)('android:scrcpy', {
|
|
40
|
+
console: true
|
|
41
|
+
});
|
|
42
|
+
const NAL_TYPE_IDR = 5;
|
|
43
|
+
const NAL_TYPE_SPS = 7;
|
|
44
|
+
const NAL_TYPE_PPS = 8;
|
|
45
|
+
const NAL_TYPE_MASK = 0x1f;
|
|
46
|
+
const DEFAULT_MAX_SIZE = 0;
|
|
47
|
+
const DEFAULT_VIDEO_BIT_RATE = 100000000;
|
|
48
|
+
const MAX_VIDEO_BIT_RATE = 100000000;
|
|
49
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 30000;
|
|
50
|
+
const MAX_KEYFRAME_WAIT_MS = 5000;
|
|
51
|
+
const FRESH_FRAME_TIMEOUT_MS = 300;
|
|
52
|
+
const KEYFRAME_POLL_INTERVAL_MS = 200;
|
|
53
|
+
const MAX_SCAN_BYTES = 1000;
|
|
54
|
+
const CONNECTION_WAIT_MS = 1000;
|
|
55
|
+
const BUSY_LOOP_WINDOW_MS = 1000;
|
|
56
|
+
const BUSY_LOOP_MAX_READS = 500;
|
|
57
|
+
const BUSY_LOOP_COOLDOWN_MS = 50;
|
|
58
|
+
const BUSY_LOOP_WARN_INTERVAL_MS = 5000;
|
|
59
|
+
const DEFAULT_SCRCPY_CONFIG = {
|
|
60
|
+
enabled: false,
|
|
61
|
+
maxSize: DEFAULT_MAX_SIZE,
|
|
62
|
+
idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
|
|
63
|
+
videoBitRate: DEFAULT_VIDEO_BIT_RATE
|
|
64
|
+
};
|
|
65
|
+
function isKeyFrameNalType(nalUnitType) {
|
|
66
|
+
return nalUnitType === NAL_TYPE_IDR || nalUnitType === NAL_TYPE_SPS || nalUnitType === NAL_TYPE_PPS;
|
|
67
|
+
}
|
|
68
|
+
function detectH264KeyFrame(buffer) {
|
|
69
|
+
const scanLimit = Math.min(buffer.length - 4, MAX_SCAN_BYTES);
|
|
70
|
+
for(let i = 0; i < scanLimit; i++)if (0x00 === buffer[i] && 0x00 === buffer[i + 1] && 0x00 === buffer[i + 2] && 0x01 === buffer[i + 3]) {
|
|
71
|
+
const nalUnitType = buffer[i + 4] & NAL_TYPE_MASK;
|
|
72
|
+
if (isKeyFrameNalType(nalUnitType)) return true;
|
|
73
|
+
} else if (0x00 === buffer[i] && 0x00 === buffer[i + 1] && 0x01 === buffer[i + 2]) {
|
|
74
|
+
const nalUnitType = buffer[i + 3] & NAL_TYPE_MASK;
|
|
75
|
+
if (isKeyFrameNalType(nalUnitType)) return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
class ScrcpyScreenshotManager {
|
|
80
|
+
async validateEnvironment() {
|
|
81
|
+
await this.ensureFfmpegAvailable();
|
|
82
|
+
}
|
|
83
|
+
async ensureConnected() {
|
|
84
|
+
if (this.scrcpyClient && this.videoStream) {
|
|
85
|
+
debugScrcpy('Scrcpy already connected');
|
|
86
|
+
this.resetIdleTimer();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (this.isConnecting) {
|
|
90
|
+
debugScrcpy('Connection already in progress, waiting...');
|
|
91
|
+
await new Promise((resolve)=>setTimeout(resolve, CONNECTION_WAIT_MS));
|
|
92
|
+
if (this.scrcpyClient && this.videoStream) return void this.resetIdleTimer();
|
|
93
|
+
throw new Error('Scrcpy connection failed: another connection attempt did not complete in time');
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
this.isConnecting = true;
|
|
97
|
+
debugScrcpy('Starting scrcpy connection...');
|
|
98
|
+
const { AdbScrcpyClient, AdbScrcpyOptions3_3_3 } = await import("@yume-chan/adb-scrcpy");
|
|
99
|
+
const { ReadableStream } = await import("@yume-chan/stream-extra");
|
|
100
|
+
const { DefaultServerPath } = await import("@yume-chan/scrcpy");
|
|
101
|
+
const serverBinPath = this.resolveServerBinPath();
|
|
102
|
+
await AdbScrcpyClient.pushServer(this.adb, ReadableStream.from((0, node_fs__rspack_import_0.createReadStream)(serverBinPath)));
|
|
103
|
+
const scrcpyOptions = new AdbScrcpyOptions3_3_3({
|
|
104
|
+
audio: false,
|
|
105
|
+
control: false,
|
|
106
|
+
maxSize: this.options.maxSize,
|
|
107
|
+
videoBitRate: this.options.videoBitRate,
|
|
108
|
+
maxFps: 10,
|
|
109
|
+
sendFrameMeta: true,
|
|
110
|
+
videoCodecOptions: 'i-frame-interval=0,bitrate-mode=2'
|
|
111
|
+
});
|
|
112
|
+
this.scrcpyClient = await AdbScrcpyClient.start(this.adb, DefaultServerPath, scrcpyOptions);
|
|
113
|
+
const videoStreamPromise = this.scrcpyClient.videoStream;
|
|
114
|
+
if (!videoStreamPromise) throw new Error('Scrcpy client did not provide video stream');
|
|
115
|
+
this.videoStream = await videoStreamPromise;
|
|
116
|
+
const { width = 0, height = 0 } = this.videoStream.metadata;
|
|
117
|
+
debugScrcpy(`Video stream started: ${width}x${height}`);
|
|
118
|
+
this.videoResolution = {
|
|
119
|
+
width,
|
|
120
|
+
height
|
|
121
|
+
};
|
|
122
|
+
this.startFrameConsumer();
|
|
123
|
+
this.resetIdleTimer();
|
|
124
|
+
this.isInitialized = true;
|
|
125
|
+
debugScrcpy('Scrcpy connection established');
|
|
126
|
+
} catch (error) {
|
|
127
|
+
debugScrcpy(`Failed to connect scrcpy: ${error}`);
|
|
128
|
+
await this.disconnect();
|
|
129
|
+
throw error;
|
|
130
|
+
} finally{
|
|
131
|
+
this.isConnecting = false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
resolveServerBinPath() {
|
|
135
|
+
const androidPkgJson = (0, node_module__rspack_import_1.createRequire)(import.meta.url).resolve('@midscene/android/package.json');
|
|
136
|
+
return node_path__rspack_import_2["default"].join(node_path__rspack_import_2["default"].dirname(androidPkgJson), 'bin', 'scrcpy-server');
|
|
137
|
+
}
|
|
138
|
+
getFfmpegPath() {
|
|
139
|
+
try {
|
|
140
|
+
const dynamicRequire = (0, node_module__rspack_import_1.createRequire)(import.meta.url);
|
|
141
|
+
const ffmpegInstaller = dynamicRequire('@ffmpeg-installer/ffmpeg');
|
|
142
|
+
debugScrcpy(`Using ffmpeg from npm package: ${ffmpegInstaller.path}`);
|
|
143
|
+
return ffmpegInstaller.path;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
debugScrcpy('Using system ffmpeg (npm package not found)');
|
|
146
|
+
return 'ffmpeg';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
startFrameConsumer() {
|
|
150
|
+
if (!this.videoStream) return;
|
|
151
|
+
const reader = this.videoStream.stream.getReader();
|
|
152
|
+
this.streamReader = reader;
|
|
153
|
+
this.consumeFramesLoop(reader);
|
|
154
|
+
}
|
|
155
|
+
async consumeFramesLoop(reader) {
|
|
156
|
+
let readCount = 0;
|
|
157
|
+
let windowStart = Date.now();
|
|
158
|
+
let lastBusyWarn = 0;
|
|
159
|
+
let totalReads = 0;
|
|
160
|
+
try {
|
|
161
|
+
while(true){
|
|
162
|
+
const { done, value } = await reader.read();
|
|
163
|
+
if (done) break;
|
|
164
|
+
totalReads++;
|
|
165
|
+
readCount++;
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
const elapsed = now - windowStart;
|
|
168
|
+
if (elapsed >= BUSY_LOOP_WINDOW_MS) {
|
|
169
|
+
const readsPerSec = readCount / elapsed * 1000;
|
|
170
|
+
if (readCount > BUSY_LOOP_MAX_READS) {
|
|
171
|
+
if (now - lastBusyWarn >= BUSY_LOOP_WARN_INTERVAL_MS) {
|
|
172
|
+
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.`);
|
|
173
|
+
lastBusyWarn = now;
|
|
174
|
+
}
|
|
175
|
+
await new Promise((resolve)=>setTimeout(resolve, BUSY_LOOP_COOLDOWN_MS));
|
|
176
|
+
} else debugScrcpy(`[CPU-DIAG] Frame loop stats: ${readCount} reads in ${elapsed}ms (${readsPerSec.toFixed(1)} reads/sec), total: ${totalReads}`);
|
|
177
|
+
readCount = 0;
|
|
178
|
+
windowStart = Date.now();
|
|
179
|
+
}
|
|
180
|
+
this.processFrame(value);
|
|
181
|
+
}
|
|
182
|
+
} catch (error) {
|
|
183
|
+
debugScrcpy(`Frame consumer error (total reads: ${totalReads}): ${error}`);
|
|
184
|
+
await this.disconnect();
|
|
185
|
+
}
|
|
186
|
+
debugScrcpy(`Frame consumer loop ended normally (total reads: ${totalReads})`);
|
|
187
|
+
}
|
|
188
|
+
processFrame(packet) {
|
|
189
|
+
if ('configuration' === packet.type) {
|
|
190
|
+
this.spsHeader = Buffer.from(packet.data);
|
|
191
|
+
debugScrcpy(`Received SPS/PPS configuration: ${this.spsHeader.length}B`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const frameBuffer = Buffer.from(packet.data);
|
|
195
|
+
const isKeyFrame = detectH264KeyFrame(frameBuffer);
|
|
196
|
+
if (isKeyFrame && this.spsHeader) {
|
|
197
|
+
this.lastRawKeyframe = frameBuffer;
|
|
198
|
+
if (this.keyframeResolvers.length > 0) {
|
|
199
|
+
const combined = Buffer.concat([
|
|
200
|
+
this.spsHeader,
|
|
201
|
+
frameBuffer
|
|
202
|
+
]);
|
|
203
|
+
this.notifyKeyframeWaiters(combined);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async getScreenshotJpeg() {
|
|
208
|
+
const perfStart = Date.now();
|
|
209
|
+
const t1 = Date.now();
|
|
210
|
+
await this.ensureConnected();
|
|
211
|
+
const connectTime = Date.now() - t1;
|
|
212
|
+
const t2 = Date.now();
|
|
213
|
+
await this.waitForKeyframe();
|
|
214
|
+
const spsWaitTime = Date.now() - t2;
|
|
215
|
+
const t3 = Date.now();
|
|
216
|
+
let keyframeBuffer;
|
|
217
|
+
let frameSource;
|
|
218
|
+
try {
|
|
219
|
+
keyframeBuffer = await this.waitForNextKeyframe(FRESH_FRAME_TIMEOUT_MS);
|
|
220
|
+
frameSource = 'fresh';
|
|
221
|
+
} catch {
|
|
222
|
+
if (this.lastRawKeyframe && this.spsHeader) {
|
|
223
|
+
keyframeBuffer = Buffer.concat([
|
|
224
|
+
this.spsHeader,
|
|
225
|
+
this.lastRawKeyframe
|
|
226
|
+
]);
|
|
227
|
+
frameSource = 'cached';
|
|
228
|
+
} else {
|
|
229
|
+
keyframeBuffer = await this.waitForNextKeyframe(MAX_KEYFRAME_WAIT_MS);
|
|
230
|
+
frameSource = 'fresh-retry';
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const frameWaitTime = Date.now() - t3;
|
|
234
|
+
this.resetIdleTimer();
|
|
235
|
+
debugScrcpy(`Decoding H.264 stream: ${keyframeBuffer.length} bytes (${frameSource})`);
|
|
236
|
+
const t4 = Date.now();
|
|
237
|
+
const result = await this.decodeH264ToJpeg(keyframeBuffer);
|
|
238
|
+
const decodeTime = Date.now() - t4;
|
|
239
|
+
const totalTime = Date.now() - perfStart;
|
|
240
|
+
debugScrcpy(`Performance: total=${totalTime}ms (connect=${connectTime}ms, spsWait=${spsWaitTime}ms, frameWait=${frameWaitTime}ms[${frameSource}], decode=${decodeTime}ms)`);
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
getResolution() {
|
|
244
|
+
return this.videoResolution;
|
|
245
|
+
}
|
|
246
|
+
notifyKeyframeWaiters(buf) {
|
|
247
|
+
const resolvers = this.keyframeResolvers;
|
|
248
|
+
this.keyframeResolvers = [];
|
|
249
|
+
for (const resolve of resolvers)resolve(buf);
|
|
250
|
+
}
|
|
251
|
+
waitForNextKeyframe(timeoutMs) {
|
|
252
|
+
return new Promise((resolve, reject)=>{
|
|
253
|
+
const wrappedResolve = (buf)=>{
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
resolve(buf);
|
|
256
|
+
};
|
|
257
|
+
const timer = setTimeout(()=>{
|
|
258
|
+
this.keyframeResolvers = this.keyframeResolvers.filter((r)=>r !== wrappedResolve);
|
|
259
|
+
reject(new Error(`No fresh keyframe received within ${timeoutMs}ms`));
|
|
260
|
+
}, timeoutMs);
|
|
261
|
+
this.keyframeResolvers.push(wrappedResolve);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
async ensureFfmpegAvailable() {
|
|
265
|
+
if (null !== this.ffmpegAvailable) return;
|
|
266
|
+
try {
|
|
267
|
+
this.ffmpegAvailable = await this.checkFfmpegAvailable();
|
|
268
|
+
if (!this.ffmpegAvailable) debugScrcpy("Warning: ffmpeg is not available. Scrcpy screenshot will be disabled.\nTo enable high-performance screenshots:\n 1. Install optional dependency: pnpm add -D @ffmpeg-installer/ffmpeg\n 2. Or install system ffmpeg: https://ffmpeg.org");
|
|
269
|
+
} catch (error) {
|
|
270
|
+
this.ffmpegAvailable = false;
|
|
271
|
+
debugScrcpy(`Error checking ffmpeg availability: ${error}`);
|
|
272
|
+
}
|
|
273
|
+
if (!this.ffmpegAvailable) throw new Error('ffmpeg is not available, please use standard ADB screenshot mode');
|
|
274
|
+
}
|
|
275
|
+
async waitForKeyframe() {
|
|
276
|
+
const startTime = Date.now();
|
|
277
|
+
while(!this.spsHeader && Date.now() - startTime < MAX_KEYFRAME_WAIT_MS){
|
|
278
|
+
const elapsed = Date.now() - startTime;
|
|
279
|
+
debugScrcpy(`Waiting for first keyframe (SPS/PPS header)... ${elapsed}ms`);
|
|
280
|
+
await new Promise((resolve)=>setTimeout(resolve, KEYFRAME_POLL_INTERVAL_MS));
|
|
281
|
+
}
|
|
282
|
+
if (!this.spsHeader) throw new Error(`No keyframe received within ${MAX_KEYFRAME_WAIT_MS}ms. Device may have a long GOP interval or video encoding issues. Please retry.`);
|
|
283
|
+
}
|
|
284
|
+
async checkFfmpegAvailable() {
|
|
285
|
+
const { execFile } = await import("node:child_process");
|
|
286
|
+
const { promisify } = await import("node:util");
|
|
287
|
+
const execFileAsync = promisify(execFile);
|
|
288
|
+
try {
|
|
289
|
+
const ffmpegPath = this.getFfmpegPath();
|
|
290
|
+
await execFileAsync(ffmpegPath, [
|
|
291
|
+
'-version'
|
|
292
|
+
]);
|
|
293
|
+
debugScrcpy(`ffmpeg is available at: ${ffmpegPath}`);
|
|
294
|
+
return true;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
debugScrcpy(`ffmpeg is not available: ${error}`);
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async decodeH264ToJpeg(h264Buffer) {
|
|
301
|
+
const { spawn } = await import("node:child_process");
|
|
302
|
+
return new Promise((resolve, reject)=>{
|
|
303
|
+
const ffmpegArgs = [
|
|
304
|
+
'-f',
|
|
305
|
+
'h264',
|
|
306
|
+
'-i',
|
|
307
|
+
'pipe:0',
|
|
308
|
+
'-vframes',
|
|
309
|
+
'1',
|
|
310
|
+
'-f',
|
|
311
|
+
'image2pipe',
|
|
312
|
+
'-vcodec',
|
|
313
|
+
'mjpeg',
|
|
314
|
+
'-q:v',
|
|
315
|
+
'5',
|
|
316
|
+
'-loglevel',
|
|
317
|
+
'error',
|
|
318
|
+
'pipe:1'
|
|
319
|
+
];
|
|
320
|
+
const ffmpegPath = this.getFfmpegPath();
|
|
321
|
+
const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
|
|
322
|
+
stdio: [
|
|
323
|
+
'pipe',
|
|
324
|
+
'pipe',
|
|
325
|
+
'pipe'
|
|
326
|
+
]
|
|
327
|
+
});
|
|
328
|
+
const chunks = [];
|
|
329
|
+
let stderrOutput = '';
|
|
330
|
+
ffmpeg.stdout.on('data', (chunk)=>{
|
|
331
|
+
chunks.push(chunk);
|
|
332
|
+
});
|
|
333
|
+
ffmpeg.stderr.on('data', (data)=>{
|
|
334
|
+
stderrOutput += data.toString();
|
|
335
|
+
});
|
|
336
|
+
ffmpeg.on('close', (code)=>{
|
|
337
|
+
if (0 === code && chunks.length > 0) {
|
|
338
|
+
const jpegBuffer = Buffer.concat(chunks);
|
|
339
|
+
debugScrcpy(`FFmpeg decode successful, JPEG size: ${jpegBuffer.length} bytes`);
|
|
340
|
+
resolve(jpegBuffer);
|
|
341
|
+
} else {
|
|
342
|
+
const errorMsg = stderrOutput || `FFmpeg exited with code ${code}`;
|
|
343
|
+
debugScrcpy(`FFmpeg decode failed: ${errorMsg}`);
|
|
344
|
+
reject(new Error(`H.264 to JPEG decode failed: ${errorMsg}`));
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
ffmpeg.on('error', (error)=>{
|
|
348
|
+
reject(new Error(`Failed to spawn ffmpeg process: ${error.message}`));
|
|
349
|
+
});
|
|
350
|
+
ffmpeg.stdin.write(h264Buffer);
|
|
351
|
+
ffmpeg.stdin.end();
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
resetIdleTimer() {
|
|
355
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
356
|
+
if (!this.options.idleTimeoutMs) return;
|
|
357
|
+
this.idleTimer = setTimeout(()=>{
|
|
358
|
+
debugScrcpy('Idle timeout reached, disconnecting scrcpy');
|
|
359
|
+
this.disconnect();
|
|
360
|
+
}, this.options.idleTimeoutMs);
|
|
361
|
+
}
|
|
362
|
+
async disconnect() {
|
|
363
|
+
debugScrcpy('Disconnecting scrcpy...');
|
|
364
|
+
if (this.idleTimer) {
|
|
365
|
+
clearTimeout(this.idleTimer);
|
|
366
|
+
this.idleTimer = null;
|
|
367
|
+
}
|
|
368
|
+
const client = this.scrcpyClient;
|
|
369
|
+
const reader = this.streamReader;
|
|
370
|
+
this.scrcpyClient = null;
|
|
371
|
+
this.videoStream = null;
|
|
372
|
+
this.streamReader = null;
|
|
373
|
+
this.spsHeader = null;
|
|
374
|
+
this.lastRawKeyframe = null;
|
|
375
|
+
this.isInitialized = false;
|
|
376
|
+
this.keyframeResolvers = [];
|
|
377
|
+
if (reader) try {
|
|
378
|
+
reader.cancel();
|
|
379
|
+
} catch {}
|
|
380
|
+
if (client) try {
|
|
381
|
+
await client.close();
|
|
382
|
+
} catch (error) {
|
|
383
|
+
debugScrcpy(`Error closing scrcpy client: ${error}`);
|
|
384
|
+
}
|
|
385
|
+
debugScrcpy('Scrcpy disconnected');
|
|
386
|
+
}
|
|
387
|
+
isConnected() {
|
|
388
|
+
return this.isInitialized && null !== this.scrcpyClient;
|
|
389
|
+
}
|
|
390
|
+
constructor(adb, options = {}){
|
|
391
|
+
_define_property(this, "adb", void 0);
|
|
392
|
+
_define_property(this, "scrcpyClient", null);
|
|
393
|
+
_define_property(this, "videoStream", null);
|
|
394
|
+
_define_property(this, "spsHeader", null);
|
|
395
|
+
_define_property(this, "idleTimer", null);
|
|
396
|
+
_define_property(this, "isConnecting", false);
|
|
397
|
+
_define_property(this, "isInitialized", false);
|
|
398
|
+
_define_property(this, "options", void 0);
|
|
399
|
+
_define_property(this, "ffmpegAvailable", null);
|
|
400
|
+
_define_property(this, "keyframeResolvers", []);
|
|
401
|
+
_define_property(this, "lastRawKeyframe", null);
|
|
402
|
+
_define_property(this, "videoResolution", null);
|
|
403
|
+
_define_property(this, "streamReader", null);
|
|
404
|
+
this.adb = adb;
|
|
405
|
+
const requestedBitRate = options.videoBitRate ?? DEFAULT_VIDEO_BIT_RATE;
|
|
406
|
+
const clampedBitRate = Math.min(requestedBitRate, MAX_VIDEO_BIT_RATE);
|
|
407
|
+
if (requestedBitRate > MAX_VIDEO_BIT_RATE) warnScrcpy(`videoBitRate ${requestedBitRate} exceeds maximum ${MAX_VIDEO_BIT_RATE}, clamped to ${clampedBitRate}`);
|
|
408
|
+
this.options = {
|
|
409
|
+
maxSize: options.maxSize ?? DEFAULT_MAX_SIZE,
|
|
410
|
+
videoBitRate: clampedBitRate,
|
|
411
|
+
idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
"@midscene/shared/logger" (module) {
|
|
417
|
+
module.exports = __rspack_external__midscene_shared_logger_b1dc2426;
|
|
418
|
+
},
|
|
419
|
+
"node:fs" (module) {
|
|
420
|
+
module.exports = __rspack_external_node_fs_5ea92f0c;
|
|
421
|
+
},
|
|
422
|
+
"node:module" (module) {
|
|
423
|
+
module.exports = __rspack_external_node_module_ab9f2194;
|
|
424
|
+
},
|
|
425
|
+
"node:path" (module) {
|
|
426
|
+
module.exports = __rspack_external_node_path_c5b9b54f;
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
var __webpack_module_cache__ = {};
|
|
430
|
+
function __webpack_require__(moduleId) {
|
|
431
|
+
var cachedModule = __webpack_module_cache__[moduleId];
|
|
432
|
+
if (void 0 !== cachedModule) return cachedModule.exports;
|
|
433
|
+
var module = __webpack_module_cache__[moduleId] = {
|
|
434
|
+
exports: {}
|
|
435
|
+
};
|
|
436
|
+
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
|
|
437
|
+
return module.exports;
|
|
438
|
+
}
|
|
439
|
+
(()=>{
|
|
440
|
+
__webpack_require__.d = (exports, definition)=>{
|
|
441
|
+
for(var key in definition)if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) Object.defineProperty(exports, key, {
|
|
442
|
+
enumerable: true,
|
|
443
|
+
get: definition[key]
|
|
444
|
+
});
|
|
445
|
+
};
|
|
446
|
+
})();
|
|
447
|
+
(()=>{
|
|
448
|
+
__webpack_require__.o = (obj, prop)=>Object.prototype.hasOwnProperty.call(obj, prop);
|
|
449
|
+
})();
|
|
450
|
+
var external_node_fs_ = __webpack_require__("node:fs");
|
|
451
|
+
var external_node_module_ = __webpack_require__("node:module");
|
|
452
|
+
var external_node_path_ = __webpack_require__("node:path");
|
|
453
|
+
var logger_ = __webpack_require__("@midscene/shared/logger");
|
|
454
|
+
var scrcpy_manager = __webpack_require__("./src/scrcpy-manager.ts");
|
|
455
|
+
function _define_property(obj, key, value) {
|
|
456
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
457
|
+
value: value,
|
|
458
|
+
enumerable: true,
|
|
459
|
+
configurable: true,
|
|
460
|
+
writable: true
|
|
461
|
+
});
|
|
462
|
+
else obj[key] = value;
|
|
463
|
+
return obj;
|
|
464
|
+
}
|
|
465
|
+
const debugAdapter = (0, logger_.getDebug)('android:scrcpy-adapter');
|
|
466
|
+
class ScrcpyDeviceAdapter {
|
|
467
|
+
isEnabled() {
|
|
468
|
+
if (this.initFailed) return false;
|
|
469
|
+
return this.scrcpyConfig?.enabled ?? scrcpy_manager.o.enabled;
|
|
470
|
+
}
|
|
471
|
+
async initialize(deviceInfo) {
|
|
472
|
+
try {
|
|
473
|
+
const manager = await this.ensureManager(deviceInfo);
|
|
474
|
+
await manager.ensureConnected();
|
|
475
|
+
} catch (error) {
|
|
476
|
+
this.initFailed = true;
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
resolveConfig(deviceInfo) {
|
|
481
|
+
if (this.resolvedConfig) return this.resolvedConfig;
|
|
482
|
+
const config = this.scrcpyConfig;
|
|
483
|
+
const maxSize = config?.maxSize ?? scrcpy_manager.o.maxSize;
|
|
484
|
+
const videoBitRate = config?.videoBitRate ?? scrcpy_manager.o.videoBitRate;
|
|
485
|
+
this.resolvedConfig = {
|
|
486
|
+
enabled: this.isEnabled(),
|
|
487
|
+
maxSize,
|
|
488
|
+
idleTimeoutMs: config?.idleTimeoutMs ?? scrcpy_manager.o.idleTimeoutMs,
|
|
489
|
+
videoBitRate
|
|
490
|
+
};
|
|
491
|
+
return this.resolvedConfig;
|
|
492
|
+
}
|
|
493
|
+
async ensureManager(deviceInfo) {
|
|
494
|
+
if (this.manager) return this.manager;
|
|
495
|
+
debugAdapter('Initializing Scrcpy manager...');
|
|
496
|
+
try {
|
|
497
|
+
const { Adb, AdbServerClient } = await import("@yume-chan/adb");
|
|
498
|
+
const { AdbServerNodeTcpConnector } = await import("@yume-chan/adb-server-node-tcp");
|
|
499
|
+
const { ScrcpyScreenshotManager: ScrcpyManager } = await Promise.resolve().then(__webpack_require__.bind(__webpack_require__, "./src/scrcpy-manager.ts"));
|
|
500
|
+
const adbClient = new AdbServerClient(new AdbServerNodeTcpConnector({
|
|
501
|
+
host: '127.0.0.1',
|
|
502
|
+
port: 5037
|
|
503
|
+
}));
|
|
504
|
+
const adb = new Adb(await adbClient.createTransport({
|
|
505
|
+
serial: this.deviceId
|
|
506
|
+
}));
|
|
507
|
+
const config = this.resolveConfig(deviceInfo);
|
|
508
|
+
const manager = new ScrcpyManager(adb, {
|
|
509
|
+
maxSize: config.maxSize,
|
|
510
|
+
videoBitRate: config.videoBitRate,
|
|
511
|
+
idleTimeoutMs: config.idleTimeoutMs
|
|
512
|
+
});
|
|
513
|
+
await manager.validateEnvironment();
|
|
514
|
+
this.manager = manager;
|
|
515
|
+
debugAdapter('Scrcpy manager initialized');
|
|
516
|
+
return this.manager;
|
|
517
|
+
} catch (error) {
|
|
518
|
+
debugAdapter(`Failed to initialize Scrcpy manager: ${error}`);
|
|
519
|
+
throw new Error(`Failed to initialize Scrcpy for device ${this.deviceId}. Ensure ADB server is running and device is connected. Error: ${error}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async screenshotBase64(deviceInfo) {
|
|
523
|
+
const manager = await this.ensureManager(deviceInfo);
|
|
524
|
+
const screenshotBuffer = await manager.getScreenshotJpeg();
|
|
525
|
+
return createImgBase64ByFormat('jpeg', screenshotBuffer.toString('base64'));
|
|
526
|
+
}
|
|
527
|
+
getResolution() {
|
|
528
|
+
return this.manager?.getResolution() ?? null;
|
|
529
|
+
}
|
|
530
|
+
getSize(deviceInfo) {
|
|
531
|
+
const resolution = this.getResolution();
|
|
532
|
+
if (!resolution) return null;
|
|
533
|
+
debugAdapter(`Using scrcpy resolution: ${resolution.width}x${resolution.height}`);
|
|
534
|
+
return {
|
|
535
|
+
width: resolution.width,
|
|
536
|
+
height: resolution.height
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
getScalingRatio(physicalWidth) {
|
|
540
|
+
const resolution = this.getResolution();
|
|
541
|
+
if (!resolution) return null;
|
|
542
|
+
return resolution.width / physicalWidth;
|
|
543
|
+
}
|
|
544
|
+
async disconnect() {
|
|
545
|
+
if (this.manager) {
|
|
546
|
+
try {
|
|
547
|
+
await this.manager.disconnect();
|
|
548
|
+
} catch (error) {
|
|
549
|
+
debugAdapter(`Error disconnecting scrcpy: ${error}`);
|
|
550
|
+
}
|
|
551
|
+
this.manager = null;
|
|
552
|
+
}
|
|
553
|
+
this.resolvedConfig = null;
|
|
554
|
+
}
|
|
555
|
+
constructor(deviceId, scrcpyConfig){
|
|
556
|
+
_define_property(this, "deviceId", void 0);
|
|
557
|
+
_define_property(this, "scrcpyConfig", void 0);
|
|
558
|
+
_define_property(this, "manager", void 0);
|
|
559
|
+
_define_property(this, "resolvedConfig", void 0);
|
|
560
|
+
_define_property(this, "initFailed", void 0);
|
|
561
|
+
this.deviceId = deviceId;
|
|
562
|
+
this.scrcpyConfig = scrcpyConfig;
|
|
563
|
+
this.manager = null;
|
|
564
|
+
this.resolvedConfig = null;
|
|
565
|
+
this.initFailed = false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function device_define_property(obj, key, value) {
|
|
569
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
570
|
+
value: value,
|
|
571
|
+
enumerable: true,
|
|
572
|
+
configurable: true,
|
|
573
|
+
writable: true
|
|
574
|
+
});
|
|
575
|
+
else obj[key] = value;
|
|
576
|
+
return obj;
|
|
577
|
+
}
|
|
578
|
+
const defaultScrollUntilTimes = 10;
|
|
579
|
+
const defaultFastScrollDuration = 100;
|
|
580
|
+
const defaultNormalScrollDuration = 1000;
|
|
581
|
+
const IME_STRATEGY_ALWAYS_YADB = 'always-yadb';
|
|
582
|
+
const IME_STRATEGY_YADB_FOR_NON_ASCII = 'yadb-for-non-ascii';
|
|
583
|
+
const debugDevice = (0, logger_.getDebug)('android:device');
|
|
584
|
+
function escapeForShell(text) {
|
|
585
|
+
return text.replace(/'/g, "'\\''").replace(/\n/g, '\\n');
|
|
586
|
+
}
|
|
587
|
+
const deviceUiautomatorPorts = new Map();
|
|
588
|
+
async function getFreePort() {
|
|
589
|
+
return new Promise((resolve, reject)=>{
|
|
590
|
+
const server = createServer();
|
|
591
|
+
server.unref();
|
|
592
|
+
server.on('error', reject);
|
|
593
|
+
server.listen(0, ()=>{
|
|
594
|
+
const port = server.address().port;
|
|
595
|
+
server.close(()=>resolve(port));
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
function uiautomatorHttpRequest(port, path, body, timeoutMs = 3000) {
|
|
600
|
+
return new Promise((resolve, reject)=>{
|
|
601
|
+
const bodyBuf = body ? Buffer.from(body, 'utf8') : void 0;
|
|
602
|
+
const req = request({
|
|
603
|
+
hostname: '127.0.0.1',
|
|
604
|
+
port,
|
|
605
|
+
path,
|
|
606
|
+
method: body ? 'POST' : 'GET',
|
|
607
|
+
headers: {
|
|
608
|
+
...bodyBuf ? {
|
|
609
|
+
'Content-Type': 'application/json',
|
|
610
|
+
'Content-Length': bodyBuf.length
|
|
611
|
+
} : {},
|
|
612
|
+
Connection: 'close'
|
|
613
|
+
},
|
|
614
|
+
timeout: timeoutMs
|
|
615
|
+
}, (res)=>{
|
|
616
|
+
let data = '';
|
|
617
|
+
res.on('data', (chunk)=>{
|
|
618
|
+
data += chunk.toString();
|
|
619
|
+
});
|
|
620
|
+
res.on('end', ()=>resolve(data));
|
|
621
|
+
});
|
|
622
|
+
req.on('timeout', ()=>{
|
|
623
|
+
req.destroy(new Error(`UIAutomator HTTP request timed out after ${timeoutMs}ms`));
|
|
624
|
+
});
|
|
625
|
+
req.on('error', reject);
|
|
626
|
+
if (bodyBuf) req.write(bodyBuf);
|
|
627
|
+
req.end();
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
class AndroidDevice {
|
|
631
|
+
actionSpace() {
|
|
632
|
+
const defaultActions = [
|
|
633
|
+
defineActionTap(async (param)=>{
|
|
634
|
+
const element = param.locate;
|
|
635
|
+
node_assert(element, 'Element not found, cannot tap');
|
|
636
|
+
await this.mouseClick(element.center[0], element.center[1]);
|
|
637
|
+
}),
|
|
638
|
+
defineActionDoubleClick(async (param)=>{
|
|
639
|
+
const element = param.locate;
|
|
640
|
+
node_assert(element, 'Element not found, cannot double click');
|
|
641
|
+
await this.mouseDoubleClick(element.center[0], element.center[1]);
|
|
642
|
+
}),
|
|
643
|
+
defineAction({
|
|
644
|
+
name: 'Input',
|
|
645
|
+
description: 'Input text into the input field',
|
|
646
|
+
interfaceAlias: 'aiInput',
|
|
647
|
+
paramSchema: z.object({
|
|
648
|
+
value: z.string().describe('The text to input. Provide the final content for replace/append modes, or an empty string when using clear mode to remove existing text.'),
|
|
649
|
+
autoDismissKeyboard: z.boolean().optional().describe('If true, the keyboard will be dismissed after the input is completed. Do not set it unless the user asks you to do so.'),
|
|
650
|
+
mode: z.preprocess((val)=>'append' === val ? 'typeOnly' : val, z["enum"]([
|
|
651
|
+
'replace',
|
|
652
|
+
'clear',
|
|
653
|
+
'typeOnly'
|
|
654
|
+
]).default('replace').optional().describe('Input mode: "replace" (default) - clear the field and input the value; "typeOnly" - type the value directly without clearing the field first; "clear" - clear the field without inputting new text.')),
|
|
655
|
+
locate: getMidsceneLocationSchema().describe('The input field to be filled').optional()
|
|
656
|
+
}),
|
|
657
|
+
sample: {
|
|
658
|
+
value: 'test@example.com',
|
|
659
|
+
locate: {
|
|
660
|
+
prompt: 'the email input field'
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
call: async (param)=>{
|
|
664
|
+
const element = param.locate;
|
|
665
|
+
if ('typeOnly' !== param.mode) await this.clearInput(element);
|
|
666
|
+
if ('clear' === param.mode) return;
|
|
667
|
+
if (!param || !param.value) return;
|
|
668
|
+
const autoDismissKeyboard = param.autoDismissKeyboard ?? this.options?.autoDismissKeyboard;
|
|
669
|
+
await this.keyboardType(param.value, {
|
|
670
|
+
autoDismissKeyboard
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}),
|
|
674
|
+
defineActionScroll(async (param)=>{
|
|
675
|
+
const element = param.locate;
|
|
676
|
+
const startingPoint = element ? {
|
|
677
|
+
left: element.center[0],
|
|
678
|
+
top: element.center[1]
|
|
679
|
+
} : void 0;
|
|
680
|
+
const scrollToEventName = param?.scrollType;
|
|
681
|
+
if ('scrollToTop' === scrollToEventName) await this.scrollUntilTop(startingPoint);
|
|
682
|
+
else if ('scrollToBottom' === scrollToEventName) await this.scrollUntilBottom(startingPoint);
|
|
683
|
+
else if ('scrollToRight' === scrollToEventName) await this.scrollUntilRight(startingPoint);
|
|
684
|
+
else if ('scrollToLeft' === scrollToEventName) await this.scrollUntilLeft(startingPoint);
|
|
685
|
+
else if ('singleAction' !== scrollToEventName && scrollToEventName) throw new Error(`Unknown scroll event type: ${scrollToEventName}, param: ${JSON.stringify(param)}`);
|
|
686
|
+
else {
|
|
687
|
+
if (param?.direction !== 'down' && param && param.direction) if ('up' === param.direction) await this.scrollUp(param.distance || void 0, startingPoint);
|
|
688
|
+
else if ('left' === param.direction) await this.scrollLeft(param.distance || void 0, startingPoint);
|
|
689
|
+
else if ('right' === param.direction) await this.scrollRight(param.distance || void 0, startingPoint);
|
|
690
|
+
else throw new Error(`Unknown scroll direction: ${param.direction}`);
|
|
691
|
+
else await this.scrollDown(param?.distance || void 0, startingPoint);
|
|
692
|
+
await sleep(500);
|
|
693
|
+
}
|
|
694
|
+
}),
|
|
695
|
+
defineActionDragAndDrop(async (param)=>{
|
|
696
|
+
const from = param.from;
|
|
697
|
+
const to = param.to;
|
|
698
|
+
node_assert(from, 'missing "from" param for drag and drop');
|
|
699
|
+
node_assert(to, 'missing "to" param for drag and drop');
|
|
700
|
+
await this.mouseDrag({
|
|
701
|
+
x: from.center[0],
|
|
702
|
+
y: from.center[1]
|
|
703
|
+
}, {
|
|
704
|
+
x: to.center[0],
|
|
705
|
+
y: to.center[1]
|
|
706
|
+
});
|
|
707
|
+
}),
|
|
708
|
+
defineActionSwipe(async (param)=>{
|
|
709
|
+
const { startPoint, endPoint, duration, repeatCount } = normalizeMobileSwipeParam(param, await this.size());
|
|
710
|
+
for(let i = 0; i < repeatCount; i++)await this.mouseDrag(startPoint, endPoint, duration);
|
|
711
|
+
}),
|
|
712
|
+
defineActionKeyboardPress(async (param)=>{
|
|
713
|
+
await this.keyboardPress(param.keyName);
|
|
714
|
+
}),
|
|
715
|
+
defineActionCursorMove(async (param)=>{
|
|
716
|
+
const arrowKey = 'left' === param.direction ? 'ArrowLeft' : 'ArrowRight';
|
|
717
|
+
const times = param.times ?? 1;
|
|
718
|
+
for(let i = 0; i < times; i++){
|
|
719
|
+
await this.keyboardPress(arrowKey);
|
|
720
|
+
await sleep(100);
|
|
721
|
+
}
|
|
722
|
+
}),
|
|
723
|
+
defineAction({
|
|
724
|
+
name: 'LongPress',
|
|
725
|
+
description: 'Trigger a long press on the screen at specified element',
|
|
726
|
+
paramSchema: z.object({
|
|
727
|
+
duration: z.number().optional().describe('The duration of the long press in milliseconds'),
|
|
728
|
+
locate: getMidsceneLocationSchema().describe('The element to be long pressed')
|
|
729
|
+
}),
|
|
730
|
+
sample: {
|
|
731
|
+
locate: {
|
|
732
|
+
prompt: 'the message bubble'
|
|
733
|
+
}
|
|
734
|
+
},
|
|
735
|
+
call: async (param)=>{
|
|
736
|
+
const element = param.locate;
|
|
737
|
+
if (!element) throw new Error('LongPress requires an element to be located');
|
|
738
|
+
const [x, y] = element.center;
|
|
739
|
+
await this.longPress(x, y, param?.duration);
|
|
740
|
+
}
|
|
741
|
+
}),
|
|
742
|
+
defineAction({
|
|
743
|
+
name: 'PullGesture',
|
|
744
|
+
description: 'Trigger pull down to refresh or pull up actions',
|
|
745
|
+
paramSchema: z.object({
|
|
746
|
+
direction: z["enum"]([
|
|
747
|
+
'up',
|
|
748
|
+
'down'
|
|
749
|
+
]).describe('The direction to pull'),
|
|
750
|
+
distance: z.number().optional().describe('The distance to pull (in pixels)'),
|
|
751
|
+
duration: z.number().optional().describe('The duration of the pull (in milliseconds)'),
|
|
752
|
+
locate: getMidsceneLocationSchema().optional().describe('The element to start the pull from (optional)')
|
|
753
|
+
}),
|
|
754
|
+
sample: {
|
|
755
|
+
direction: 'down',
|
|
756
|
+
locate: {
|
|
757
|
+
prompt: 'the center of the content list area'
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
call: async (param)=>{
|
|
761
|
+
const element = param.locate;
|
|
762
|
+
const startPoint = element ? {
|
|
763
|
+
left: element.center[0],
|
|
764
|
+
top: element.center[1]
|
|
765
|
+
} : void 0;
|
|
766
|
+
if (!param || !param.direction) throw new Error('PullGesture requires a direction parameter');
|
|
767
|
+
if ('down' === param.direction) await this.pullDown(startPoint, param.distance, param.duration);
|
|
768
|
+
else if ('up' === param.direction) await this.pullUp(startPoint, param.distance, param.duration);
|
|
769
|
+
else throw new Error(`Unknown pull direction: ${param.direction}`);
|
|
770
|
+
}
|
|
771
|
+
}),
|
|
772
|
+
defineActionPinch(async (param)=>{
|
|
773
|
+
const { centerX, centerY, startDistance, endDistance, duration } = normalizePinchParam(param, await this.size());
|
|
774
|
+
const { x: adjCenterX, y: adjCenterY } = await this.adjustCoordinates(centerX, centerY);
|
|
775
|
+
const ratio = 0 !== adjCenterX && 0 !== centerX ? adjCenterX / centerX : 1;
|
|
776
|
+
const adjStartDist = Math.round(startDistance * ratio);
|
|
777
|
+
const adjEndDist = Math.round(endDistance * ratio);
|
|
778
|
+
await this.ensureYadb();
|
|
779
|
+
const adb = await this.getAdb();
|
|
780
|
+
await adb.shell(`app_process${this.getDisplayArg()} -Djava.class.path=/data/local/tmp/yadb /data/local/tmp com.ysbing.yadb.Main -pinch ${adjCenterX} ${adjCenterY} ${adjStartDist} ${adjEndDist} ${duration}`);
|
|
781
|
+
}),
|
|
782
|
+
defineActionClearInput(async (param)=>{
|
|
783
|
+
await this.clearInput(param.locate);
|
|
784
|
+
})
|
|
785
|
+
];
|
|
786
|
+
const platformSpecificActions = Object.values(createPlatformActions(this));
|
|
787
|
+
const customActions = this.customActions || [];
|
|
788
|
+
return [
|
|
789
|
+
...defaultActions,
|
|
790
|
+
...platformSpecificActions,
|
|
791
|
+
...customActions
|
|
792
|
+
];
|
|
793
|
+
}
|
|
794
|
+
describe() {
|
|
795
|
+
return this.description || `DeviceId: ${this.deviceId}`;
|
|
796
|
+
}
|
|
797
|
+
async connect() {
|
|
798
|
+
const adb = await this.getAdb();
|
|
799
|
+
const adapter = this.getScrcpyAdapter();
|
|
800
|
+
if (adapter.isEnabled()) try {
|
|
801
|
+
const deviceInfo = await this.getDevicePhysicalInfo();
|
|
802
|
+
await adapter.initialize(deviceInfo);
|
|
803
|
+
console.log(`[midscene] Using scrcpy for screenshots (device: ${this.deviceId})`);
|
|
804
|
+
} catch (error) {
|
|
805
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
806
|
+
console.warn(`[midscene] Scrcpy unavailable, using ADB fallback (device: ${this.deviceId}): ${msg}`);
|
|
807
|
+
}
|
|
808
|
+
return adb;
|
|
809
|
+
}
|
|
810
|
+
async getAdb() {
|
|
811
|
+
if (this.destroyed) throw new Error(`AndroidDevice ${this.deviceId} has been destroyed and cannot execute ADB commands`);
|
|
812
|
+
if (this.adb) return this.createAdbProxy(this.adb);
|
|
813
|
+
if (this.connectingAdb) return this.connectingAdb.then((adb)=>this.createAdbProxy(adb));
|
|
814
|
+
this.connectingAdb = (async ()=>{
|
|
815
|
+
let error = null;
|
|
816
|
+
debugDevice(`Initializing ADB with device ID: ${this.deviceId}`);
|
|
817
|
+
try {
|
|
818
|
+
const androidAdbPath = this.options?.androidAdbPath || globalConfigManager.getEnvConfigValue(MIDSCENE_ADB_PATH);
|
|
819
|
+
const remoteAdbHost = this.options?.remoteAdbHost || globalConfigManager.getEnvConfigValue(MIDSCENE_ADB_REMOTE_HOST);
|
|
820
|
+
const remoteAdbPort = this.options?.remoteAdbPort || globalConfigManager.getEnvConfigValue(MIDSCENE_ADB_REMOTE_PORT);
|
|
821
|
+
this.adb = new ADB({
|
|
822
|
+
udid: this.deviceId,
|
|
823
|
+
adbExecTimeout: 60000,
|
|
824
|
+
executable: androidAdbPath ? {
|
|
825
|
+
path: androidAdbPath,
|
|
826
|
+
defaultArgs: []
|
|
827
|
+
} : void 0,
|
|
828
|
+
remoteAdbHost: remoteAdbHost || void 0,
|
|
829
|
+
remoteAdbPort: remoteAdbPort ? Number(remoteAdbPort) : void 0
|
|
830
|
+
});
|
|
831
|
+
if (!deviceUiautomatorPorts.has(this.deviceId)) try {
|
|
832
|
+
const localPort = await getFreePort();
|
|
833
|
+
this.startUIAutomatorProcess();
|
|
834
|
+
console.log(`[UIAutomator] Started uiautomator2 server process for ${this.deviceId}`);
|
|
835
|
+
await this.adb.forwardPort(localPort, 9008);
|
|
836
|
+
deviceUiautomatorPorts.set(this.deviceId, localPort);
|
|
837
|
+
console.log(`[UIAutomator] Device ${this.deviceId} routed to local port ${localPort}. Readiness will be checked on first click.`);
|
|
838
|
+
} catch (e) {
|
|
839
|
+
console.warn(`[UIAutomator] Port forward failed for ${this.deviceId}:`, e);
|
|
840
|
+
}
|
|
841
|
+
const size = await this.getScreenSize();
|
|
842
|
+
this.description = `
|
|
843
|
+
DeviceId: ${this.deviceId}
|
|
844
|
+
ScreenSize:
|
|
845
|
+
${Object.keys(size).filter((key)=>size[key]).map((key)=>` ${key} size: ${size[key]}${'override' === key && size[key] ? ' ✅' : ''}`).join('\n')}
|
|
846
|
+
`;
|
|
847
|
+
debugDevice('ADB initialized successfully', this.description);
|
|
848
|
+
return this.adb;
|
|
849
|
+
} catch (e) {
|
|
850
|
+
debugDevice(`Failed to initialize ADB: ${e}`);
|
|
851
|
+
error = new Error(`Unable to connect to device ${this.deviceId}: ${e}`);
|
|
852
|
+
} finally{
|
|
853
|
+
this.connectingAdb = null;
|
|
854
|
+
}
|
|
855
|
+
if (error) throw error;
|
|
856
|
+
throw new Error('ADB initialization failed unexpectedly');
|
|
857
|
+
})();
|
|
858
|
+
return this.connectingAdb;
|
|
859
|
+
}
|
|
860
|
+
createAdbProxy(adb) {
|
|
861
|
+
return new Proxy(adb, {
|
|
862
|
+
get: (target, prop)=>{
|
|
863
|
+
const originalMethod = target[prop];
|
|
864
|
+
if ('function' != typeof originalMethod) return originalMethod;
|
|
865
|
+
return async (...args)=>{
|
|
866
|
+
try {
|
|
867
|
+
debugDevice(`adb ${String(prop)} ${args.join(' ')}`);
|
|
868
|
+
const result = await originalMethod.apply(target, args);
|
|
869
|
+
debugDevice(`adb ${String(prop)} ${args.join(' ')} end`);
|
|
870
|
+
return result;
|
|
871
|
+
} catch (error) {
|
|
872
|
+
const methodName = String(prop);
|
|
873
|
+
const deviceId = this.deviceId;
|
|
874
|
+
debugDevice(`ADB error with device ${deviceId} when calling ${methodName}: ${error}`);
|
|
875
|
+
throw new Error(`ADB error with device ${deviceId} when calling ${methodName}, please check https://midscenejs.com/integrate-with-android.html#faq : ${error.message}`, {
|
|
876
|
+
cause: error
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
getScrcpyAdapter() {
|
|
884
|
+
if (!this.scrcpyAdapter) this.scrcpyAdapter = new ScrcpyDeviceAdapter(this.deviceId, this.options?.scrcpyConfig);
|
|
885
|
+
return this.scrcpyAdapter;
|
|
886
|
+
}
|
|
887
|
+
async getDevicePhysicalInfo() {
|
|
888
|
+
await this.initializeDevicePixelRatio();
|
|
889
|
+
const screenSize = await this.getScreenSize();
|
|
890
|
+
const sizeStr = screenSize.override || screenSize.physical;
|
|
891
|
+
const match = sizeStr.match(/(\d{1,5})x(\d{1,5})/);
|
|
892
|
+
if (!match) throw new Error(`Unable to parse screen size: ${sizeStr}`);
|
|
893
|
+
return {
|
|
894
|
+
physicalWidth: Number.parseInt(match[1], 10),
|
|
895
|
+
physicalHeight: Number.parseInt(match[2], 10),
|
|
896
|
+
dpr: this.devicePixelRatio,
|
|
897
|
+
orientation: screenSize.orientation,
|
|
898
|
+
isCurrentOrientation: screenSize.isCurrentOrientation
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
setAppNameMapping(mapping) {
|
|
902
|
+
this.appNameMapping = mapping;
|
|
903
|
+
}
|
|
904
|
+
resolvePackageName(appName) {
|
|
905
|
+
const normalizedAppName = normalizeForComparison(appName);
|
|
906
|
+
return this.appNameMapping[normalizedAppName];
|
|
907
|
+
}
|
|
908
|
+
async launch(uri) {
|
|
909
|
+
const adb = await this.getAdb();
|
|
910
|
+
this.uri = uri;
|
|
911
|
+
try {
|
|
912
|
+
debugDevice(`Launching app: ${uri}`);
|
|
913
|
+
if (uri.startsWith('http://') || uri.startsWith('https://') || uri.includes('://')) await adb.startUri(uri);
|
|
914
|
+
else if (uri.includes('/')) {
|
|
915
|
+
const [appPackage, appActivity] = uri.split('/');
|
|
916
|
+
await adb.startApp({
|
|
917
|
+
pkg: appPackage,
|
|
918
|
+
activity: appActivity
|
|
919
|
+
});
|
|
920
|
+
} else {
|
|
921
|
+
const resolvedUri = this.resolvePackageName(uri) ?? uri;
|
|
922
|
+
await adb.activateApp(resolvedUri);
|
|
923
|
+
}
|
|
924
|
+
debugDevice(`Successfully launched: ${uri}`);
|
|
925
|
+
} catch (error) {
|
|
926
|
+
debugDevice(`Error launching ${uri}: ${error}`);
|
|
927
|
+
throw new Error(`Failed to launch ${uri}: ${error.message}`, {
|
|
928
|
+
cause: error
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
return this;
|
|
932
|
+
}
|
|
933
|
+
async execYadb(keyboardContent) {
|
|
934
|
+
await this.ensureYadb();
|
|
935
|
+
const adb = await this.getAdb();
|
|
936
|
+
await adb.shell(`app_process${this.getDisplayArg()} -Djava.class.path=/data/local/tmp/yadb /data/local/tmp com.ysbing.yadb.Main -keyboard '${keyboardContent}'`);
|
|
937
|
+
}
|
|
938
|
+
async getElementsInfo() {
|
|
939
|
+
return [];
|
|
940
|
+
}
|
|
941
|
+
async getElementsNodeTree() {
|
|
942
|
+
return {
|
|
943
|
+
node: null,
|
|
944
|
+
children: []
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
async getScreenSize() {
|
|
948
|
+
const shouldCache = !(this.options?.alwaysRefreshScreenInfo ?? false);
|
|
949
|
+
debugDevice(`getScreenSize: alwaysRefreshScreenInfo=${this.options?.alwaysRefreshScreenInfo}, shouldCache=${shouldCache}, hasCachedSize=${!!this.cachedScreenSize}`);
|
|
950
|
+
if (shouldCache && this.cachedScreenSize) return this.cachedScreenSize;
|
|
951
|
+
const adb = await this.getAdb();
|
|
952
|
+
if ('number' == typeof this.options?.displayId) try {
|
|
953
|
+
const stdout = await adb.shell('dumpsys display');
|
|
954
|
+
if (this.options?.usePhysicalDisplayIdForDisplayLookup) {
|
|
955
|
+
const physicalDisplayId = await this.getPhysicalDisplayId();
|
|
956
|
+
if (physicalDisplayId) {
|
|
957
|
+
const lineRegex = new RegExp(`^.*uniqueId \"local:${physicalDisplayId}\".*$
|
|
958
|
+
`, 'm');
|
|
959
|
+
const lineMatch = stdout.match(lineRegex);
|
|
960
|
+
if (lineMatch) {
|
|
961
|
+
const targetLine = lineMatch[0];
|
|
962
|
+
const realMatch = targetLine.match(/real (\d+) x (\d+)/);
|
|
963
|
+
const rotationMatch = targetLine.match(/rotation (\d+)/);
|
|
964
|
+
if (realMatch && rotationMatch) {
|
|
965
|
+
const width = Number(realMatch[1]);
|
|
966
|
+
const height = Number(realMatch[2]);
|
|
967
|
+
const rotation = Number(rotationMatch[1]);
|
|
968
|
+
const sizeStr = `${width}x${height}`;
|
|
969
|
+
debugDevice(`Using display info for long ID ${physicalDisplayId}: ${sizeStr}, rotation: ${rotation}`);
|
|
970
|
+
const result = {
|
|
971
|
+
override: sizeStr,
|
|
972
|
+
physical: sizeStr,
|
|
973
|
+
orientation: rotation,
|
|
974
|
+
isCurrentOrientation: true
|
|
975
|
+
};
|
|
976
|
+
if (shouldCache) this.cachedScreenSize = result;
|
|
977
|
+
return result;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
} else {
|
|
982
|
+
const viewportRegex = new RegExp(`DisplayViewport{[^}]*displayId=${this.options.displayId}[^}]*}`, 'g');
|
|
983
|
+
const match = stdout.match(viewportRegex);
|
|
984
|
+
if (match) {
|
|
985
|
+
const targetLine = match[0];
|
|
986
|
+
const physicalFrameMatch = targetLine.match(/physicalFrame=Rect\(\d+, \d+ - (\d+), (\d+)\)/);
|
|
987
|
+
const orientationMatch = targetLine.match(/orientation=(\d+)/);
|
|
988
|
+
if (physicalFrameMatch && orientationMatch) {
|
|
989
|
+
const width = Number(physicalFrameMatch[1]);
|
|
990
|
+
const height = Number(physicalFrameMatch[2]);
|
|
991
|
+
const rotation = Number(orientationMatch[1]);
|
|
992
|
+
const sizeStr = `${width}x${height}`;
|
|
993
|
+
debugDevice(`Using display info for display ID ${this.options.displayId}: ${sizeStr}, rotation: ${rotation}`);
|
|
994
|
+
const result = {
|
|
995
|
+
override: sizeStr,
|
|
996
|
+
physical: sizeStr,
|
|
997
|
+
orientation: rotation,
|
|
998
|
+
isCurrentOrientation: true
|
|
999
|
+
};
|
|
1000
|
+
if (shouldCache) this.cachedScreenSize = result;
|
|
1001
|
+
return result;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
debugDevice(`Could not find display info for displayId ${this.options.displayId}`);
|
|
1006
|
+
} catch (e) {
|
|
1007
|
+
debugDevice(`Failed to get size from display info for display ${this.options.displayId}: ${e}`);
|
|
1008
|
+
}
|
|
1009
|
+
const stdout = await adb.shell([
|
|
1010
|
+
'wm',
|
|
1011
|
+
'size'
|
|
1012
|
+
]);
|
|
1013
|
+
const size = {
|
|
1014
|
+
override: '',
|
|
1015
|
+
physical: ''
|
|
1016
|
+
};
|
|
1017
|
+
const overrideSize = new RegExp(/Override size: ([^\r?\n]+)*/g).exec(stdout);
|
|
1018
|
+
if (overrideSize && overrideSize.length >= 2 && overrideSize[1]) {
|
|
1019
|
+
debugDevice(`Using Override size: ${overrideSize[1].trim()}`);
|
|
1020
|
+
size.override = overrideSize[1].trim();
|
|
1021
|
+
}
|
|
1022
|
+
const physicalSize = new RegExp(/Physical size: ([^\r?\n]+)*/g).exec(stdout);
|
|
1023
|
+
if (physicalSize && physicalSize.length >= 2) {
|
|
1024
|
+
debugDevice(`Using Physical size: ${physicalSize[1].trim()}`);
|
|
1025
|
+
size.physical = physicalSize[1].trim();
|
|
1026
|
+
}
|
|
1027
|
+
const orientation = await this.getDisplayOrientation();
|
|
1028
|
+
if (size.override || size.physical) {
|
|
1029
|
+
const result = {
|
|
1030
|
+
...size,
|
|
1031
|
+
orientation,
|
|
1032
|
+
isCurrentOrientation: false
|
|
1033
|
+
};
|
|
1034
|
+
if (shouldCache) this.cachedScreenSize = result;
|
|
1035
|
+
return result;
|
|
1036
|
+
}
|
|
1037
|
+
throw new Error(`Failed to get screen size, output: ${stdout}`);
|
|
1038
|
+
}
|
|
1039
|
+
async initializeDevicePixelRatio() {
|
|
1040
|
+
if (this.devicePixelRatioInitialized) return;
|
|
1041
|
+
const densityNum = await this.getDisplayDensity();
|
|
1042
|
+
this.devicePixelRatio = Number(densityNum) / 160;
|
|
1043
|
+
debugDevice(`Initialized device pixel ratio: ${this.devicePixelRatio}`);
|
|
1044
|
+
this.devicePixelRatioInitialized = true;
|
|
1045
|
+
}
|
|
1046
|
+
async getDisplayDensity() {
|
|
1047
|
+
const adb = await this.getAdb();
|
|
1048
|
+
if ('number' == typeof this.options?.displayId) try {
|
|
1049
|
+
const stdout = await adb.shell('dumpsys display');
|
|
1050
|
+
if (this.options?.usePhysicalDisplayIdForDisplayLookup) {
|
|
1051
|
+
const physicalDisplayId = await this.getPhysicalDisplayId();
|
|
1052
|
+
if (physicalDisplayId) {
|
|
1053
|
+
const lineRegex = new RegExp(`^.*uniqueId \"local:${physicalDisplayId}\".*$
|
|
1054
|
+
`, 'm');
|
|
1055
|
+
const lineMatch = stdout.match(lineRegex);
|
|
1056
|
+
if (lineMatch) {
|
|
1057
|
+
const targetLine = lineMatch[0];
|
|
1058
|
+
const densityMatch = targetLine.match(/density (\d+)/);
|
|
1059
|
+
if (densityMatch) {
|
|
1060
|
+
const density = Number(densityMatch[1]);
|
|
1061
|
+
debugDevice(`Using display density for physical ID ${physicalDisplayId}: ${density}`);
|
|
1062
|
+
return density;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
} else {
|
|
1067
|
+
const displayDeviceRegex = new RegExp(`DisplayDevice:[\\s\\S]*?mDisplayId=${this.options.displayId}[\\s\\S]*?DisplayInfo{[^}]*density (\\d+)`, 'm');
|
|
1068
|
+
const deviceBlockMatch = stdout.match(displayDeviceRegex);
|
|
1069
|
+
if (deviceBlockMatch) {
|
|
1070
|
+
const density = Number(deviceBlockMatch[1]);
|
|
1071
|
+
debugDevice(`Using display density for display ID ${this.options.displayId}: ${density}`);
|
|
1072
|
+
return density;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
} catch (e) {
|
|
1076
|
+
debugDevice(`Failed to get density from display info: ${e}`);
|
|
1077
|
+
}
|
|
1078
|
+
const density = await adb.getScreenDensity();
|
|
1079
|
+
return density ?? 160;
|
|
1080
|
+
}
|
|
1081
|
+
async getDisplayOrientation() {
|
|
1082
|
+
const shouldCache = !(this.options?.alwaysRefreshScreenInfo ?? false);
|
|
1083
|
+
debugDevice(`getDisplayOrientation: alwaysRefreshScreenInfo=${this.options?.alwaysRefreshScreenInfo}, shouldCache=${shouldCache}, hasCachedOrientation=${null !== this.cachedOrientation}`);
|
|
1084
|
+
if (shouldCache && null !== this.cachedOrientation) return this.cachedOrientation;
|
|
1085
|
+
const adb = await this.getAdb();
|
|
1086
|
+
let orientation = 0;
|
|
1087
|
+
try {
|
|
1088
|
+
const orientationStdout = await adb.shell(`dumpsys${this.getDisplayArg()} input | grep SurfaceOrientation`);
|
|
1089
|
+
const orientationMatch = orientationStdout.match(/SurfaceOrientation:\s*(\d)/);
|
|
1090
|
+
if (!orientationMatch) throw new Error('Failed to get orientation from input');
|
|
1091
|
+
orientation = Number(orientationMatch[1]);
|
|
1092
|
+
debugDevice(`Screen orientation: ${orientation}`);
|
|
1093
|
+
} catch (e) {
|
|
1094
|
+
debugDevice('Failed to get orientation from input, try display');
|
|
1095
|
+
try {
|
|
1096
|
+
const orientationStdout = await adb.shell(`dumpsys${this.getDisplayArg()} display | grep mCurrentOrientation`);
|
|
1097
|
+
const orientationMatch = orientationStdout.match(/mCurrentOrientation=(\d)/);
|
|
1098
|
+
if (!orientationMatch) throw new Error('Failed to get orientation from display');
|
|
1099
|
+
orientation = Number(orientationMatch[1]);
|
|
1100
|
+
debugDevice(`Screen orientation (fallback): ${orientation}`);
|
|
1101
|
+
} catch (e2) {
|
|
1102
|
+
orientation = 0;
|
|
1103
|
+
debugDevice('Failed to get orientation from display, default to 0');
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
if (shouldCache) this.cachedOrientation = orientation;
|
|
1107
|
+
return orientation;
|
|
1108
|
+
}
|
|
1109
|
+
async getOrientedPhysicalSize() {
|
|
1110
|
+
const info = await this.getDevicePhysicalInfo();
|
|
1111
|
+
const isLandscape = 1 === info.orientation || 3 === info.orientation;
|
|
1112
|
+
const shouldSwap = true !== info.isCurrentOrientation && isLandscape;
|
|
1113
|
+
return {
|
|
1114
|
+
width: shouldSwap ? info.physicalHeight : info.physicalWidth,
|
|
1115
|
+
height: shouldSwap ? info.physicalWidth : info.physicalHeight
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
async size() {
|
|
1119
|
+
const physical = await this.getOrientedPhysicalSize();
|
|
1120
|
+
const scale = 1 / this.devicePixelRatio;
|
|
1121
|
+
return {
|
|
1122
|
+
width: Math.round(physical.width * scale),
|
|
1123
|
+
height: Math.round(physical.height * scale)
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
async getAdjustScale() {
|
|
1127
|
+
const shouldCache = !(this.options?.alwaysRefreshScreenInfo ?? false);
|
|
1128
|
+
debugDevice(`getAdjustScale: alwaysRefreshScreenInfo=${this.options?.alwaysRefreshScreenInfo}, shouldCache=${shouldCache}, hasCachedScale=${!!this.cachedAdjustScale}`);
|
|
1129
|
+
if (shouldCache && this.cachedAdjustScale) return this.cachedAdjustScale;
|
|
1130
|
+
const physical = await this.getOrientedPhysicalSize();
|
|
1131
|
+
const { width: logicalW, height: logicalH } = await this.size();
|
|
1132
|
+
const scale = {
|
|
1133
|
+
x: logicalW / physical.width,
|
|
1134
|
+
y: logicalH / physical.height
|
|
1135
|
+
};
|
|
1136
|
+
if (shouldCache) this.cachedAdjustScale = scale;
|
|
1137
|
+
return scale;
|
|
1138
|
+
}
|
|
1139
|
+
async adjustCoordinates(x, y) {
|
|
1140
|
+
const scale = await this.getAdjustScale();
|
|
1141
|
+
return {
|
|
1142
|
+
x: Math.round(x / scale.x),
|
|
1143
|
+
y: Math.round(y / scale.y)
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
calculateScrollEndPoint(start, deltaX, deltaY, maxWidth, maxHeight) {
|
|
1147
|
+
const minScrollDistance = 50;
|
|
1148
|
+
let actualScrollDistanceX = 0;
|
|
1149
|
+
let actualScrollDistanceY = 0;
|
|
1150
|
+
if (0 !== deltaX) {
|
|
1151
|
+
const maxAvailableX = deltaX > 0 ? maxWidth - start.x : start.x;
|
|
1152
|
+
actualScrollDistanceX = Math.min(Math.abs(deltaX), maxAvailableX);
|
|
1153
|
+
const minScrollX = Math.min(minScrollDistance, actualScrollDistanceX);
|
|
1154
|
+
actualScrollDistanceX = Math.max(minScrollX, actualScrollDistanceX);
|
|
1155
|
+
}
|
|
1156
|
+
if (0 !== deltaY) {
|
|
1157
|
+
const maxAvailableY = deltaY > 0 ? maxHeight - start.y : start.y;
|
|
1158
|
+
actualScrollDistanceY = Math.min(Math.abs(deltaY), maxAvailableY);
|
|
1159
|
+
const minScrollY = Math.min(minScrollDistance, actualScrollDistanceY);
|
|
1160
|
+
actualScrollDistanceY = Math.max(minScrollY, actualScrollDistanceY);
|
|
1161
|
+
}
|
|
1162
|
+
const endX = Math.round(0 === deltaX ? start.x : deltaX > 0 ? Math.min(maxWidth, start.x + actualScrollDistanceX) : Math.max(0, start.x - actualScrollDistanceX));
|
|
1163
|
+
const endY = Math.round(0 === deltaY ? start.y : deltaY > 0 ? Math.min(maxHeight, start.y + actualScrollDistanceY) : Math.max(0, start.y - actualScrollDistanceY));
|
|
1164
|
+
return {
|
|
1165
|
+
x: endX,
|
|
1166
|
+
y: endY
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
async screenshotBase64() {
|
|
1170
|
+
debugDevice('screenshotBase64 begin');
|
|
1171
|
+
const adapter = this.getScrcpyAdapter();
|
|
1172
|
+
if (adapter.isEnabled()) try {
|
|
1173
|
+
debugDevice('Attempting scrcpy screenshot...');
|
|
1174
|
+
const deviceInfo = await this.getDevicePhysicalInfo();
|
|
1175
|
+
const result = await adapter.screenshotBase64(deviceInfo);
|
|
1176
|
+
debugDevice('screenshotBase64 end (scrcpy mode)');
|
|
1177
|
+
return result;
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
debugDevice(`Scrcpy screenshot failed, falling back to standard ADB method.\nError: ${error}`);
|
|
1180
|
+
}
|
|
1181
|
+
const adb = await this.getAdb();
|
|
1182
|
+
let screenshotBuffer;
|
|
1183
|
+
let localScreenshotPath = null;
|
|
1184
|
+
const screenshotId = Date.now().toString(36);
|
|
1185
|
+
const androidScreenshotPath = `/data/local/tmp/ms_${screenshotId}.png`;
|
|
1186
|
+
const useShellScreencap = 'number' == typeof this.options?.displayId;
|
|
1187
|
+
try {
|
|
1188
|
+
if (!useShellScreencap && this.takeScreenshotFailCount < AndroidDevice.TAKE_SCREENSHOT_FAIL_THRESHOLD) {
|
|
1189
|
+
debugDevice('Taking screenshot via adb.takeScreenshot');
|
|
1190
|
+
screenshotBuffer = await adb.takeScreenshot(null);
|
|
1191
|
+
debugDevice('adb.takeScreenshot completed');
|
|
1192
|
+
if (!screenshotBuffer) {
|
|
1193
|
+
this.takeScreenshotFailCount++;
|
|
1194
|
+
throw new Error('Failed to capture screenshot: screenshotBuffer is null');
|
|
1195
|
+
}
|
|
1196
|
+
if (!isValidImageBuffer(screenshotBuffer)) {
|
|
1197
|
+
debugDevice('Invalid image buffer detected: not a valid image format');
|
|
1198
|
+
this.takeScreenshotFailCount++;
|
|
1199
|
+
throw new Error('Screenshot buffer has invalid format: could not find valid image signature');
|
|
1200
|
+
}
|
|
1201
|
+
this.takeScreenshotFailCount = 0;
|
|
1202
|
+
} else {
|
|
1203
|
+
if (this.takeScreenshotFailCount >= AndroidDevice.TAKE_SCREENSHOT_FAIL_THRESHOLD) debugDevice('Skipping takeScreenshot (failed %d consecutive times), using shell screencap directly', this.takeScreenshotFailCount);
|
|
1204
|
+
throw new Error('Using shell screencap directly');
|
|
1205
|
+
}
|
|
1206
|
+
const validScreenshotBufferSize = this.options?.minScreenshotBufferSize ?? 10240;
|
|
1207
|
+
if (validScreenshotBufferSize > 0 && screenshotBuffer.length < validScreenshotBufferSize) {
|
|
1208
|
+
debugDevice(`Screenshot buffer too small: ${screenshotBuffer.length} bytes (minimum: ${validScreenshotBufferSize})`);
|
|
1209
|
+
throw new Error(`Screenshot buffer too small: ${screenshotBuffer.length} bytes (minimum: ${validScreenshotBufferSize})`);
|
|
1210
|
+
}
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
debugDevice(`Taking screenshot via adb.takeScreenshot failed or was skipped: ${error}`);
|
|
1213
|
+
const screenshotPath = getTmpFile('png');
|
|
1214
|
+
localScreenshotPath = screenshotPath;
|
|
1215
|
+
try {
|
|
1216
|
+
debugDevice('Fallback: taking screenshot via shell screencap');
|
|
1217
|
+
const displayId = this.options?.usePhysicalDisplayIdForScreenshot ? await this.getPhysicalDisplayId() : this.options?.displayId;
|
|
1218
|
+
const displayArg = displayId ? `-d ${displayId}` : '';
|
|
1219
|
+
try {
|
|
1220
|
+
await adb.shell(`screencap -p ${displayArg} ${androidScreenshotPath}`.trim());
|
|
1221
|
+
debugDevice('adb.shell screencap completed');
|
|
1222
|
+
} catch (screencapError) {
|
|
1223
|
+
debugDevice('screencap failed, using forceScreenshot');
|
|
1224
|
+
await this.forceScreenshot(androidScreenshotPath);
|
|
1225
|
+
debugDevice('forceScreenshot completed');
|
|
1226
|
+
}
|
|
1227
|
+
debugDevice('Pulling screenshot file from device');
|
|
1228
|
+
await adb.pull(androidScreenshotPath, screenshotPath);
|
|
1229
|
+
debugDevice(`adb.pull completed, local path: ${screenshotPath}`);
|
|
1230
|
+
screenshotBuffer = await external_node_fs_["default"].promises.readFile(screenshotPath);
|
|
1231
|
+
const validScreenshotBufferSize = this.options?.minScreenshotBufferSize ?? 10240;
|
|
1232
|
+
if (!screenshotBuffer || validScreenshotBufferSize > 0 && screenshotBuffer.length < validScreenshotBufferSize) throw new Error(`Fallback screenshot validation failed: buffer size ${screenshotBuffer?.length || 0} bytes (minimum: ${validScreenshotBufferSize})`);
|
|
1233
|
+
if (!isValidImageBuffer(screenshotBuffer)) throw new Error('Fallback screenshot buffer has invalid PNG format');
|
|
1234
|
+
debugDevice(`Fallback screenshot validated successfully: ${screenshotBuffer.length} bytes`);
|
|
1235
|
+
} finally{
|
|
1236
|
+
const adbPath = adb.executable?.path ?? 'adb';
|
|
1237
|
+
const child = execFile(adbPath, [
|
|
1238
|
+
'-s',
|
|
1239
|
+
this.deviceId,
|
|
1240
|
+
'shell',
|
|
1241
|
+
`rm ${androidScreenshotPath}`
|
|
1242
|
+
], {
|
|
1243
|
+
timeout: 3000
|
|
1244
|
+
}, (err)=>{
|
|
1245
|
+
if (err) debugDevice('Failed to delete remote screenshot: %s', err.message);
|
|
1246
|
+
});
|
|
1247
|
+
child.unref();
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
if (!screenshotBuffer) throw new Error('Failed to capture screenshot: all methods failed');
|
|
1251
|
+
debugDevice('Converting to base64');
|
|
1252
|
+
const result = createImgBase64ByFormat('png', screenshotBuffer.toString('base64'));
|
|
1253
|
+
if (localScreenshotPath) {
|
|
1254
|
+
debugDevice(`Deleting local screenshot: ${localScreenshotPath}`);
|
|
1255
|
+
(0, external_node_fs_.unlink)(localScreenshotPath, (unlinkError)=>{
|
|
1256
|
+
if (unlinkError) debugDevice(`Failed to delete screenshot: ${unlinkError}`);
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
debugDevice('screenshotBase64 end');
|
|
1260
|
+
return result;
|
|
1261
|
+
}
|
|
1262
|
+
async clearInput(element) {
|
|
1263
|
+
if (element) await this.mouseClick(element.center[0], element.center[1]);
|
|
1264
|
+
await this.ensureYadb();
|
|
1265
|
+
const adb = await this.getAdb();
|
|
1266
|
+
const IME_STRATEGY = (this.options?.imeStrategy || globalConfigManager.getEnvConfigValue(MIDSCENE_ANDROID_IME_STRATEGY)) ?? IME_STRATEGY_YADB_FOR_NON_ASCII;
|
|
1267
|
+
if (IME_STRATEGY === IME_STRATEGY_YADB_FOR_NON_ASCII) await adb.clearTextField(100);
|
|
1268
|
+
else await adb.shell(`app_process${this.getDisplayArg()} -Djava.class.path=/data/local/tmp/yadb /data/local/tmp com.ysbing.yadb.Main -keyboard "~CLEAR~"`);
|
|
1269
|
+
if (await adb.isSoftKeyboardPresent()) return;
|
|
1270
|
+
if (element) await this.mouseClick(element.center[0], element.center[1]);
|
|
1271
|
+
}
|
|
1272
|
+
async forceScreenshot(path) {
|
|
1273
|
+
await this.ensureYadb();
|
|
1274
|
+
const adb = await this.getAdb();
|
|
1275
|
+
await adb.shell(`app_process -Djava.class.path=/data/local/tmp/yadb /data/local/tmp com.ysbing.yadb.Main -screenshot ${path}`);
|
|
1276
|
+
}
|
|
1277
|
+
async url() {
|
|
1278
|
+
return '';
|
|
1279
|
+
}
|
|
1280
|
+
async scrollUntilTop(startPoint) {
|
|
1281
|
+
if (startPoint) {
|
|
1282
|
+
const { height } = await this.size();
|
|
1283
|
+
const start = {
|
|
1284
|
+
x: Math.round(startPoint.left),
|
|
1285
|
+
y: Math.round(startPoint.top)
|
|
1286
|
+
};
|
|
1287
|
+
const end = {
|
|
1288
|
+
x: start.x,
|
|
1289
|
+
y: Math.round(height)
|
|
1290
|
+
};
|
|
1291
|
+
await repeat(defaultScrollUntilTimes, ()=>this.mouseDrag(start, end, defaultFastScrollDuration));
|
|
1292
|
+
await sleep(1000);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
await repeat(defaultScrollUntilTimes, ()=>this.scroll(0, -9999999, defaultFastScrollDuration));
|
|
1296
|
+
await sleep(1000);
|
|
1297
|
+
}
|
|
1298
|
+
async scrollUntilBottom(startPoint) {
|
|
1299
|
+
if (startPoint) {
|
|
1300
|
+
const start = {
|
|
1301
|
+
x: Math.round(startPoint.left),
|
|
1302
|
+
y: Math.round(startPoint.top)
|
|
1303
|
+
};
|
|
1304
|
+
const end = {
|
|
1305
|
+
x: start.x,
|
|
1306
|
+
y: 0
|
|
1307
|
+
};
|
|
1308
|
+
await repeat(defaultScrollUntilTimes, ()=>this.mouseDrag(start, end, defaultFastScrollDuration));
|
|
1309
|
+
await sleep(1000);
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
await repeat(defaultScrollUntilTimes, ()=>this.scroll(0, 9999999, defaultFastScrollDuration));
|
|
1313
|
+
await sleep(1000);
|
|
1314
|
+
}
|
|
1315
|
+
async scrollUntilLeft(startPoint) {
|
|
1316
|
+
if (startPoint) {
|
|
1317
|
+
const { width } = await this.size();
|
|
1318
|
+
const start = {
|
|
1319
|
+
x: Math.round(startPoint.left),
|
|
1320
|
+
y: Math.round(startPoint.top)
|
|
1321
|
+
};
|
|
1322
|
+
const end = {
|
|
1323
|
+
x: Math.round(width),
|
|
1324
|
+
y: start.y
|
|
1325
|
+
};
|
|
1326
|
+
await repeat(defaultScrollUntilTimes, ()=>this.mouseDrag(start, end, defaultFastScrollDuration));
|
|
1327
|
+
await sleep(1000);
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
await repeat(defaultScrollUntilTimes, ()=>this.scroll(-9999999, 0, defaultFastScrollDuration));
|
|
1331
|
+
await sleep(1000);
|
|
1332
|
+
}
|
|
1333
|
+
async scrollUntilRight(startPoint) {
|
|
1334
|
+
if (startPoint) {
|
|
1335
|
+
const start = {
|
|
1336
|
+
x: Math.round(startPoint.left),
|
|
1337
|
+
y: Math.round(startPoint.top)
|
|
1338
|
+
};
|
|
1339
|
+
const end = {
|
|
1340
|
+
x: 0,
|
|
1341
|
+
y: start.y
|
|
1342
|
+
};
|
|
1343
|
+
await repeat(defaultScrollUntilTimes, ()=>this.mouseDrag(start, end, defaultFastScrollDuration));
|
|
1344
|
+
await sleep(1000);
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
await repeat(defaultScrollUntilTimes, ()=>this.scroll(9999999, 0, defaultFastScrollDuration));
|
|
1348
|
+
await sleep(1000);
|
|
1349
|
+
}
|
|
1350
|
+
async scrollUp(distance, startPoint) {
|
|
1351
|
+
const { height } = await this.size();
|
|
1352
|
+
const scrollDistance = Math.round(distance || height);
|
|
1353
|
+
if (startPoint) {
|
|
1354
|
+
const start = {
|
|
1355
|
+
x: Math.round(startPoint.left),
|
|
1356
|
+
y: Math.round(startPoint.top)
|
|
1357
|
+
};
|
|
1358
|
+
const end = this.calculateScrollEndPoint(start, 0, scrollDistance, 0, height);
|
|
1359
|
+
await this.mouseDrag(start, end);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
await this.scroll(0, -scrollDistance);
|
|
1363
|
+
}
|
|
1364
|
+
async scrollDown(distance, startPoint) {
|
|
1365
|
+
const { height } = await this.size();
|
|
1366
|
+
const scrollDistance = Math.round(distance || height);
|
|
1367
|
+
if (startPoint) {
|
|
1368
|
+
const start = {
|
|
1369
|
+
x: Math.round(startPoint.left),
|
|
1370
|
+
y: Math.round(startPoint.top)
|
|
1371
|
+
};
|
|
1372
|
+
const end = this.calculateScrollEndPoint(start, 0, -scrollDistance, 0, height);
|
|
1373
|
+
await this.mouseDrag(start, end);
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
await this.scroll(0, scrollDistance);
|
|
1377
|
+
}
|
|
1378
|
+
async scrollLeft(distance, startPoint) {
|
|
1379
|
+
const { width } = await this.size();
|
|
1380
|
+
const scrollDistance = Math.round(distance || width);
|
|
1381
|
+
if (startPoint) {
|
|
1382
|
+
const start = {
|
|
1383
|
+
x: Math.round(startPoint.left),
|
|
1384
|
+
y: Math.round(startPoint.top)
|
|
1385
|
+
};
|
|
1386
|
+
const end = this.calculateScrollEndPoint(start, scrollDistance, 0, width, 0);
|
|
1387
|
+
await this.mouseDrag(start, end);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
await this.scroll(-scrollDistance, 0);
|
|
1391
|
+
}
|
|
1392
|
+
async scrollRight(distance, startPoint) {
|
|
1393
|
+
const { width } = await this.size();
|
|
1394
|
+
const scrollDistance = Math.round(distance || width);
|
|
1395
|
+
if (startPoint) {
|
|
1396
|
+
const start = {
|
|
1397
|
+
x: Math.round(startPoint.left),
|
|
1398
|
+
y: Math.round(startPoint.top)
|
|
1399
|
+
};
|
|
1400
|
+
const end = this.calculateScrollEndPoint(start, -scrollDistance, 0, width, 0);
|
|
1401
|
+
await this.mouseDrag(start, end);
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
await this.scroll(scrollDistance, 0);
|
|
1405
|
+
}
|
|
1406
|
+
async ensureYadb() {
|
|
1407
|
+
if (!this.yadbPushed) {
|
|
1408
|
+
const adb = await this.getAdb();
|
|
1409
|
+
const androidPkgJson = (0, external_node_module_.createRequire)(import.meta.url).resolve('@midscene/android/package.json');
|
|
1410
|
+
const yadbBin = external_node_path_["default"].join(external_node_path_["default"].dirname(androidPkgJson), 'bin', 'yadb');
|
|
1411
|
+
await adb.push(yadbBin, '/data/local/tmp');
|
|
1412
|
+
this.yadbPushed = true;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
shouldUseYadbForText(text) {
|
|
1416
|
+
const hasNonAscii = /[\x80-\uFFFF]/.test(text);
|
|
1417
|
+
const hasFormatSpecifiers = /%[a-zA-Z]/.test(text);
|
|
1418
|
+
const hasShellSpecialChars = /[\\`$]/.test(text);
|
|
1419
|
+
const hasBothQuotes = text.includes('"') && text.includes("'");
|
|
1420
|
+
return hasNonAscii || hasFormatSpecifiers || hasShellSpecialChars || hasBothQuotes;
|
|
1421
|
+
}
|
|
1422
|
+
async keyboardType(text, options) {
|
|
1423
|
+
if (!text) return;
|
|
1424
|
+
const adb = await this.getAdb();
|
|
1425
|
+
const IME_STRATEGY = (this.options?.imeStrategy || globalConfigManager.getEnvConfigValue(MIDSCENE_ANDROID_IME_STRATEGY)) ?? IME_STRATEGY_YADB_FOR_NON_ASCII;
|
|
1426
|
+
const shouldAutoDismissKeyboard = options?.autoDismissKeyboard ?? this.options?.autoDismissKeyboard ?? true;
|
|
1427
|
+
const useYadb = IME_STRATEGY === IME_STRATEGY_ALWAYS_YADB || IME_STRATEGY === IME_STRATEGY_YADB_FOR_NON_ASCII && this.shouldUseYadbForText(text);
|
|
1428
|
+
if (useYadb) await this.execYadb(escapeForShell(text));
|
|
1429
|
+
else {
|
|
1430
|
+
const segments = text.split('\n');
|
|
1431
|
+
for(let i = 0; i < segments.length; i++){
|
|
1432
|
+
if (segments[i].length > 0) await adb.inputText(segments[i]);
|
|
1433
|
+
if (i < segments.length - 1) await adb.keyevent(66);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
if (true === shouldAutoDismissKeyboard) await this.hideKeyboard(options);
|
|
1437
|
+
}
|
|
1438
|
+
normalizeKeyName(key) {
|
|
1439
|
+
const keyMap = {
|
|
1440
|
+
enter: 'Enter',
|
|
1441
|
+
backspace: 'Backspace',
|
|
1442
|
+
tab: 'Tab',
|
|
1443
|
+
escape: 'Escape',
|
|
1444
|
+
esc: 'Escape',
|
|
1445
|
+
home: 'Home',
|
|
1446
|
+
end: 'End',
|
|
1447
|
+
arrowup: 'ArrowUp',
|
|
1448
|
+
arrowdown: 'ArrowDown',
|
|
1449
|
+
arrowleft: 'ArrowLeft',
|
|
1450
|
+
arrowright: 'ArrowRight',
|
|
1451
|
+
up: 'ArrowUp',
|
|
1452
|
+
down: 'ArrowDown',
|
|
1453
|
+
left: 'ArrowLeft',
|
|
1454
|
+
right: 'ArrowRight'
|
|
1455
|
+
};
|
|
1456
|
+
const lowerKey = key.toLowerCase();
|
|
1457
|
+
return keyMap[lowerKey] || key;
|
|
1458
|
+
}
|
|
1459
|
+
async keyboardPress(key) {
|
|
1460
|
+
const keyCodeMap = {
|
|
1461
|
+
Enter: 66,
|
|
1462
|
+
Backspace: 67,
|
|
1463
|
+
Tab: 61,
|
|
1464
|
+
ArrowUp: 19,
|
|
1465
|
+
ArrowDown: 20,
|
|
1466
|
+
ArrowLeft: 21,
|
|
1467
|
+
ArrowRight: 22,
|
|
1468
|
+
Escape: 111,
|
|
1469
|
+
Home: 3,
|
|
1470
|
+
End: 123
|
|
1471
|
+
};
|
|
1472
|
+
const adb = await this.getAdb();
|
|
1473
|
+
const normalizedKey = this.normalizeKeyName(key);
|
|
1474
|
+
const keyCode = keyCodeMap[normalizedKey];
|
|
1475
|
+
if (void 0 !== keyCode) await adb.keyevent(keyCode);
|
|
1476
|
+
else if (1 === key.length) {
|
|
1477
|
+
const asciiCode = key.toUpperCase().charCodeAt(0);
|
|
1478
|
+
if (asciiCode >= 65 && asciiCode <= 90) await adb.keyevent(asciiCode - 36);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
startUIAutomatorProcess() {
|
|
1482
|
+
if (this.uiautomatorProcess || this.destroyed) return;
|
|
1483
|
+
const adbPath = this.adb?.executable?.path ?? 'adb';
|
|
1484
|
+
this.uiautomatorProcess = execFile(adbPath, [
|
|
1485
|
+
'-s',
|
|
1486
|
+
this.deviceId,
|
|
1487
|
+
'shell',
|
|
1488
|
+
'CLASSPATH=/data/local/tmp/u2.jar app_process / com.wetest.uia2.Main'
|
|
1489
|
+
], {
|
|
1490
|
+
timeout: 0
|
|
1491
|
+
}, ()=>{
|
|
1492
|
+
this.uiautomatorProcess = null;
|
|
1493
|
+
this.uiautomatorKnownHealthy = false;
|
|
1494
|
+
debugDevice(`[UIAutomator] server process exited for ${this.deviceId}`);
|
|
1495
|
+
});
|
|
1496
|
+
this.uiautomatorProcess.unref();
|
|
1497
|
+
}
|
|
1498
|
+
async ensureUIAutomatorReady(port, timeoutMs = 5000) {
|
|
1499
|
+
if (this.uiautomatorConfirmedUnavailable) return false;
|
|
1500
|
+
if (this.uiautomatorKnownHealthy && this.uiautomatorProcess) return true;
|
|
1501
|
+
const processWasAlive = !!this.uiautomatorProcess;
|
|
1502
|
+
if (!this.uiautomatorProcess) {
|
|
1503
|
+
console.log(`[UIAutomator] server not running on ${this.deviceId}, starting...`);
|
|
1504
|
+
this.startUIAutomatorProcess();
|
|
1505
|
+
}
|
|
1506
|
+
try {
|
|
1507
|
+
const adb = await this.getAdb();
|
|
1508
|
+
await adb.forwardPort(port, 9008);
|
|
1509
|
+
} catch (e) {}
|
|
1510
|
+
const deadline = Date.now() + timeoutMs;
|
|
1511
|
+
let firstIteration = true;
|
|
1512
|
+
while(Date.now() < deadline){
|
|
1513
|
+
if (!firstIteration || !processWasAlive) await new Promise((r)=>setTimeout(r, 500));
|
|
1514
|
+
firstIteration = false;
|
|
1515
|
+
try {
|
|
1516
|
+
const text = await uiautomatorHttpRequest(port, '/ping', void 0, 1000);
|
|
1517
|
+
if ('pong' === text.trim()) {
|
|
1518
|
+
console.log(`[UIAutomator] ✅ server ready on ${this.deviceId} (port ${port})`);
|
|
1519
|
+
this.uiautomatorKnownHealthy = true;
|
|
1520
|
+
return true;
|
|
1521
|
+
}
|
|
1522
|
+
} catch (_) {}
|
|
1523
|
+
}
|
|
1524
|
+
if (this.uiautomatorProcess) {
|
|
1525
|
+
this.uiautomatorProcess.kill();
|
|
1526
|
+
this.uiautomatorProcess = null;
|
|
1527
|
+
}
|
|
1528
|
+
console.warn(`[UIAutomator] ⚠️ server did not respond within ${timeoutMs}ms on ${this.deviceId}. Marking as unavailable. Run 'python -m uiautomator2 init' to install it.`);
|
|
1529
|
+
this.uiautomatorConfirmedUnavailable = true;
|
|
1530
|
+
return false;
|
|
1531
|
+
}
|
|
1532
|
+
async mouseClick(x, y) {
|
|
1533
|
+
const adb = await this.getAdb();
|
|
1534
|
+
const { x: adjustedX, y: adjustedY } = await this.adjustCoordinates(x, y);
|
|
1535
|
+
const localPort = deviceUiautomatorPorts.get(this.deviceId);
|
|
1536
|
+
if (localPort && !this.uiautomatorConfirmedUnavailable) {
|
|
1537
|
+
const performUIAutomatorClick = async ()=>{
|
|
1538
|
+
try {
|
|
1539
|
+
console.log(`[UIAutomator] click(${adjustedX}, ${adjustedY}) via port ${localPort}`);
|
|
1540
|
+
const body = JSON.stringify({
|
|
1541
|
+
jsonrpc: "2.0",
|
|
1542
|
+
id: 1,
|
|
1543
|
+
method: "click",
|
|
1544
|
+
params: [
|
|
1545
|
+
adjustedX,
|
|
1546
|
+
adjustedY
|
|
1547
|
+
]
|
|
1548
|
+
});
|
|
1549
|
+
const resultText = await uiautomatorHttpRequest(localPort, '/jsonrpc/0', body);
|
|
1550
|
+
const result = JSON.parse(resultText);
|
|
1551
|
+
return true === result.result;
|
|
1552
|
+
} catch (err) {
|
|
1553
|
+
return false;
|
|
1554
|
+
}
|
|
1555
|
+
};
|
|
1556
|
+
if (this.uiautomatorKnownHealthy) {
|
|
1557
|
+
const success = await performUIAutomatorClick();
|
|
1558
|
+
if (success) return void debugDevice("[UIAutomator] click success");
|
|
1559
|
+
this.uiautomatorKnownHealthy = false;
|
|
1560
|
+
console.warn(`[UIAutomator] optimistic click failed on ${this.deviceId}, attempting to recover...`);
|
|
1561
|
+
}
|
|
1562
|
+
const ready = await this.ensureUIAutomatorReady(localPort);
|
|
1563
|
+
if (ready) {
|
|
1564
|
+
const success = await performUIAutomatorClick();
|
|
1565
|
+
if (success) {
|
|
1566
|
+
this.uiautomatorKnownHealthy = true;
|
|
1567
|
+
debugDevice("[UIAutomator] click success after recovery");
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
this.uiautomatorKnownHealthy = false;
|
|
1571
|
+
console.warn("[UIAutomator] click unexpected response or failed after recovery, falling back to adb swipe");
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
console.log(`[ADB swipe] input swipe ${adjustedX} ${adjustedY} ${adjustedX} ${adjustedY} 150`);
|
|
1575
|
+
await adb.shell(`input${this.getDisplayArg()} swipe ${adjustedX} ${adjustedY} ${adjustedX} ${adjustedY} 150`);
|
|
1576
|
+
}
|
|
1577
|
+
async mouseDoubleClick(x, y) {
|
|
1578
|
+
const adb = await this.getAdb();
|
|
1579
|
+
const { x: adjustedX, y: adjustedY } = await this.adjustCoordinates(x, y);
|
|
1580
|
+
const tapCommand = `input${this.getDisplayArg()} tap ${adjustedX} ${adjustedY}`;
|
|
1581
|
+
await adb.shell(tapCommand);
|
|
1582
|
+
await sleep(50);
|
|
1583
|
+
await adb.shell(tapCommand);
|
|
1584
|
+
}
|
|
1585
|
+
async mouseMove() {
|
|
1586
|
+
return Promise.resolve();
|
|
1587
|
+
}
|
|
1588
|
+
async mouseDrag(from, to, duration) {
|
|
1589
|
+
const adb = await this.getAdb();
|
|
1590
|
+
const { x: fromX, y: fromY } = await this.adjustCoordinates(from.x, from.y);
|
|
1591
|
+
const { x: toX, y: toY } = await this.adjustCoordinates(to.x, to.y);
|
|
1592
|
+
const swipeDuration = duration ?? defaultNormalScrollDuration;
|
|
1593
|
+
await adb.shell(`input${this.getDisplayArg()} swipe ${fromX} ${fromY} ${toX} ${toY} ${swipeDuration}`);
|
|
1594
|
+
}
|
|
1595
|
+
async scroll(deltaX, deltaY, duration) {
|
|
1596
|
+
if (0 === deltaX && 0 === deltaY) throw new Error('Scroll distance cannot be zero in both directions');
|
|
1597
|
+
const { width, height } = await this.size();
|
|
1598
|
+
const n = 4;
|
|
1599
|
+
const startX = Math.round(deltaX < 0 ? width / n * (n - 1) : width / n);
|
|
1600
|
+
const startY = Math.round(deltaY < 0 ? height / n * (n - 1) : height / n);
|
|
1601
|
+
const maxPositiveDeltaX = startX;
|
|
1602
|
+
const maxNegativeDeltaX = width - startX;
|
|
1603
|
+
const maxPositiveDeltaY = startY;
|
|
1604
|
+
const maxNegativeDeltaY = height - startY;
|
|
1605
|
+
deltaX = Math.max(-maxNegativeDeltaX, Math.min(deltaX, maxPositiveDeltaX));
|
|
1606
|
+
deltaY = Math.max(-maxNegativeDeltaY, Math.min(deltaY, maxPositiveDeltaY));
|
|
1607
|
+
const endX = Math.round(startX - deltaX);
|
|
1608
|
+
const endY = Math.round(startY - deltaY);
|
|
1609
|
+
const { x: adjustedStartX, y: adjustedStartY } = await this.adjustCoordinates(startX, startY);
|
|
1610
|
+
const { x: adjustedEndX, y: adjustedEndY } = await this.adjustCoordinates(endX, endY);
|
|
1611
|
+
const adb = await this.getAdb();
|
|
1612
|
+
const swipeDuration = duration ?? defaultNormalScrollDuration;
|
|
1613
|
+
await adb.shell(`input${this.getDisplayArg()} swipe ${adjustedStartX} ${adjustedStartY} ${adjustedEndX} ${adjustedEndY} ${swipeDuration}`);
|
|
1614
|
+
}
|
|
1615
|
+
async destroy() {
|
|
1616
|
+
if (this.destroyed) return;
|
|
1617
|
+
this.destroyed = true;
|
|
1618
|
+
this.cachedPhysicalDisplayId = void 0;
|
|
1619
|
+
this.cachedScreenSize = null;
|
|
1620
|
+
this.cachedOrientation = null;
|
|
1621
|
+
this.cachedAdjustScale = null;
|
|
1622
|
+
const localPort = deviceUiautomatorPorts.get(this.deviceId);
|
|
1623
|
+
if (localPort && this.adb) {
|
|
1624
|
+
try {
|
|
1625
|
+
await this.adb.removePortForward(localPort);
|
|
1626
|
+
debugDevice(`[UIAutomator] Removed port forwarding for ${this.deviceId} on port ${localPort}`);
|
|
1627
|
+
} catch (e) {}
|
|
1628
|
+
deviceUiautomatorPorts.delete(this.deviceId);
|
|
1629
|
+
}
|
|
1630
|
+
if (this.uiautomatorProcess) {
|
|
1631
|
+
this.uiautomatorProcess.kill();
|
|
1632
|
+
this.uiautomatorProcess = null;
|
|
1633
|
+
debugDevice(`[UIAutomator] Killed uiautomator2 server process for ${this.deviceId}`);
|
|
1634
|
+
}
|
|
1635
|
+
if (this.scrcpyAdapter) {
|
|
1636
|
+
await this.scrcpyAdapter.disconnect();
|
|
1637
|
+
this.scrcpyAdapter = null;
|
|
1638
|
+
}
|
|
1639
|
+
try {
|
|
1640
|
+
if (this.adb) this.adb = null;
|
|
1641
|
+
} catch (error) {
|
|
1642
|
+
console.error('Error during cleanup:', error);
|
|
1643
|
+
}
|
|
1644
|
+
this.connectingAdb = null;
|
|
1645
|
+
this.yadbPushed = false;
|
|
1646
|
+
}
|
|
1647
|
+
async getTimestamp() {
|
|
1648
|
+
const adb = await this.getAdb();
|
|
1649
|
+
try {
|
|
1650
|
+
const stdout = await adb.shell('date +%s%3N');
|
|
1651
|
+
const timestamp = Number.parseInt(stdout.trim(), 10);
|
|
1652
|
+
if (Number.isNaN(timestamp)) throw new Error(`Invalid timestamp format: ${stdout}`);
|
|
1653
|
+
debugDevice(`Got device time: ${timestamp}`);
|
|
1654
|
+
return timestamp;
|
|
1655
|
+
} catch (error) {
|
|
1656
|
+
debugDevice(`Failed to get device time: ${error}`);
|
|
1657
|
+
throw new Error(`Failed to get device time: ${error}`);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
async back() {
|
|
1661
|
+
const adb = await this.getAdb();
|
|
1662
|
+
await adb.shell(`input${this.getDisplayArg()} keyevent 4`);
|
|
1663
|
+
}
|
|
1664
|
+
async home() {
|
|
1665
|
+
const adb = await this.getAdb();
|
|
1666
|
+
await adb.shell(`input${this.getDisplayArg()} keyevent 3`);
|
|
1667
|
+
}
|
|
1668
|
+
async recentApps() {
|
|
1669
|
+
const adb = await this.getAdb();
|
|
1670
|
+
await adb.shell(`input${this.getDisplayArg()} keyevent 187`);
|
|
1671
|
+
}
|
|
1672
|
+
async longPress(x, y, duration = 2000) {
|
|
1673
|
+
const adb = await this.getAdb();
|
|
1674
|
+
const { x: adjustedX, y: adjustedY } = await this.adjustCoordinates(x, y);
|
|
1675
|
+
await adb.shell(`input${this.getDisplayArg()} swipe ${adjustedX} ${adjustedY} ${adjustedX} ${adjustedY} ${duration}`);
|
|
1676
|
+
}
|
|
1677
|
+
async pullDown(startPoint, distance, duration = 800) {
|
|
1678
|
+
const { width, height } = await this.size();
|
|
1679
|
+
const start = startPoint ? {
|
|
1680
|
+
x: Math.round(startPoint.left),
|
|
1681
|
+
y: Math.round(startPoint.top)
|
|
1682
|
+
} : {
|
|
1683
|
+
x: Math.round(width / 2),
|
|
1684
|
+
y: Math.round(0.15 * height)
|
|
1685
|
+
};
|
|
1686
|
+
const pullDistance = Math.round(distance || 0.5 * height);
|
|
1687
|
+
const end = {
|
|
1688
|
+
x: start.x,
|
|
1689
|
+
y: start.y + pullDistance
|
|
1690
|
+
};
|
|
1691
|
+
await this.pullDrag(start, end, duration);
|
|
1692
|
+
await sleep(200);
|
|
1693
|
+
}
|
|
1694
|
+
async pullDrag(from, to, duration) {
|
|
1695
|
+
const adb = await this.getAdb();
|
|
1696
|
+
const { x: fromX, y: fromY } = await this.adjustCoordinates(from.x, from.y);
|
|
1697
|
+
const { x: toX, y: toY } = await this.adjustCoordinates(to.x, to.y);
|
|
1698
|
+
await adb.shell(`input${this.getDisplayArg()} swipe ${fromX} ${fromY} ${toX} ${toY} ${duration}`);
|
|
1699
|
+
}
|
|
1700
|
+
async pullUp(startPoint, distance, duration = 600) {
|
|
1701
|
+
const { width, height } = await this.size();
|
|
1702
|
+
const start = startPoint ? {
|
|
1703
|
+
x: Math.round(startPoint.left),
|
|
1704
|
+
y: Math.round(startPoint.top)
|
|
1705
|
+
} : {
|
|
1706
|
+
x: Math.round(width / 2),
|
|
1707
|
+
y: Math.round(0.85 * height)
|
|
1708
|
+
};
|
|
1709
|
+
const pullDistance = Math.round(distance || 0.4 * height);
|
|
1710
|
+
const end = {
|
|
1711
|
+
x: start.x,
|
|
1712
|
+
y: start.y - pullDistance
|
|
1713
|
+
};
|
|
1714
|
+
await this.pullDrag(start, end, duration);
|
|
1715
|
+
await sleep(100);
|
|
1716
|
+
}
|
|
1717
|
+
getDisplayArg() {
|
|
1718
|
+
return 'number' == typeof this.options?.displayId ? ` -d ${this.options.displayId}` : '';
|
|
1719
|
+
}
|
|
1720
|
+
async getPhysicalDisplayId() {
|
|
1721
|
+
if (void 0 !== this.cachedPhysicalDisplayId) return this.cachedPhysicalDisplayId;
|
|
1722
|
+
if ('number' != typeof this.options?.displayId) {
|
|
1723
|
+
this.cachedPhysicalDisplayId = null;
|
|
1724
|
+
return null;
|
|
1725
|
+
}
|
|
1726
|
+
const adb = await this.getAdb();
|
|
1727
|
+
try {
|
|
1728
|
+
const stdout = await adb.shell(`dumpsys SurfaceFlinger --display-id ${this.options.displayId}`);
|
|
1729
|
+
const regex = new RegExp(`Display (\\d+) \\(HWC display ${this.options.displayId}\\):`);
|
|
1730
|
+
const displayMatch = stdout.match(regex);
|
|
1731
|
+
if (displayMatch?.[1]) {
|
|
1732
|
+
this.cachedPhysicalDisplayId = displayMatch[1];
|
|
1733
|
+
debugDevice(`Found and cached physical display ID: ${displayMatch[1]} for display ID: ${this.options.displayId}`);
|
|
1734
|
+
return this.cachedPhysicalDisplayId;
|
|
1735
|
+
}
|
|
1736
|
+
this.cachedPhysicalDisplayId = null;
|
|
1737
|
+
debugDevice(`Could not find physical display ID for display ID: ${this.options.displayId}`);
|
|
1738
|
+
return null;
|
|
1739
|
+
} catch (error) {
|
|
1740
|
+
debugDevice(`Error getting physical display ID: ${error}`);
|
|
1741
|
+
this.cachedPhysicalDisplayId = null;
|
|
1742
|
+
return null;
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
async hideKeyboard(options, timeoutMs = 1000) {
|
|
1746
|
+
const adb = await this.getAdb();
|
|
1747
|
+
const keyboardDismissStrategy = options?.keyboardDismissStrategy ?? this.options?.keyboardDismissStrategy ?? 'esc-first';
|
|
1748
|
+
const keyboardStatus = await adb.isSoftKeyboardPresent();
|
|
1749
|
+
const isKeyboardShown = 'boolean' == typeof keyboardStatus ? keyboardStatus : keyboardStatus?.isKeyboardShown;
|
|
1750
|
+
if (!isKeyboardShown) {
|
|
1751
|
+
debugDevice('Keyboard has no UI; no closing necessary');
|
|
1752
|
+
return false;
|
|
1753
|
+
}
|
|
1754
|
+
const keyCodes = 'back-first' === keyboardDismissStrategy ? [
|
|
1755
|
+
4,
|
|
1756
|
+
111
|
|
1757
|
+
] : [
|
|
1758
|
+
111,
|
|
1759
|
+
4
|
|
1760
|
+
];
|
|
1761
|
+
for (const keyCode of keyCodes){
|
|
1762
|
+
await adb.keyevent(keyCode);
|
|
1763
|
+
const startTime = Date.now();
|
|
1764
|
+
const intervalMs = 100;
|
|
1765
|
+
while(Date.now() - startTime < timeoutMs){
|
|
1766
|
+
await sleep(intervalMs);
|
|
1767
|
+
const currentStatus = await adb.isSoftKeyboardPresent();
|
|
1768
|
+
const isStillShown = 'boolean' == typeof currentStatus ? currentStatus : currentStatus?.isKeyboardShown;
|
|
1769
|
+
if (!isStillShown) {
|
|
1770
|
+
debugDevice(`Keyboard hidden successfully with keycode ${keyCode}`);
|
|
1771
|
+
return true;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
debugDevice(`Keyboard still shown after keycode ${keyCode}, trying next key`);
|
|
1775
|
+
}
|
|
1776
|
+
console.warn('Warning: Failed to hide the software keyboard after trying both ESC and BACK keys');
|
|
1777
|
+
return false;
|
|
1778
|
+
}
|
|
1779
|
+
constructor(deviceId, options){
|
|
1780
|
+
device_define_property(this, "deviceId", void 0);
|
|
1781
|
+
device_define_property(this, "yadbPushed", false);
|
|
1782
|
+
device_define_property(this, "devicePixelRatio", 1);
|
|
1783
|
+
device_define_property(this, "devicePixelRatioInitialized", false);
|
|
1784
|
+
device_define_property(this, "adb", null);
|
|
1785
|
+
device_define_property(this, "connectingAdb", null);
|
|
1786
|
+
device_define_property(this, "destroyed", false);
|
|
1787
|
+
device_define_property(this, "description", void 0);
|
|
1788
|
+
device_define_property(this, "customActions", void 0);
|
|
1789
|
+
device_define_property(this, "cachedScreenSize", null);
|
|
1790
|
+
device_define_property(this, "cachedOrientation", null);
|
|
1791
|
+
device_define_property(this, "cachedPhysicalDisplayId", void 0);
|
|
1792
|
+
device_define_property(this, "scrcpyAdapter", null);
|
|
1793
|
+
device_define_property(this, "appNameMapping", {});
|
|
1794
|
+
device_define_property(this, "cachedAdjustScale", null);
|
|
1795
|
+
device_define_property(this, "takeScreenshotFailCount", 0);
|
|
1796
|
+
device_define_property(this, "uiautomatorProcess", null);
|
|
1797
|
+
device_define_property(this, "uiautomatorConfirmedUnavailable", false);
|
|
1798
|
+
device_define_property(this, "uiautomatorKnownHealthy", false);
|
|
1799
|
+
device_define_property(this, "interfaceType", 'android');
|
|
1800
|
+
device_define_property(this, "uri", void 0);
|
|
1801
|
+
device_define_property(this, "options", void 0);
|
|
1802
|
+
node_assert(deviceId, 'deviceId is required for AndroidDevice');
|
|
1803
|
+
this.deviceId = deviceId;
|
|
1804
|
+
this.options = options;
|
|
1805
|
+
this.customActions = options?.customActions;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
device_define_property(AndroidDevice, "TAKE_SCREENSHOT_FAIL_THRESHOLD", 3);
|
|
1809
|
+
const runAdbShellParamSchema = z.object({
|
|
1810
|
+
command: z.string().describe('ADB shell command to execute')
|
|
1811
|
+
});
|
|
1812
|
+
const launchParamSchema = z.object({
|
|
1813
|
+
uri: z.string().describe('App name, package name, or URL to launch. Prioritize using the exact package name or URL the user has provided. If none provided, use the accurate app name.')
|
|
1814
|
+
});
|
|
1815
|
+
const createPlatformActions = (device)=>({
|
|
1816
|
+
RunAdbShell: defineAction({
|
|
1817
|
+
name: 'RunAdbShell',
|
|
1818
|
+
description: 'Execute ADB shell command on Android device',
|
|
1819
|
+
interfaceAlias: 'runAdbShell',
|
|
1820
|
+
paramSchema: runAdbShellParamSchema,
|
|
1821
|
+
sample: {
|
|
1822
|
+
command: 'dumpsys window displays | grep -E "mCurrentFocus"'
|
|
1823
|
+
},
|
|
1824
|
+
call: async (param)=>{
|
|
1825
|
+
if (!param.command || '' === param.command.trim()) throw new Error('RunAdbShell requires a non-empty command parameter');
|
|
1826
|
+
const adb = await device.getAdb();
|
|
1827
|
+
return await adb.shell(param.command);
|
|
1828
|
+
}
|
|
1829
|
+
}),
|
|
1830
|
+
Launch: defineAction({
|
|
1831
|
+
name: 'Launch',
|
|
1832
|
+
description: 'Launch an Android app or URL',
|
|
1833
|
+
interfaceAlias: 'launch',
|
|
1834
|
+
paramSchema: launchParamSchema,
|
|
1835
|
+
sample: {
|
|
1836
|
+
uri: 'com.example.app'
|
|
1837
|
+
},
|
|
1838
|
+
call: async (param)=>{
|
|
1839
|
+
if (!param.uri || '' === param.uri.trim()) throw new Error('Launch requires a non-empty uri parameter');
|
|
1840
|
+
await device.launch(param.uri);
|
|
1841
|
+
}
|
|
1842
|
+
}),
|
|
1843
|
+
AndroidBackButton: defineAction({
|
|
1844
|
+
name: 'AndroidBackButton',
|
|
1845
|
+
description: 'Trigger the system "back" operation on Android devices',
|
|
1846
|
+
call: async ()=>{
|
|
1847
|
+
await device.back();
|
|
1848
|
+
}
|
|
1849
|
+
}),
|
|
1850
|
+
AndroidHomeButton: defineAction({
|
|
1851
|
+
name: 'AndroidHomeButton',
|
|
1852
|
+
description: 'Trigger the system "home" operation on Android devices',
|
|
1853
|
+
call: async ()=>{
|
|
1854
|
+
await device.home();
|
|
1855
|
+
}
|
|
1856
|
+
}),
|
|
1857
|
+
AndroidRecentAppsButton: defineAction({
|
|
1858
|
+
name: 'AndroidRecentAppsButton',
|
|
1859
|
+
description: 'Trigger the system "recent apps" operation on Android devices',
|
|
1860
|
+
call: async ()=>{
|
|
1861
|
+
await device.recentApps();
|
|
1862
|
+
}
|
|
1863
|
+
})
|
|
1864
|
+
});
|
|
1865
|
+
const defaultAppNameMapping = {
|
|
1866
|
+
微信: 'com.tencent.mm',
|
|
1867
|
+
QQ: 'com.tencent.mobileqq',
|
|
1868
|
+
微博: 'com.sina.weibo',
|
|
1869
|
+
淘宝: 'com.taobao.taobao',
|
|
1870
|
+
京东: 'com.jingdong.app.mall',
|
|
1871
|
+
拼多多: 'com.xunmeng.pinduoduo',
|
|
1872
|
+
淘宝闪购: 'com.taobao.taobao',
|
|
1873
|
+
京东秒送: 'com.jingdong.app.mall',
|
|
1874
|
+
小红书: 'com.xingin.xhs',
|
|
1875
|
+
豆瓣: 'com.douban.frodo',
|
|
1876
|
+
知乎: 'com.zhihu.android',
|
|
1877
|
+
高德地图: 'com.autonavi.minimap',
|
|
1878
|
+
百度地图: 'com.baidu.BaiduMap',
|
|
1879
|
+
美团: 'com.sankuai.meituan',
|
|
1880
|
+
大众点评: 'com.dianping.v1',
|
|
1881
|
+
饿了么: 'me.ele',
|
|
1882
|
+
肯德基: 'com.yek.android.kfc.activitys',
|
|
1883
|
+
携程: 'ctrip.android.view',
|
|
1884
|
+
铁路12306: 'com.MobileTicket',
|
|
1885
|
+
12306: 'com.MobileTicket',
|
|
1886
|
+
去哪儿旅行: 'com.Qunar',
|
|
1887
|
+
滴滴出行: 'com.sdu.didi.psnger',
|
|
1888
|
+
bilibili: 'tv.danmaku.bili',
|
|
1889
|
+
抖音: 'com.ss.android.ugc.aweme',
|
|
1890
|
+
懂车帝: 'com.ss.android.auto',
|
|
1891
|
+
快手: 'com.smile.gifmaker',
|
|
1892
|
+
腾讯视频: 'com.tencent.qqlive',
|
|
1893
|
+
爱奇艺: 'com.qiyi.video',
|
|
1894
|
+
优酷视频: 'com.youku.phone',
|
|
1895
|
+
芒果TV: 'com.hunantv.imgo.activity',
|
|
1896
|
+
红果短剧: 'com.phoenix.read',
|
|
1897
|
+
网易云音乐: 'com.netease.cloudmusic',
|
|
1898
|
+
QQ音乐: 'com.tencent.qqmusic',
|
|
1899
|
+
汽水音乐: 'com.luna.music',
|
|
1900
|
+
喜马拉雅: 'com.ximalaya.ting.android',
|
|
1901
|
+
番茄小说: 'com.dragon.read',
|
|
1902
|
+
七猫免费小说: 'com.kmxs.reader',
|
|
1903
|
+
飞书: 'com.ss.android.lark',
|
|
1904
|
+
QQ邮箱: 'com.tencent.androidqqmail',
|
|
1905
|
+
豆包: 'com.larus.nova',
|
|
1906
|
+
Keep: 'com.gotokeep.keep',
|
|
1907
|
+
美柚: 'com.lingan.seeyou',
|
|
1908
|
+
腾讯新闻: 'com.tencent.news',
|
|
1909
|
+
今日头条: 'com.ss.android.article.news',
|
|
1910
|
+
贝壳找房: 'com.lianjia.beike',
|
|
1911
|
+
安居客: 'com.anjuke.android.app',
|
|
1912
|
+
同花顺: 'com.hexin.plat.android',
|
|
1913
|
+
星穹铁道: 'com.miHoYo.hkrpg',
|
|
1914
|
+
'崩坏:星穹铁道': 'com.miHoYo.hkrpg',
|
|
1915
|
+
恋与深空: 'com.papegames.lysk.cn',
|
|
1916
|
+
'Android System Settings': 'com.android.settings',
|
|
1917
|
+
Settings: 'com.android.settings',
|
|
1918
|
+
'Audio Recorder': 'com.android.soundrecorder',
|
|
1919
|
+
Clock: 'com.android.deskclock',
|
|
1920
|
+
Contacts: 'com.android.contacts',
|
|
1921
|
+
Files: 'com.android.fileexplorer',
|
|
1922
|
+
'Google Chrome': 'com.android.chrome',
|
|
1923
|
+
Gmail: 'com.google.android.gm',
|
|
1924
|
+
'Google Files': 'com.google.android.apps.nbu.files',
|
|
1925
|
+
'Google Calendar': 'com.google.android.calendar',
|
|
1926
|
+
'Google Chat': 'com.google.android.apps.dynamite',
|
|
1927
|
+
'Google Clock': 'com.google.android.deskclock',
|
|
1928
|
+
'Google Contacts': 'com.google.android.contacts',
|
|
1929
|
+
'Google Docs': 'com.google.android.apps.docs.editors.docs',
|
|
1930
|
+
'Google Drive': 'com.google.android.apps.docs',
|
|
1931
|
+
'Google Fit': 'com.google.android.apps.fitness',
|
|
1932
|
+
'Google Keep': 'com.google.android.keep',
|
|
1933
|
+
'Google Maps': 'com.google.android.apps.maps',
|
|
1934
|
+
'Google Play Books': 'com.google.android.apps.books',
|
|
1935
|
+
'Google Play Store': 'com.android.vending',
|
|
1936
|
+
'Google Slides': 'com.google.android.apps.docs.editors.slides',
|
|
1937
|
+
'Google Tasks': 'com.google.android.apps.tasks',
|
|
1938
|
+
Bluecoins: 'com.rammigsoftware.bluecoins',
|
|
1939
|
+
Broccoli: 'com.flauschcode.broccoli',
|
|
1940
|
+
'Booking.com': 'com.booking',
|
|
1941
|
+
Duolingo: 'com.duolingo',
|
|
1942
|
+
Expedia: 'com.expedia.bookings',
|
|
1943
|
+
Joplin: 'net.cozic.joplin',
|
|
1944
|
+
McDonald: 'com.mcdonalds.app',
|
|
1945
|
+
Osmand: 'net.osmand',
|
|
1946
|
+
PiMusicPlayer: 'com.Project100Pi.themusicplayer',
|
|
1947
|
+
Quora: 'com.quora.android',
|
|
1948
|
+
Reddit: 'com.reddit.frontpage',
|
|
1949
|
+
RetroMusic: 'code.name.monkey.retromusic',
|
|
1950
|
+
SimpleCalendarPro: 'com.scientificcalculatorplus.simplecalculator.basiccalculator.mathcalc',
|
|
1951
|
+
SimpleSMSMessenger: 'com.simplemobiletools.smsmessenger',
|
|
1952
|
+
Telegram: 'org.telegram.messenger',
|
|
1953
|
+
Temu: 'com.einnovation.temu',
|
|
1954
|
+
TikTok: 'com.zhiliaoapp.musically',
|
|
1955
|
+
Twitter: 'com.twitter.android',
|
|
1956
|
+
X: 'com.twitter.android',
|
|
1957
|
+
VLC: 'org.videolan.vlc',
|
|
1958
|
+
WeChat: 'com.tencent.mm',
|
|
1959
|
+
WhatsApp: 'com.whatsapp'
|
|
1960
|
+
};
|
|
1961
|
+
const debugUtils = (0, logger_.getDebug)('android:utils');
|
|
1962
|
+
const DETAIL_LOOKUP_ADB_TIMEOUT_MS = 8000;
|
|
1963
|
+
const DETAIL_LOOKUP_STEP_TIMEOUT_MS = 2000;
|
|
1964
|
+
function cleanProp(value) {
|
|
1965
|
+
const normalized = value?.trim();
|
|
1966
|
+
return normalized ? normalized : void 0;
|
|
1967
|
+
}
|
|
1968
|
+
function parseResolution(stdout) {
|
|
1969
|
+
const overrideSize = stdout.match(/Override size:\s*([^\r\n]+)/);
|
|
1970
|
+
if (overrideSize?.[1]) return overrideSize[1].trim();
|
|
1971
|
+
const physicalSize = stdout.match(/Physical size:\s*([^\r\n]+)/);
|
|
1972
|
+
if (physicalSize?.[1]) return physicalSize[1].trim();
|
|
1973
|
+
}
|
|
1974
|
+
async function withTimeout(promise, timeoutMs, label) {
|
|
1975
|
+
let timer;
|
|
1976
|
+
try {
|
|
1977
|
+
return await Promise.race([
|
|
1978
|
+
promise,
|
|
1979
|
+
new Promise((_, reject)=>{
|
|
1980
|
+
timer = setTimeout(()=>{
|
|
1981
|
+
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
1982
|
+
}, timeoutMs);
|
|
1983
|
+
})
|
|
1984
|
+
]);
|
|
1985
|
+
} finally{
|
|
1986
|
+
if (timer) clearTimeout(timer);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
async function createAdbForDetailLookup() {
|
|
1990
|
+
return await ADB.createADB({
|
|
1991
|
+
adbExecTimeout: DETAIL_LOOKUP_ADB_TIMEOUT_MS
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
async function getConnectedDevices() {
|
|
1995
|
+
try {
|
|
1996
|
+
const adb = await ADB.createADB({
|
|
1997
|
+
adbExecTimeout: 60000
|
|
1998
|
+
});
|
|
1999
|
+
const devices = await adb.getConnectedDevices();
|
|
2000
|
+
debugUtils(`Found ${devices.length} connected devices: `, devices);
|
|
2001
|
+
return devices;
|
|
2002
|
+
} catch (error) {
|
|
2003
|
+
console.error('Failed to get device list:', error);
|
|
2004
|
+
throw new Error(`Unable to get connected Android device list, please check https://midscenejs.com/integrate-with-android.html#faq : ${error.message}`, {
|
|
2005
|
+
cause: error
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
async function getConnectedDevicesWithDetails() {
|
|
2010
|
+
const devices = await getConnectedDevices();
|
|
2011
|
+
if (0 === devices.length) return [];
|
|
2012
|
+
return await Promise.all(devices.map(async (device)=>{
|
|
2013
|
+
const detailedDevice = {
|
|
2014
|
+
...device
|
|
2015
|
+
};
|
|
2016
|
+
try {
|
|
2017
|
+
const adb = await createAdbForDetailLookup();
|
|
2018
|
+
adb.setDeviceId(device.udid);
|
|
2019
|
+
const [modelResult, brandResult, sizeResult, densityResult] = await Promise.allSettled([
|
|
2020
|
+
withTimeout(adb.shell([
|
|
2021
|
+
'getprop',
|
|
2022
|
+
'ro.product.model'
|
|
2023
|
+
]), DETAIL_LOOKUP_STEP_TIMEOUT_MS, `Android model lookup for ${device.udid}`),
|
|
2024
|
+
withTimeout(adb.shell([
|
|
2025
|
+
'getprop',
|
|
2026
|
+
'ro.product.brand'
|
|
2027
|
+
]), DETAIL_LOOKUP_STEP_TIMEOUT_MS, `Android brand lookup for ${device.udid}`),
|
|
2028
|
+
withTimeout(adb.shell([
|
|
2029
|
+
'wm',
|
|
2030
|
+
'size'
|
|
2031
|
+
]), DETAIL_LOOKUP_STEP_TIMEOUT_MS, `Android resolution lookup for ${device.udid}`),
|
|
2032
|
+
withTimeout(adb.getScreenDensity(), DETAIL_LOOKUP_STEP_TIMEOUT_MS, `Android density lookup for ${device.udid}`)
|
|
2033
|
+
]);
|
|
2034
|
+
if ('fulfilled' === modelResult.status) detailedDevice.model = cleanProp(modelResult.value);
|
|
2035
|
+
if ('fulfilled' === brandResult.status) detailedDevice.brand = cleanProp(brandResult.value);
|
|
2036
|
+
if ('fulfilled' === sizeResult.status) detailedDevice.resolution = parseResolution(sizeResult.value);
|
|
2037
|
+
if ('fulfilled' === densityResult.status && 'number' == typeof densityResult.value) detailedDevice.density = densityResult.value;
|
|
2038
|
+
} catch (error) {
|
|
2039
|
+
debugUtils(`Failed to enrich Android device ${device.udid}:`, error);
|
|
2040
|
+
}
|
|
2041
|
+
return detailedDevice;
|
|
2042
|
+
}));
|
|
2043
|
+
}
|
|
2044
|
+
function agent_define_property(obj, key, value) {
|
|
2045
|
+
if (key in obj) Object.defineProperty(obj, key, {
|
|
2046
|
+
value: value,
|
|
2047
|
+
enumerable: true,
|
|
2048
|
+
configurable: true,
|
|
2049
|
+
writable: true
|
|
2050
|
+
});
|
|
2051
|
+
else obj[key] = value;
|
|
2052
|
+
return obj;
|
|
2053
|
+
}
|
|
2054
|
+
const debugAgent = (0, logger_.getDebug)('android:agent');
|
|
2055
|
+
class AndroidAgent extends Agent {
|
|
2056
|
+
async launch(uri) {
|
|
2057
|
+
const action = this.wrapActionInActionSpace('Launch');
|
|
2058
|
+
return action({
|
|
2059
|
+
uri
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
async runAdbShell(command) {
|
|
2063
|
+
const action = this.wrapActionInActionSpace('RunAdbShell');
|
|
2064
|
+
return action({
|
|
2065
|
+
command
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
createActionWrapper(name) {
|
|
2069
|
+
const action = this.wrapActionInActionSpace(name);
|
|
2070
|
+
return (...args)=>action(args[0]);
|
|
2071
|
+
}
|
|
2072
|
+
constructor(device, opts){
|
|
2073
|
+
super(device, opts), agent_define_property(this, "back", void 0), agent_define_property(this, "home", void 0), agent_define_property(this, "recentApps", void 0), agent_define_property(this, "appNameMapping", void 0);
|
|
2074
|
+
this.appNameMapping = mergeAndNormalizeAppNameMapping(defaultAppNameMapping, opts?.appNameMapping);
|
|
2075
|
+
device.setAppNameMapping(this.appNameMapping);
|
|
2076
|
+
this.back = this.createActionWrapper('AndroidBackButton');
|
|
2077
|
+
this.home = this.createActionWrapper('AndroidHomeButton');
|
|
2078
|
+
this.recentApps = this.createActionWrapper('AndroidRecentAppsButton');
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
async function agentFromAdbDevice(deviceId, opts) {
|
|
2082
|
+
if (!deviceId) {
|
|
2083
|
+
const devices = await getConnectedDevices();
|
|
2084
|
+
if (0 === devices.length) throw new Error('No Android devices found. Please connect an Android device and ensure ADB is properly configured. Run `adb devices` to verify device connection.');
|
|
2085
|
+
deviceId = devices[0].udid;
|
|
2086
|
+
debugAgent('deviceId not specified, will use the first device (id = %s)', deviceId);
|
|
2087
|
+
}
|
|
2088
|
+
const device = new AndroidDevice(deviceId, opts || {});
|
|
2089
|
+
await device.connect();
|
|
2090
|
+
return new AndroidAgent(device, opts);
|
|
2091
|
+
}
|
|
2092
|
+
const debug = (0, logger_.getDebug)('mcp:android-tools');
|
|
2093
|
+
class AndroidMidsceneTools extends BaseMidsceneTools {
|
|
2094
|
+
createTemporaryDevice() {
|
|
2095
|
+
return new AndroidDevice('temp-for-action-space', {});
|
|
2096
|
+
}
|
|
2097
|
+
async ensureAgent(deviceId) {
|
|
2098
|
+
if (this.agent && deviceId) {
|
|
2099
|
+
try {
|
|
2100
|
+
await this.agent.destroy?.();
|
|
2101
|
+
} catch (error) {
|
|
2102
|
+
debug('Failed to destroy agent during cleanup:', error);
|
|
2103
|
+
}
|
|
2104
|
+
this.agent = void 0;
|
|
2105
|
+
}
|
|
2106
|
+
if (this.agent) return this.agent;
|
|
2107
|
+
debug('Creating Android agent with deviceId:', deviceId || 'auto-detect');
|
|
2108
|
+
const agent = await agentFromAdbDevice(deviceId, {
|
|
2109
|
+
autoDismissKeyboard: false
|
|
2110
|
+
});
|
|
2111
|
+
this.agent = agent;
|
|
2112
|
+
return agent;
|
|
2113
|
+
}
|
|
2114
|
+
preparePlatformTools() {
|
|
2115
|
+
return [
|
|
2116
|
+
{
|
|
2117
|
+
name: 'android_connect',
|
|
2118
|
+
description: 'Connect to Android device via ADB. If deviceId not provided, uses the first available device.',
|
|
2119
|
+
schema: {
|
|
2120
|
+
deviceId: z.string().optional().describe('Android device ID (from adb devices)')
|
|
2121
|
+
},
|
|
2122
|
+
handler: async ({ deviceId })=>{
|
|
2123
|
+
const agent = await this.ensureAgent(deviceId);
|
|
2124
|
+
const screenshot = await agent.page.screenshotBase64();
|
|
2125
|
+
return {
|
|
2126
|
+
content: [
|
|
2127
|
+
{
|
|
2128
|
+
type: 'text',
|
|
2129
|
+
text: `Connected to Android device${deviceId ? `: ${deviceId}` : ' (auto-detected)'}`
|
|
2130
|
+
},
|
|
2131
|
+
...this.buildScreenshotContent(screenshot)
|
|
2132
|
+
],
|
|
2133
|
+
isError: false
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
},
|
|
2137
|
+
{
|
|
2138
|
+
name: 'android_disconnect',
|
|
2139
|
+
description: 'Disconnect from current Android device and release ADB resources',
|
|
2140
|
+
schema: {},
|
|
2141
|
+
handler: this.createDisconnectHandler('Android device')
|
|
2142
|
+
}
|
|
2143
|
+
];
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
export { AndroidAgent, AndroidDevice, AndroidMidsceneTools, ScrcpyDeviceAdapter, agentFromAdbDevice, getConnectedDevices, getConnectedDevicesWithDetails, overrideAIConfig };
|