mobile-debug-mcp 0.15.0 → 0.17.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.
@@ -0,0 +1,169 @@
1
+ import crypto from 'crypto';
2
+ const ANDROID_STRUCTURAL_TYPES = ['Window', 'Application', 'View', 'ViewGroup', 'LinearLayout', 'FrameLayout', 'RelativeLayout', 'ScrollView', 'RecyclerView', 'TextView', 'ImageView'];
3
+ const IOS_STRUCTURAL_TYPES = ['Window', 'Application', 'View', 'ViewController', 'UITableView', 'UICollectionView', 'UILabel', 'UIImageView', 'UIView', 'UIWindow', 'UIStackView', 'UITextView', 'UITableViewCell'];
4
+ function isDynamicText(t) {
5
+ if (!t)
6
+ return false;
7
+ const txt = t.trim();
8
+ if (!txt)
9
+ return false;
10
+ if (/\b\d{1,2}:\d{2}\b/.test(txt))
11
+ return true;
12
+ if (/\b\d{4}-\d{2}-\d{2}\b/.test(txt))
13
+ return true;
14
+ if (/^\d+(?:\.\d+)?%$/.test(txt))
15
+ return true;
16
+ if (/^\d+$/.test(txt))
17
+ return true;
18
+ if (/^[\d,]{1,10}$/.test(txt))
19
+ return true;
20
+ return false;
21
+ }
22
+ function normalizeElement(e) {
23
+ return {
24
+ type: (e.type || '').toString(),
25
+ resourceId: (e.resourceId || '').toString(),
26
+ text: typeof e.text === 'string' ? (isDynamicText(e.text) ? '' : e.text.trim().toLowerCase()) : '',
27
+ contentDesc: (e.contentDescription || '').toString(),
28
+ bounds: Array.isArray(e.bounds) ? e.bounds.slice(0, 4).map((n) => Number(n) || 0) : [0, 0, 0, 0]
29
+ };
30
+ }
31
+ export function computeScreenFingerprint(tree, current, platform, limit = 50) {
32
+ try {
33
+ if (!tree || tree.error)
34
+ return { fingerprint: null, error: tree.error };
35
+ const activity = current && (current.activity || current.shortActivity) ? (current.activity || current.shortActivity) : '';
36
+ const candidates = (tree.elements || []).filter(e => {
37
+ if (!e)
38
+ return false;
39
+ if (!e.visible)
40
+ return false;
41
+ const hasStableText = typeof e.text === 'string' && e.text.trim().length > 0;
42
+ const hasResource = !!e.resourceId;
43
+ const interactable = !!e.clickable || !!e.enabled;
44
+ const structuralList = platform === 'android' ? ANDROID_STRUCTURAL_TYPES : IOS_STRUCTURAL_TYPES;
45
+ const structurallySignificant = hasStableText || hasResource || structuralList.includes(e.type || '');
46
+ return interactable || structurallySignificant;
47
+ });
48
+ const normalized = candidates.map(normalizeElement);
49
+ const filteredNormalized = normalized.filter(e => (e.text && e.text.length > 0) || (e.resourceId && e.resourceId.length > 0) || (e.contentDesc && e.contentDesc.length > 0));
50
+ filteredNormalized.sort((a, b) => {
51
+ const ay = (a.bounds && a.bounds[1]) || 0;
52
+ const by = (b.bounds && b.bounds[1]) || 0;
53
+ if (ay !== by)
54
+ return ay - by;
55
+ const ax = (a.bounds && a.bounds[0]) || 0;
56
+ const bx = (b.bounds && b.bounds[0]) || 0;
57
+ return ax - bx;
58
+ });
59
+ const limited = filteredNormalized.slice(0, Math.max(0, limit));
60
+ const payload = {
61
+ activity: platform === 'android' ? (activity || '') : '',
62
+ resolution: tree.resolution || { width: 0, height: 0 },
63
+ elements: limited.map(e => ({ type: e.type, resourceId: e.resourceId, text: e.text, contentDesc: e.contentDesc }))
64
+ };
65
+ const combined = JSON.stringify(payload);
66
+ const hash = crypto.createHash('sha256').update(combined).digest('hex');
67
+ return { fingerprint: hash, activity: activity };
68
+ }
69
+ catch (e) {
70
+ return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
71
+ }
72
+ }
73
+ export async function scrollToElementShared(opts) {
74
+ const { selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId, fetchTree, swipe, stabilizationDelayMs = 350 } = opts;
75
+ const matchElement = (el) => {
76
+ if (!el)
77
+ return false;
78
+ if (selector.text !== undefined && selector.text !== el.text)
79
+ return false;
80
+ if (selector.resourceId !== undefined && selector.resourceId !== el.resourceId)
81
+ return false;
82
+ if (selector.contentDesc !== undefined && selector.contentDesc !== el.contentDescription)
83
+ return false;
84
+ if (selector.className !== undefined && selector.className !== el.type)
85
+ return false;
86
+ return true;
87
+ };
88
+ const isVisible = (el, resolution) => {
89
+ if (!el)
90
+ return false;
91
+ if (el.visible === false)
92
+ return false;
93
+ if (!el.bounds || !resolution || !resolution.width || !resolution.height)
94
+ return (el.visible === undefined ? true : !!el.visible);
95
+ const [left, top, right, bottom] = el.bounds;
96
+ const withinY = bottom > 0 && top < resolution.height;
97
+ const withinX = right > 0 && left < resolution.width;
98
+ return withinX && withinY;
99
+ };
100
+ const findVisibleMatch = (elements, resolution) => {
101
+ if (!Array.isArray(elements))
102
+ return null;
103
+ for (const e of elements) {
104
+ if (matchElement(e) && isVisible(e, resolution))
105
+ return e;
106
+ }
107
+ return null;
108
+ };
109
+ // Initial check
110
+ let tree = await fetchTree();
111
+ if (tree.error)
112
+ return { success: false, reason: tree.error, scrollsPerformed: 0 };
113
+ let found = findVisibleMatch(tree.elements, tree.resolution);
114
+ if (found) {
115
+ return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed: 0 };
116
+ }
117
+ const fingerprintOf = (t) => {
118
+ try {
119
+ return JSON.stringify((t.elements || []).map((e) => ({ text: e.text, resourceId: e.resourceId, bounds: e.bounds })));
120
+ }
121
+ catch {
122
+ return '';
123
+ }
124
+ };
125
+ let prevFingerprint = fingerprintOf(tree);
126
+ const width = (tree.resolution && tree.resolution.width) ? tree.resolution.width : 0;
127
+ const height = (tree.resolution && tree.resolution.height) ? tree.resolution.height : 0;
128
+ const centerX = Math.round(width / 2) || 50;
129
+ const clampPct = (v) => Math.max(0.05, Math.min(0.95, v));
130
+ const computeCoords = () => {
131
+ const defaultStart = direction === 'down' ? 0.8 : 0.2;
132
+ const startPct = clampPct(defaultStart);
133
+ const endPct = clampPct(defaultStart + (direction === 'down' ? -scrollAmount : scrollAmount));
134
+ const x1 = centerX;
135
+ const x2 = centerX;
136
+ const y1 = Math.round((height || 100) * startPct);
137
+ const y2 = Math.round((height || 100) * endPct);
138
+ return { x1, y1, x2, y2 };
139
+ };
140
+ const duration = 300;
141
+ let scrollsPerformed = 0;
142
+ for (let i = 0; i < maxScrolls; i++) {
143
+ const { x1, y1, x2, y2 } = computeCoords();
144
+ try {
145
+ await swipe(x1, y1, x2, y2, duration, deviceId);
146
+ }
147
+ catch (e) {
148
+ try {
149
+ console.warn(`scrollToElement swipe failed: ${e instanceof Error ? e.message : String(e)}`);
150
+ }
151
+ catch { }
152
+ }
153
+ scrollsPerformed++;
154
+ await new Promise(resolve => setTimeout(resolve, stabilizationDelayMs));
155
+ tree = await fetchTree();
156
+ if (tree.error)
157
+ return { success: false, reason: tree.error, scrollsPerformed: scrollsPerformed };
158
+ found = findVisibleMatch(tree.elements, tree.resolution);
159
+ if (found) {
160
+ return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed };
161
+ }
162
+ const fp = fingerprintOf(tree);
163
+ if (fp === prevFingerprint) {
164
+ return { success: false, reason: 'UI unchanged after scroll; likely end of list', scrollsPerformed: scrollsPerformed };
165
+ }
166
+ prevFingerprint = fp;
167
+ }
168
+ return { success: false, reason: 'Element not found after scrolling', scrollsPerformed: scrollsPerformed };
169
+ }
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.17.0]
6
+ - Added `capture_debug_snapshot` observe tool: captures a full debugging snapshot including screenshot (base64), UI tree, current activity (Android), screen fingerprint, and recent logs (prefers active log stream, falls back to snapshot logs). Returns a single structured JSON object and includes per-part error fields for partial failures. Implemented as `ToolsObserve.captureDebugSnapshotHandler` and registered in the server.
7
+
8
+ ## [0.16.0]
9
+ - Added `wait_for_screen_change` interact tool: polls the platform-specific `get_screen_fingerprint` until it differs from a provided `previousFingerprint`, with configurable `timeoutMs` and `pollIntervalMs` and an optional stability confirmation poll to avoid reacting to transient UI flickers. Implemented at the interact layer and delegates fingerprinting to the observe implementations (Android/iOS).
10
+ - Added unit tests covering immediate change, transient null fingerprints, stability confirmation and timeout behavior: `test/interact/unit/wait_for_screen_change.test.ts`.
11
+
5
12
  ## [0.15.0]
6
13
  - Reorganised repository for cohesion: merged tool handlers into feature entrypoints (src/observe, src/interact, src/manage) and moved platform helpers and CLI tooling into src/utils/{android,ios,cli}.
7
14
  - Added computeScreenFingerprint utility used by observe/interact to normalise UI element significance across platforms (fingerprint shared between Android and iOS implementations).
@@ -72,3 +72,32 @@ Notes:
72
72
  - Android swipe uses `adb shell input swipe` with screen percentage coordinates. iOS swipe uses `idb ui swipe` command; note `idb` swipe does not accept a duration argument.
73
73
  - Unit tests are located at `test/unit/observe/scroll_to_element.test.ts` and device runners at `test/device/observe/`.
74
74
 
75
+ ---
76
+
77
+ ## wait_for_screen_change
78
+
79
+ Description:
80
+ - Waits until the current screen fingerprint differs from the provided `previousFingerprint`. Useful after taps, navigation, or other interactions that should change the visible UI.
81
+
82
+ Input example:
83
+ ```
84
+ { "platform": "android", "previousFingerprint": "<hex-fingerprint>", "timeoutMs": 5000, "pollIntervalMs": 300, "deviceId": "emulator-5554" }
85
+ ```
86
+
87
+ Success response example:
88
+ ```
89
+ { "success": true, "newFingerprint": "<hex-fingerprint>", "elapsedMs": 420 }
90
+ ```
91
+
92
+ Failure (timeout) example:
93
+ ```
94
+ { "success": false, "reason": "timeout", "lastFingerprint": "<hex-fingerprint>", "elapsedMs": 5000 }
95
+ ```
96
+
97
+ Notes:
98
+ - Always compares to the original `previousFingerprint` (baseline is not updated during polling).
99
+ - Treats `null` fingerprints as transient; continues polling rather than returning success.
100
+ - Includes a stability confirmation: after detecting a different fingerprint it waits one additional poll interval and confirms the fingerprint is stable before returning success to avoid reacting to transient flickers or animation frames.
101
+ - Default `timeoutMs` is 5000ms and default `pollIntervalMs` is 300ms; callers may override these.
102
+ - Implemented as an interact-level tool and delegates platform-specific fingerprint calculation to the observe layer (`get_screen_fingerprint`).
103
+
@@ -76,6 +76,50 @@ Response:
76
76
 
77
77
  ---
78
78
 
79
+ ## capture_debug_snapshot
80
+ Capture a complete debug snapshot of the app state for diagnostics and post-mortem analysis.
81
+
82
+ Input:
83
+
84
+ ```json
85
+ {
86
+ "reason": "optional string describing why snapshot is taken",
87
+ "includeLogs": true,
88
+ "logLines": 200,
89
+ "platform": "android | ios",
90
+ "appId": "optional package/bundle id to scope logs",
91
+ "deviceId": "optional device serial/udid",
92
+ "sessionId": "optional log stream session id to prefer"
93
+ }
94
+ ```
95
+
96
+ Behavior:
97
+ - Captures screenshot (base64), current activity (Android), screen fingerprint, full UI tree, and recent logs.
98
+ - Prefers active log stream entries (read_log_stream) and falls back to get_logs when no active stream is available.
99
+ - Returns partial data when components fail and includes per-part error fields (e.g. `screenshot_error`, `ui_tree_error`).
100
+ - Caps logs to `logLines` entries and prefers recent entries.
101
+ - Fast by default: does not wait for new logs and avoids long blocking operations.
102
+
103
+ Response (example):
104
+
105
+ ```json
106
+ {
107
+ "timestamp": 1710000000,
108
+ "reason": "Crash after tapping checkout",
109
+ "activity": "CheckoutActivity",
110
+ "fingerprint": "abc123",
111
+ "screenshot": "<base64 PNG string>",
112
+ "ui_tree": { ... },
113
+ "logs": [ { "timestamp": 1710000000, "level": "ERROR", "message": "NullPointerException at CheckoutViewModel" } ]
114
+ }
115
+ ```
116
+
117
+ Notes:
118
+ - Useful immediately after detecting crashes or unexpected UI behaviour.
119
+ - Do not expect perfect data during a crash; tool is designed to return best-effort context and include errors for failed parts.
120
+
121
+ ---
122
+
79
123
  ## get_screen_fingerprint
80
124
  Generate a stable fingerprint representing the visible screen. Useful for detecting navigation changes, preventing loops, and synchronisation.
81
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  import { WaitForElementResponse, TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
2
2
  import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
3
3
  import { AndroidObserve } from "../observe/index.js"
4
- import { scrollToElementShared } from "../interact/shared/scroll_to_element.js"
4
+ import { scrollToElementShared } from "../utils/ui/index.js"
5
5
 
6
6
 
7
7
  export class AndroidInteract {
@@ -3,6 +3,9 @@ import { iOSInteract } from './ios.js';
3
3
  export { AndroidInteract, iOSInteract };
4
4
 
5
5
  import { resolveTargetDevice } from '../utils/resolve-device.js'
6
+ import { ToolsObserve } from '../observe/index.js'
7
+
8
+ interface ScreenFingerprintResponse { fingerprint: string | null }
6
9
 
7
10
  export class ToolsInteract {
8
11
 
@@ -44,4 +47,46 @@ export class ToolsInteract {
44
47
  return await interact.scrollToElement(selector, direction, maxScrolls, scrollAmount, resolved.id)
45
48
  }
46
49
 
50
+ static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }: { platform?: 'android' | 'ios', previousFingerprint: string, timeoutMs?: number, pollIntervalMs?: number, deviceId?: string }) {
51
+ const start = Date.now()
52
+ let lastFingerprint: string | null = null
53
+
54
+ while (Date.now() - start < timeoutMs) {
55
+ try {
56
+ const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
57
+ const fp = res?.fingerprint ?? null
58
+ if (fp === null || fp === undefined) {
59
+ lastFingerprint = null
60
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
61
+ continue
62
+ }
63
+
64
+ lastFingerprint = fp
65
+
66
+ if (fp !== previousFingerprint) {
67
+ // Stability confirmation
68
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
69
+ try {
70
+ const confirmRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
71
+ const confirmFp = confirmRes?.fingerprint ?? null
72
+ if (confirmFp === fp) {
73
+ return { success: true, newFingerprint: fp, elapsedMs: Date.now() - start }
74
+ }
75
+ lastFingerprint = confirmFp
76
+ continue
77
+ } catch {
78
+ // ignore and continue polling
79
+ continue
80
+ }
81
+ }
82
+ } catch {
83
+ // ignore transient errors
84
+ }
85
+
86
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
87
+ }
88
+
89
+ return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start }
90
+ }
91
+
47
92
  }
@@ -2,7 +2,7 @@ import { spawn } from "child_process"
2
2
  import { WaitForElementResponse, TapResponse, SwipeResponse } from "../types.js"
3
3
  import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
4
4
  import { iOSObserve } from "../observe/index.js"
5
- import { scrollToElementShared } from "../interact/shared/scroll_to_element.js"
5
+ import { scrollToElementShared } from "../utils/ui/index.js"
6
6
 
7
7
  export class iOSInteract {
8
8
  private observe = new iOSObserve();
@@ -5,7 +5,7 @@ import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, get
5
5
  import { createWriteStream } from "fs"
6
6
  import { promises as fsPromises } from "fs"
7
7
  import path from "path"
8
- import { computeScreenFingerprint } from "../interact/shared/fingerprint.js"
8
+ import { computeScreenFingerprint } from "../utils/ui/index.js"
9
9
 
10
10
  const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
11
11
 
@@ -89,4 +89,92 @@ export class ToolsObserve {
89
89
  // Both observes implement getScreenFingerprint
90
90
  return await (observe as any).getScreenFingerprint(resolved.id)
91
91
  }
92
+
93
+ static async captureDebugSnapshotHandler({ reason, includeLogs = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
94
+ const timestamp = Date.now()
95
+ const out: any = { timestamp, reason: reason || '', activity: null, fingerprint: null, screenshot: null, ui_tree: null, logs: [] }
96
+
97
+ // Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
98
+ const sid = sessionId || 'default'
99
+ const tasks = {
100
+ screenshot: ToolsObserve.captureScreenshotHandler({ platform, deviceId }),
101
+ currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
102
+ fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
103
+ uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
104
+ readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
105
+ }
106
+
107
+ const results = await Promise.allSettled(Object.values(tasks))
108
+ const keys = Object.keys(tasks)
109
+
110
+ // Map results back to keys
111
+ for (let i = 0; i < results.length; i++) {
112
+ const key = keys[i]
113
+ const res = results[i] as PromiseSettledResult<any>
114
+ if (res.status === 'fulfilled') {
115
+ const val = res.value
116
+ if (key === 'screenshot') {
117
+ out.screenshot = val && val.screenshot ? val.screenshot : null
118
+ } else if (key === 'currentScreen') {
119
+ out.activity = val && ((val.activity || val.shortActivity)) ? (val.activity || val.shortActivity) : out.activity || ''
120
+ } else if (key === 'fingerprint') {
121
+ if (val && val.fingerprint) out.fingerprint = val.fingerprint
122
+ if (val && val.activity) out.activity = out.activity || val.activity
123
+ if (val && val.error) out.fingerprint_error = val.error
124
+ } else if (key === 'uiTree') {
125
+ out.ui_tree = val
126
+ if (val && val.error) out.ui_tree_error = val.error
127
+ } else if (key === 'readLogStream') {
128
+ // handle below after evaluating fallback
129
+ // temporarily attach to out._streamEntries
130
+ out._streamEntries = val && val.entries ? val.entries : []
131
+ }
132
+ } else {
133
+ const errMsg = res.reason instanceof Error ? res.reason.message : String(res.reason)
134
+ if (key === 'screenshot') out.screenshot_error = errMsg
135
+ if (key === 'currentScreen') out.activity_error = errMsg
136
+ if (key === 'fingerprint') { out.fingerprint = null; out.fingerprint_error = errMsg }
137
+ if (key === 'uiTree') { out.ui_tree = null; out.ui_tree_error = errMsg }
138
+ if (key === 'readLogStream') { out._streamEntries = [] ; out.logs_error = errMsg }
139
+ }
140
+ }
141
+
142
+ // Logs: prefer stream entries, fallback to snapshot logs when empty
143
+ if (includeLogs) {
144
+ try {
145
+ let entries: any[] = Array.isArray(out._streamEntries) ? out._streamEntries : []
146
+ if (!entries || entries.length === 0) {
147
+ const gl = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines: logLines })
148
+ const raw: string[] = (gl && (gl as any).logs) ? (gl as any).logs : []
149
+ entries = raw.slice(-Math.max(0, logLines)).map(line => {
150
+ const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(line) ? 'ERROR' : /\b(WARN| W )\b/i.test(line) ? 'WARN' : 'INFO'
151
+ return { timestamp: null, level, message: line }
152
+ })
153
+ } else {
154
+ entries = entries.map(ent => {
155
+ const msg = (ent && (ent.message || ent.msg)) ? (ent.message || ent.msg) : (typeof ent === 'string' ? ent : JSON.stringify(ent))
156
+ const levelRaw = (ent && (ent.level || ent.levelName || ent._level)) ? (ent.level || ent.levelName || ent._level) : ''
157
+ const level = (levelRaw && String(levelRaw)).toString().toUpperCase() || (/\bERROR\b/i.test(msg) ? 'ERROR' : /\bWARN\b/i.test(msg) ? 'WARN' : 'INFO')
158
+ let tsNum: number | null = null
159
+ const maybeIso = ent && ((ent._iso || ent.timestamp) as any)
160
+ if (maybeIso && typeof maybeIso === 'string') {
161
+ const d = new Date(maybeIso)
162
+ if (!isNaN(d.getTime())) tsNum = d.getTime()
163
+ }
164
+ return { timestamp: tsNum, level, message: msg }
165
+ })
166
+ }
167
+
168
+ out.logs = entries
169
+ } catch (e) {
170
+ out.logs = []
171
+ out.logs_error = e instanceof Error ? e.message : String(e)
172
+ }
173
+ }
174
+
175
+ // Clean up internal temporary field
176
+ delete out._streamEntries
177
+
178
+ return out
179
+ }
92
180
  }
@@ -5,7 +5,7 @@ import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcru
5
5
  import { createWriteStream, promises as fsPromises } from 'fs'
6
6
  import path from 'path'
7
7
  import { parseLogLine } from '../utils/android/utils.js'
8
- import { computeScreenFingerprint } from '../interact/shared/fingerprint.js'
8
+ import { computeScreenFingerprint } from '../utils/ui/index.js'
9
9
 
10
10
  const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
11
11
 
package/src/server.ts CHANGED
@@ -215,6 +215,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
215
215
  required: ["platform"]
216
216
  }
217
217
  },
218
+ {
219
+ name: "capture_debug_snapshot",
220
+ description: "Capture a complete debug snapshot (screenshot, ui tree, activity, fingerprint, logs). Returns structured JSON."
221
+ ,
222
+ inputSchema: {
223
+ type: "object",
224
+ properties: {
225
+ reason: { type: "string", description: "Optional reason for snapshot" },
226
+ includeLogs: { type: "boolean", description: "Whether to include logs", default: true },
227
+ logLines: { type: "number", description: "Maximum number of log lines to include", default: 200 },
228
+ platform: { type: "string", enum: ["android","ios"], description: "Optional platform override" },
229
+ appId: { type: "string", description: "Optional appId to scope logs (package/bundle id)" },
230
+ deviceId: { type: "string", description: "Optional device serial/udid" },
231
+ sessionId: { type: "string", description: "Optional log stream session id to prefer" }
232
+ }
233
+ }
234
+ },
218
235
  {
219
236
  name: "start_log_stream",
220
237
  description: "Start streaming logs for a target application on Android or iOS. For Android this uses adb logcat --pid=<pid>; for iOS it streams `xcrun simctl spawn <device> log stream` with a predicate.",
@@ -294,6 +311,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
294
311
  }
295
312
  }
296
313
  },
314
+ {
315
+ name: "wait_for_screen_change",
316
+ description: "Wait until the current screen fingerprint differs from a provided previousFingerprint. Useful to wait for navigation/animation completion.",
317
+ inputSchema: {
318
+ type: "object",
319
+ properties: {
320
+ platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override (android|ios)" },
321
+ previousFingerprint: { type: "string", description: "The fingerprint to compare against (required)" },
322
+ timeoutMs: { type: "number", description: "Timeout in ms to wait for change (default 5000)", default: 5000 },
323
+ pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300)", default: 300 },
324
+ deviceId: { type: "string", description: "Optional device id/udid to target" }
325
+ },
326
+ required: ["previousFingerprint"]
327
+ }
328
+ },
297
329
  {
298
330
  name: "wait_for_element",
299
331
  description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
@@ -322,6 +354,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
322
354
  required: ["platform", "text"]
323
355
  }
324
356
  },
357
+
325
358
  {
326
359
  name: "tap",
327
360
  description: "Simulate a finger tap on the device screen at specific coordinates.",
@@ -578,6 +611,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
578
611
  }
579
612
  }
580
613
 
614
+ if (name === "capture_debug_snapshot") {
615
+ const { reason, includeLogs, logLines, platform, appId, deviceId, sessionId } = args as any
616
+ const res = await ToolsObserve.captureDebugSnapshotHandler({ reason, includeLogs, logLines, platform, appId, deviceId, sessionId })
617
+ return wrapResponse(res)
618
+ }
619
+
581
620
  if (name === "get_ui_tree") {
582
621
  const { platform, deviceId } = args as any
583
622
  const res = await ToolsObserve.getUITreeHandler({ platform, deviceId })
@@ -596,6 +635,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
596
635
  return wrapResponse(res)
597
636
  }
598
637
 
638
+ if (name === "wait_for_screen_change") {
639
+ const { platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId } = (args || {}) as any
640
+ const res = await ToolsInteract.waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId })
641
+ return wrapResponse(res)
642
+ }
643
+
599
644
  if (name === "wait_for_element") {
600
645
  const { platform, text, timeout, deviceId } = (args || {}) as any
601
646
  const res = await ToolsInteract.waitForElementHandler({ platform, text, timeout, deviceId })
package/src/types.ts CHANGED
@@ -143,3 +143,4 @@ export interface InstallAppResponse {
143
143
  error?: string;
144
144
  diagnostics?: any;
145
145
  }
146
+
@@ -1,8 +1,8 @@
1
- import { spawn } from 'child_process'
2
1
  import { DeviceInfo, UIElement } from "../../types.js"
3
2
  import { promises as fsPromises, existsSync } from 'fs'
4
3
  import path from 'path'
5
4
  import { detectJavaHome } from '../java.js'
5
+ import { execCmd } from '../exec.js'
6
6
 
7
7
  export function getAdbCmd() { return process.env.ADB_PATH || 'adb' }
8
8
 
@@ -83,87 +83,20 @@ import type { SpawnOptions } from 'child_process'
83
83
 
84
84
  export type SpawnOptionsWithTimeout = SpawnOptions & { timeout?: number }
85
85
 
86
- export function execAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<string> {
86
+ export async function execAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<string> {
87
87
  const adbArgs = getAdbArgs(args, deviceId)
88
- return new Promise((resolve, reject) => {
89
- // Extract timeout from options if present, otherwise pass options to spawn
90
- const { timeout: customTimeout, ...spawnOptions } = options;
91
-
92
- // Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
93
- const child = spawn(getAdbCmd(), adbArgs, spawnOptions)
94
-
95
- let stdout = ''
96
- let stderr = ''
97
-
98
- if (child.stdout) {
99
- child.stdout.on('data', (data) => {
100
- stdout += data.toString()
101
- })
102
- }
103
-
104
- if (child.stderr) {
105
- child.stderr.on('data', (data) => {
106
- stderr += data.toString()
107
- })
108
- }
109
-
110
- const timeoutMs = getAdbTimeout(args, customTimeout)
111
-
112
-
113
- const timeout = setTimeout(() => {
114
- child.kill()
115
- reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`))
116
- }, timeoutMs)
117
-
118
- child.on('close', (code) => {
119
- clearTimeout(timeout)
120
- if (code !== 0) {
121
- // If there's an actual error (non-zero exit code), reject
122
- reject(new Error(stderr.trim() || `Command failed with code ${code}`))
123
- } else {
124
- // If exit code is 0, resolve with stdout
125
- resolve(stdout.trim())
126
- }
127
- })
128
-
129
- child.on('error', (err) => {
130
- clearTimeout(timeout)
131
- reject(err)
132
- })
133
- })
88
+ const timeoutMs = getAdbTimeout(args, options.timeout)
89
+ const res = await execCmd(getAdbCmd(), adbArgs, { timeout: timeoutMs, env: options.env as any, cwd: typeof options.cwd === 'string' ? options.cwd : undefined, shell: !!options.shell })
90
+ if (res.exitCode !== 0) throw new Error(res.stderr || `Command failed with code ${res.exitCode}`)
91
+ return res.stdout
134
92
  }
135
93
 
136
94
  // Spawn adb but return full streams and exit code so callers can implement fallbacks or stream output
137
- export function spawnAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<{ stdout: string, stderr: string, code: number | null }> {
95
+ export async function spawnAdb(args: string[], deviceId?: string, options: SpawnOptionsWithTimeout = {}): Promise<{ stdout: string, stderr: string, code: number | null }> {
138
96
  const adbArgs = getAdbArgs(args, deviceId)
139
- return new Promise((resolve, reject) => {
140
- const { timeout: customTimeout, ...spawnOptions } = options
141
- const child = spawn(getAdbCmd(), adbArgs, spawnOptions)
142
-
143
- let stdout = ''
144
- let stderr = ''
145
-
146
- if (child.stdout) child.stdout.on('data', d => { stdout += d.toString() })
147
- if (child.stderr) child.stderr.on('data', d => { stderr += d.toString() })
148
-
149
- const timeoutMs = getAdbTimeout(args, customTimeout)
150
-
151
-
152
- const timeout = setTimeout(() => {
153
- try { child.kill() } catch {}
154
- reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`))
155
- }, timeoutMs)
156
-
157
- child.on('close', (code) => {
158
- clearTimeout(timeout)
159
- resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code })
160
- })
161
-
162
- child.on('error', (err) => {
163
- clearTimeout(timeout)
164
- reject(err)
165
- })
166
- })
97
+ const timeoutMs = getAdbTimeout(args, options.timeout)
98
+ const res = await execCmd(getAdbCmd(), adbArgs, { timeout: timeoutMs, env: options.env as any, cwd: typeof options.cwd === 'string' ? options.cwd : undefined, shell: !!options.shell })
99
+ return { stdout: res.stdout, stderr: res.stderr, code: res.exitCode }
167
100
  }
168
101
 
169
102
  export function getDeviceInfo(deviceId: string, metadata: Partial<DeviceInfo> = {}): DeviceInfo {