mobile-debug-mcp 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -29
- package/dist/android/interact.js +30 -0
- package/dist/android/observe.js +313 -0
- package/dist/android/utils.js +82 -0
- package/dist/android.js +131 -1
- package/dist/ios/interact.js +65 -0
- package/dist/ios/observe.js +219 -0
- package/dist/ios/utils.js +114 -0
- package/dist/ios.js +134 -0
- package/dist/server.js +84 -27
- package/docs/CHANGELOG.md +5 -0
- package/package.json +2 -1
- package/smoke-test.ts +17 -10
- package/src/android/interact.ts +41 -0
- package/src/android/observe.ts +360 -0
- package/src/android/utils.ts +94 -0
- package/src/ios/interact.ts +75 -0
- package/src/ios/observe.ts +269 -0
- package/src/ios/utils.ts +133 -0
- package/src/server.ts +90 -28
- package/src/types.ts +34 -0
- package/test-ui-tree.js +68 -0
- package/test-ui-tree.ts +76 -0
- package/tsconfig.json +2 -1
- package/src/android.ts +0 -222
- package/src/ios.ts +0 -243
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ This server is designed with security in mind, using strict argument handling to
|
|
|
26
26
|
- Node.js >= 18
|
|
27
27
|
- Android SDK (`adb` in PATH) for Android support
|
|
28
28
|
- Xcode command-line tools (`xcrun simctl`) for iOS support
|
|
29
|
+
- **iOS Device Bridge (`idb`)** for iOS UI tree support
|
|
29
30
|
- Booted iOS simulator for iOS testing
|
|
30
31
|
|
|
31
32
|
---
|
|
@@ -34,7 +35,18 @@ This server is designed with security in mind, using strict argument handling to
|
|
|
34
35
|
|
|
35
36
|
You can install and use **Mobile Debug MCP** in one of two ways:
|
|
36
37
|
|
|
37
|
-
### 1.
|
|
38
|
+
### 1. Install Dependencies
|
|
39
|
+
|
|
40
|
+
**iOS Prerequisite (`idb`):**
|
|
41
|
+
To use the `get_ui_tree` tool on iOS, you must install Facebook's `idb`:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
brew tap facebook/fb
|
|
45
|
+
brew install idb-companion
|
|
46
|
+
pip3 install fb-idb
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 2. Clone the repository for local development
|
|
38
50
|
|
|
39
51
|
```bash
|
|
40
52
|
git clone https://github.com/YOUR_USERNAME/mobile-debug-mcp.git
|
|
@@ -45,7 +57,7 @@ npm run build
|
|
|
45
57
|
|
|
46
58
|
This option is suitable if you want to modify or contribute to the code.
|
|
47
59
|
|
|
48
|
-
###
|
|
60
|
+
### 3. Install via npm for standard use
|
|
49
61
|
|
|
50
62
|
```bash
|
|
51
63
|
npm install -g mobile-debug-mcp
|
|
@@ -55,27 +67,7 @@ This option installs the package globally for easy use without cloning the repo.
|
|
|
55
67
|
|
|
56
68
|
---
|
|
57
69
|
|
|
58
|
-
##
|
|
59
|
-
33.
|
|
60
|
-
34. The repository includes a smoke test script to verify end-to-end functionality on real devices or simulators.
|
|
61
|
-
35.
|
|
62
|
-
36. ```bash
|
|
63
|
-
37. # Run smoke test for Android
|
|
64
|
-
38. npx tsx smoke-test.ts android com.example.package
|
|
65
|
-
39.
|
|
66
|
-
40. # Run smoke test for iOS
|
|
67
|
-
41. npx tsx smoke-test.ts ios com.example.bundleid
|
|
68
|
-
42. ```
|
|
69
|
-
43.
|
|
70
|
-
44. The smoke test performs the following sequence:
|
|
71
|
-
45. 1. Starts the app
|
|
72
|
-
46. 2. Captures a screenshot
|
|
73
|
-
47. 3. Fetches logs
|
|
74
|
-
48. 4. Terminates the app
|
|
75
|
-
49.
|
|
76
|
-
50. ---
|
|
77
|
-
51.
|
|
78
|
-
52. ## MCP Server Configuration
|
|
70
|
+
## MCP Server Configuration
|
|
79
71
|
|
|
80
72
|
Example WebUI MCP config using `npx --yes` and environment variables:
|
|
81
73
|
|
|
@@ -110,7 +102,7 @@ All tools accept a JSON input payload and return a structured JSON response. **E
|
|
|
110
102
|
Launch a mobile app.
|
|
111
103
|
|
|
112
104
|
**Input:**
|
|
113
|
-
```
|
|
105
|
+
```jsonc
|
|
114
106
|
{
|
|
115
107
|
"platform": "android" | "ios",
|
|
116
108
|
"appId": "com.example.app", // Android package or iOS bundle ID (Required)
|
|
@@ -131,7 +123,7 @@ Launch a mobile app.
|
|
|
131
123
|
Fetch recent logs from the app or device.
|
|
132
124
|
|
|
133
125
|
**Input:**
|
|
134
|
-
```
|
|
126
|
+
```jsonc
|
|
135
127
|
{
|
|
136
128
|
"platform": "android" | "ios",
|
|
137
129
|
"appId": "com.example.app", // Optional: filter logs by app
|
|
@@ -155,7 +147,7 @@ Returns two content blocks:
|
|
|
155
147
|
Capture a screenshot of the current device screen.
|
|
156
148
|
|
|
157
149
|
**Input:**
|
|
158
|
-
```
|
|
150
|
+
```jsonc
|
|
159
151
|
{
|
|
160
152
|
"platform": "android" | "ios",
|
|
161
153
|
"deviceId": "emulator-5554" // Optional: target specific device
|
|
@@ -177,7 +169,7 @@ Returns two content blocks:
|
|
|
177
169
|
Terminate a running app.
|
|
178
170
|
|
|
179
171
|
**Input:**
|
|
180
|
-
```
|
|
172
|
+
```jsonc
|
|
181
173
|
{
|
|
182
174
|
"platform": "android" | "ios",
|
|
183
175
|
"appId": "com.example.app", // Android package or iOS bundle ID (Required)
|
|
@@ -197,7 +189,7 @@ Terminate a running app.
|
|
|
197
189
|
Restart an app (terminate then launch).
|
|
198
190
|
|
|
199
191
|
**Input:**
|
|
200
|
-
```
|
|
192
|
+
```jsonc
|
|
201
193
|
{
|
|
202
194
|
"platform": "android" | "ios",
|
|
203
195
|
"appId": "com.example.app", // Android package or iOS bundle ID (Required)
|
|
@@ -218,7 +210,7 @@ Restart an app (terminate then launch).
|
|
|
218
210
|
Clear app storage (reset to fresh install state).
|
|
219
211
|
|
|
220
212
|
**Input:**
|
|
221
|
-
```
|
|
213
|
+
```jsonc
|
|
222
214
|
{
|
|
223
215
|
"platform": "android" | "ios",
|
|
224
216
|
"appId": "com.example.app", // Android package or iOS bundle ID (Required)
|
|
@@ -234,6 +226,62 @@ Clear app storage (reset to fresh install state).
|
|
|
234
226
|
}
|
|
235
227
|
```
|
|
236
228
|
|
|
229
|
+
### get_ui_tree
|
|
230
|
+
Get the current UI hierarchy from the device. Returns a structured JSON representation of the screen content.
|
|
231
|
+
|
|
232
|
+
**Input:**
|
|
233
|
+
```jsonc
|
|
234
|
+
{
|
|
235
|
+
"platform": "android" | "ios",
|
|
236
|
+
"deviceId": "emulator-5554" // Optional
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**Response:**
|
|
241
|
+
```json
|
|
242
|
+
{
|
|
243
|
+
"device": { /* device info */ },
|
|
244
|
+
"screen": "",
|
|
245
|
+
"resolution": { "width": 1080, "height": 1920 },
|
|
246
|
+
"elements": [
|
|
247
|
+
{
|
|
248
|
+
"text": "Login",
|
|
249
|
+
"contentDescription": null,
|
|
250
|
+
"type": "android.widget.Button",
|
|
251
|
+
"resourceId": "com.example:id/login_button",
|
|
252
|
+
"clickable": true,
|
|
253
|
+
"enabled": true,
|
|
254
|
+
"visible": true,
|
|
255
|
+
"bounds": [120,400,280,450],
|
|
256
|
+
"center": [200, 425],
|
|
257
|
+
"depth": 1,
|
|
258
|
+
"parentId": 0,
|
|
259
|
+
"children": []
|
|
260
|
+
}
|
|
261
|
+
]
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### get_current_screen
|
|
266
|
+
Get the currently visible activity on an Android device.
|
|
267
|
+
|
|
268
|
+
**Input:**
|
|
269
|
+
```jsonc
|
|
270
|
+
{
|
|
271
|
+
"deviceId": "emulator-5554" // Optional: target specific device
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Response:**
|
|
276
|
+
```json
|
|
277
|
+
{
|
|
278
|
+
"device": { /* device info */ },
|
|
279
|
+
"package": "com.example.app",
|
|
280
|
+
"activity": "com.example.app.LoginActivity",
|
|
281
|
+
"shortActivity": "LoginActivity"
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
237
285
|
---
|
|
238
286
|
|
|
239
287
|
## Recommended Workflow
|
|
@@ -255,6 +303,26 @@ Clear app storage (reset to fresh install state).
|
|
|
255
303
|
|
|
256
304
|
---
|
|
257
305
|
|
|
306
|
+
## Testing
|
|
307
|
+
|
|
308
|
+
The repository includes a smoke test script to verify end-to-end functionality on real devices or simulators.
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
# Run smoke test for Android
|
|
312
|
+
npx tsx smoke-test.ts android com.example.package
|
|
313
|
+
|
|
314
|
+
# Run smoke test for iOS
|
|
315
|
+
npx tsx smoke-test.ts ios com.example.bundleid
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
The smoke test performs the following sequence:
|
|
319
|
+
1. Starts the app
|
|
320
|
+
2. Captures a screenshot
|
|
321
|
+
3. Fetches logs
|
|
322
|
+
4. Terminates the app
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
258
326
|
## License
|
|
259
327
|
|
|
260
328
|
MIT License
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js";
|
|
2
|
+
export class AndroidInteract {
|
|
3
|
+
async startApp(appId, deviceId) {
|
|
4
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
5
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
6
|
+
await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
|
|
7
|
+
return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
|
|
8
|
+
}
|
|
9
|
+
async terminateApp(appId, deviceId) {
|
|
10
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
11
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
12
|
+
await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
|
|
13
|
+
return { device: deviceInfo, appTerminated: true };
|
|
14
|
+
}
|
|
15
|
+
async restartApp(appId, deviceId) {
|
|
16
|
+
await this.terminateApp(appId, deviceId);
|
|
17
|
+
const startResult = await this.startApp(appId, deviceId);
|
|
18
|
+
return {
|
|
19
|
+
device: startResult.device,
|
|
20
|
+
appRestarted: startResult.appStarted,
|
|
21
|
+
launchTimeMs: startResult.launchTimeMs
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
async resetAppData(appId, deviceId) {
|
|
25
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
26
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
27
|
+
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
|
|
28
|
+
return { device: deviceInfo, dataCleared: output === 'Success' };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { XMLParser } from "fast-xml-parser";
|
|
3
|
+
import { ADB, execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js";
|
|
4
|
+
// --- Helper Functions Specific to Observe ---
|
|
5
|
+
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
6
|
+
function parseBounds(bounds) {
|
|
7
|
+
const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
|
8
|
+
if (match) {
|
|
9
|
+
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4])];
|
|
10
|
+
}
|
|
11
|
+
return [0, 0, 0, 0];
|
|
12
|
+
}
|
|
13
|
+
function getCenter(bounds) {
|
|
14
|
+
const [x1, y1, x2, y2] = bounds;
|
|
15
|
+
return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
|
|
16
|
+
}
|
|
17
|
+
async function getScreenResolution(deviceId) {
|
|
18
|
+
try {
|
|
19
|
+
const output = await execAdb(['shell', 'wm', 'size'], deviceId);
|
|
20
|
+
const match = output.match(/Physical size: (\d+)x(\d+)/);
|
|
21
|
+
if (match) {
|
|
22
|
+
return { width: parseInt(match[1]), height: parseInt(match[2]) };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
// ignore
|
|
27
|
+
}
|
|
28
|
+
return { width: 0, height: 0 };
|
|
29
|
+
}
|
|
30
|
+
function traverseNode(node, elements, parentIndex = -1, depth = 0) {
|
|
31
|
+
if (!node)
|
|
32
|
+
return -1;
|
|
33
|
+
let currentIndex = -1;
|
|
34
|
+
// Check if it's a valid node with attributes we care about
|
|
35
|
+
if (node['@_class']) {
|
|
36
|
+
const text = node['@_text'] || null;
|
|
37
|
+
const contentDescription = node['@_content-desc'] || null;
|
|
38
|
+
const clickable = node['@_clickable'] === 'true';
|
|
39
|
+
const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
|
|
40
|
+
// Filtering Logic:
|
|
41
|
+
// Keep if clickable OR has visible text OR has content description
|
|
42
|
+
const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
|
|
43
|
+
if (isUseful) {
|
|
44
|
+
const element = {
|
|
45
|
+
text,
|
|
46
|
+
contentDescription,
|
|
47
|
+
type: node['@_class'] || 'unknown',
|
|
48
|
+
resourceId: node['@_resource-id'] || null,
|
|
49
|
+
clickable,
|
|
50
|
+
enabled: node['@_enabled'] === 'true',
|
|
51
|
+
visible: true,
|
|
52
|
+
bounds,
|
|
53
|
+
center: getCenter(bounds),
|
|
54
|
+
depth
|
|
55
|
+
};
|
|
56
|
+
if (parentIndex !== -1) {
|
|
57
|
+
element.parentId = parentIndex;
|
|
58
|
+
}
|
|
59
|
+
elements.push(element);
|
|
60
|
+
currentIndex = elements.length - 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// If current node was skipped (not useful or no class), children inherit parentIndex
|
|
64
|
+
// If current node was added, children use currentIndex
|
|
65
|
+
const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
|
|
66
|
+
const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
|
|
67
|
+
const childrenIndices = [];
|
|
68
|
+
// Traverse children
|
|
69
|
+
if (node.node) {
|
|
70
|
+
if (Array.isArray(node.node)) {
|
|
71
|
+
node.node.forEach((child) => {
|
|
72
|
+
const childIndex = traverseNode(child, elements, nextParentIndex, nextDepth);
|
|
73
|
+
if (childIndex !== -1)
|
|
74
|
+
childrenIndices.push(childIndex);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const childIndex = traverseNode(node.node, elements, nextParentIndex, nextDepth);
|
|
79
|
+
if (childIndex !== -1)
|
|
80
|
+
childrenIndices.push(childIndex);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Update current element with children if it was added
|
|
84
|
+
if (currentIndex !== -1 && childrenIndices.length > 0) {
|
|
85
|
+
elements[currentIndex].children = childrenIndices;
|
|
86
|
+
}
|
|
87
|
+
return currentIndex;
|
|
88
|
+
}
|
|
89
|
+
export class AndroidObserve {
|
|
90
|
+
async getDeviceMetadata(appId, deviceId) {
|
|
91
|
+
return getAndroidDeviceMetadata(appId, deviceId);
|
|
92
|
+
}
|
|
93
|
+
async getUITree(deviceId) {
|
|
94
|
+
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
95
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
96
|
+
try {
|
|
97
|
+
// Get screen resolution first
|
|
98
|
+
const resolution = await getScreenResolution(deviceId);
|
|
99
|
+
if (resolution.width === 0 && resolution.height === 0) {
|
|
100
|
+
throw new Error("Failed to get screen resolution. Is the device connected and authorized?");
|
|
101
|
+
}
|
|
102
|
+
// Retry Logic
|
|
103
|
+
let xmlContent = '';
|
|
104
|
+
let attempts = 0;
|
|
105
|
+
const maxAttempts = 3;
|
|
106
|
+
while (attempts < maxAttempts) {
|
|
107
|
+
attempts++;
|
|
108
|
+
try {
|
|
109
|
+
// Stabilization delay
|
|
110
|
+
await delay(300 + (attempts * 100)); // 300ms, 400ms, 500ms...
|
|
111
|
+
// Dump UI hierarchy
|
|
112
|
+
await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
|
|
113
|
+
// Read the file
|
|
114
|
+
xmlContent = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId);
|
|
115
|
+
// Check validity
|
|
116
|
+
if (xmlContent && xmlContent.trim().length > 0 && !xmlContent.includes("ERROR:")) {
|
|
117
|
+
break; // Success
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
console.error(`Attempt ${attempts} failed: ${err}`);
|
|
122
|
+
}
|
|
123
|
+
if (attempts === maxAttempts) {
|
|
124
|
+
throw new Error(`Failed to retrieve valid UI dump after ${maxAttempts} attempts.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const parser = new XMLParser({
|
|
128
|
+
ignoreAttributes: false,
|
|
129
|
+
attributeNamePrefix: "@_"
|
|
130
|
+
});
|
|
131
|
+
const result = parser.parse(xmlContent);
|
|
132
|
+
const elements = [];
|
|
133
|
+
// The root is usually hierarchy -> node
|
|
134
|
+
if (result.hierarchy && result.hierarchy.node) {
|
|
135
|
+
// If the root is an array (unlikely for root, but good to be safe) or single object
|
|
136
|
+
if (Array.isArray(result.hierarchy.node)) {
|
|
137
|
+
result.hierarchy.node.forEach((n) => traverseNode(n, elements));
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
traverseNode(result.hierarchy.node, elements);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
device: deviceInfo,
|
|
145
|
+
screen: "",
|
|
146
|
+
resolution,
|
|
147
|
+
elements
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
const errorMessage = `Failed to get UI tree. ADB Path: '${ADB}'. Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
152
|
+
console.error(errorMessage);
|
|
153
|
+
return {
|
|
154
|
+
device: deviceInfo,
|
|
155
|
+
screen: "",
|
|
156
|
+
resolution: { width: 0, height: 0 },
|
|
157
|
+
elements: [],
|
|
158
|
+
error: errorMessage
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async getLogs(appId, lines = 200, deviceId) {
|
|
163
|
+
const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
|
|
164
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
165
|
+
try {
|
|
166
|
+
// We'll skip PID lookup for now to avoid potential hangs with 'pidof' on some emulators
|
|
167
|
+
// and rely on robust string matching against the log line.
|
|
168
|
+
// Get logs
|
|
169
|
+
const stdout = await execAdb(['logcat', '-d', '-t', lines.toString(), '-v', 'threadtime'], deviceId);
|
|
170
|
+
const allLogs = stdout.split('\n');
|
|
171
|
+
let filteredLogs = allLogs;
|
|
172
|
+
if (appId) {
|
|
173
|
+
// Filter by checking if the line contains the appId string.
|
|
174
|
+
const matchingLogs = allLogs.filter(line => line.includes(appId));
|
|
175
|
+
if (matchingLogs.length > 0) {
|
|
176
|
+
filteredLogs = matchingLogs;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// Fallback: if no logs match the appId, return the raw logs (last N lines)
|
|
180
|
+
// This matches the behavior of the "working" version provided by the user,
|
|
181
|
+
// ensuring they at least see system activity if the app is silent or crashing early.
|
|
182
|
+
filteredLogs = allLogs;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { device: deviceInfo, logs: filteredLogs, logCount: filteredLogs.length };
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
console.error("Error fetching logs:", e);
|
|
189
|
+
return { device: deviceInfo, logs: [], logCount: 0 };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async captureScreen(deviceId) {
|
|
193
|
+
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
194
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
// Need to construct ADB args manually since spawn handles it
|
|
197
|
+
const args = deviceId ? ['-s', deviceId, 'exec-out', 'screencap', '-p'] : ['exec-out', 'screencap', '-p'];
|
|
198
|
+
// Using spawn for screencap as well to ensure consistent process handling
|
|
199
|
+
const child = spawn(ADB, args);
|
|
200
|
+
const chunks = [];
|
|
201
|
+
let stderr = '';
|
|
202
|
+
child.stdout.on('data', (chunk) => {
|
|
203
|
+
chunks.push(Buffer.from(chunk));
|
|
204
|
+
});
|
|
205
|
+
child.stderr.on('data', (data) => {
|
|
206
|
+
stderr += data.toString();
|
|
207
|
+
});
|
|
208
|
+
const timeout = setTimeout(() => {
|
|
209
|
+
child.kill();
|
|
210
|
+
reject(new Error(`ADB screencap timed out after 10s`));
|
|
211
|
+
}, 10000);
|
|
212
|
+
child.on('close', (code) => {
|
|
213
|
+
clearTimeout(timeout);
|
|
214
|
+
if (code !== 0) {
|
|
215
|
+
reject(new Error(stderr.trim() || `Screencap failed with code ${code}`));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const screenshotBuffer = Buffer.concat(chunks);
|
|
219
|
+
const screenshotBase64 = screenshotBuffer.toString('base64');
|
|
220
|
+
// Get resolution
|
|
221
|
+
execAdb(['shell', 'wm', 'size'], deviceId)
|
|
222
|
+
.then(sizeStdout => {
|
|
223
|
+
let width = 0;
|
|
224
|
+
let height = 0;
|
|
225
|
+
const match = sizeStdout.match(/Physical size: (\d+)x(\d+)/);
|
|
226
|
+
if (match) {
|
|
227
|
+
width = parseInt(match[1], 10);
|
|
228
|
+
height = parseInt(match[2], 10);
|
|
229
|
+
}
|
|
230
|
+
resolve({
|
|
231
|
+
device: deviceInfo,
|
|
232
|
+
screenshot: screenshotBase64,
|
|
233
|
+
resolution: { width, height }
|
|
234
|
+
});
|
|
235
|
+
})
|
|
236
|
+
.catch(() => {
|
|
237
|
+
resolve({
|
|
238
|
+
device: deviceInfo,
|
|
239
|
+
screenshot: screenshotBase64,
|
|
240
|
+
resolution: { width: 0, height: 0 }
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
child.on('error', (err) => {
|
|
245
|
+
clearTimeout(timeout);
|
|
246
|
+
reject(err);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
async getCurrentScreen(deviceId) {
|
|
251
|
+
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
252
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
253
|
+
try {
|
|
254
|
+
// Dumpsys activity can be slow on some devices, so we increase timeout to 10s
|
|
255
|
+
const output = await execAdb(['shell', 'dumpsys', 'activity', 'activities'], deviceId, { timeout: 10000 });
|
|
256
|
+
// Find the line with mResumedActivity or ResumedActivity (some versions might differ)
|
|
257
|
+
const lines = output.split('\n');
|
|
258
|
+
// Prioritize mResumedActivity, then ResumedActivity.
|
|
259
|
+
// Use strict regex match to ensure it starts with the key, avoiding false positives like 'mLastResumedActivity'.
|
|
260
|
+
let resumedLine = lines.find(line => /^\s*mResumedActivity:/.test(line));
|
|
261
|
+
if (!resumedLine) {
|
|
262
|
+
resumedLine = lines.find(line => /^\s*ResumedActivity:/.test(line));
|
|
263
|
+
}
|
|
264
|
+
if (!resumedLine) {
|
|
265
|
+
return {
|
|
266
|
+
device: deviceInfo,
|
|
267
|
+
package: "",
|
|
268
|
+
activity: "",
|
|
269
|
+
shortActivity: "",
|
|
270
|
+
error: "Could not find 'mResumedActivity' in dumpsys output"
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
// Regex to parse the line: ActivityRecord{... package/activity ...}
|
|
274
|
+
// Matches: ActivityRecord{<hex> <user> <package>/<activity> ...}
|
|
275
|
+
// We want to capture the component "package/activity" which is separated by space from other tokens.
|
|
276
|
+
// We use greedy match ([^ \{}]+) for activity to ensure we get the full name until a space or closing brace.
|
|
277
|
+
const match = resumedLine.match(/ActivityRecord\{[^ ]*(?:\s+[^ ]+)*\s+([^\/ ]+)\/([^ \{}]+)[^}]*\}/);
|
|
278
|
+
if (match) {
|
|
279
|
+
const packageName = match[1];
|
|
280
|
+
let activityName = match[2];
|
|
281
|
+
// Handle relative activity names (e.g. .LoginActivity)
|
|
282
|
+
if (activityName.startsWith('.')) {
|
|
283
|
+
activityName = packageName + activityName;
|
|
284
|
+
}
|
|
285
|
+
const shortActivity = activityName.split('.').pop() || activityName;
|
|
286
|
+
return {
|
|
287
|
+
device: deviceInfo,
|
|
288
|
+
package: packageName,
|
|
289
|
+
activity: activityName,
|
|
290
|
+
shortActivity: shortActivity
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
return {
|
|
295
|
+
device: deviceInfo,
|
|
296
|
+
package: "",
|
|
297
|
+
activity: "",
|
|
298
|
+
shortActivity: "",
|
|
299
|
+
error: `Found resumed activity line but failed to parse: '${resumedLine.trim()}'`
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
return {
|
|
305
|
+
device: deviceInfo,
|
|
306
|
+
package: "",
|
|
307
|
+
activity: "",
|
|
308
|
+
shortActivity: "",
|
|
309
|
+
error: e instanceof Error ? e.message : String(e)
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
export const ADB = process.env.ADB_PATH || "adb";
|
|
3
|
+
// Helper to construct ADB args with optional device ID
|
|
4
|
+
function getAdbArgs(args, deviceId) {
|
|
5
|
+
if (deviceId) {
|
|
6
|
+
return ['-s', deviceId, ...args];
|
|
7
|
+
}
|
|
8
|
+
return args;
|
|
9
|
+
}
|
|
10
|
+
export function execAdb(args, deviceId, options = {}) {
|
|
11
|
+
const adbArgs = getAdbArgs(args, deviceId);
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
// Extract timeout from options if present, otherwise pass options to spawn
|
|
14
|
+
const { timeout: customTimeout, ...spawnOptions } = options;
|
|
15
|
+
// Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
|
|
16
|
+
const child = spawn(ADB, adbArgs, spawnOptions);
|
|
17
|
+
let stdout = '';
|
|
18
|
+
let stderr = '';
|
|
19
|
+
if (child.stdout) {
|
|
20
|
+
child.stdout.on('data', (data) => {
|
|
21
|
+
stdout += data.toString();
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (child.stderr) {
|
|
25
|
+
child.stderr.on('data', (data) => {
|
|
26
|
+
stderr += data.toString();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
let timeoutMs = customTimeout || 2000;
|
|
30
|
+
if (!customTimeout) {
|
|
31
|
+
if (args.includes('logcat')) {
|
|
32
|
+
timeoutMs = 10000;
|
|
33
|
+
}
|
|
34
|
+
else if (args.includes('uiautomator') && args.includes('dump')) {
|
|
35
|
+
timeoutMs = 20000; // UI dump can be slow
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const timeout = setTimeout(() => {
|
|
39
|
+
child.kill();
|
|
40
|
+
reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`));
|
|
41
|
+
}, timeoutMs);
|
|
42
|
+
child.on('close', (code) => {
|
|
43
|
+
clearTimeout(timeout);
|
|
44
|
+
if (code !== 0) {
|
|
45
|
+
// If there's an actual error (non-zero exit code), reject
|
|
46
|
+
reject(new Error(stderr.trim() || `Command failed with code ${code}`));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
// If exit code is 0, resolve with stdout
|
|
50
|
+
resolve(stdout.trim());
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
child.on('error', (err) => {
|
|
54
|
+
clearTimeout(timeout);
|
|
55
|
+
reject(err);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export function getDeviceInfo(deviceId, metadata = {}) {
|
|
60
|
+
return {
|
|
61
|
+
platform: 'android',
|
|
62
|
+
id: deviceId || 'default',
|
|
63
|
+
osVersion: metadata.osVersion || '',
|
|
64
|
+
model: metadata.model || '',
|
|
65
|
+
simulator: metadata.simulator || false
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export async function getAndroidDeviceMetadata(appId, deviceId) {
|
|
69
|
+
try {
|
|
70
|
+
// Run these in parallel to avoid sequential timeouts
|
|
71
|
+
const [osVersion, model, simOutput] = await Promise.all([
|
|
72
|
+
execAdb(['shell', 'getprop', 'ro.build.version.release'], deviceId).catch(() => ''),
|
|
73
|
+
execAdb(['shell', 'getprop', 'ro.product.model'], deviceId).catch(() => ''),
|
|
74
|
+
execAdb(['shell', 'getprop', 'ro.kernel.qemu'], deviceId).catch(() => '0')
|
|
75
|
+
]);
|
|
76
|
+
const simulator = simOutput === '1';
|
|
77
|
+
return { platform: 'android', id: deviceId || 'default', osVersion, model, simulator };
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false };
|
|
81
|
+
}
|
|
82
|
+
}
|