mobile-debug-mcp 0.24.6 → 0.24.8
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/.github/workflows/ci.yml +1 -3
- package/README.md +12 -5
- package/dist/interact/index.js +89 -3
- package/dist/manage/android.js +9 -5
- package/dist/manage/index.js +37 -23
- package/dist/manage/ios.js +12 -15
- package/dist/server/common.js +46 -0
- package/dist/server/tool-handlers.js +120 -33
- package/dist/server-core.js +1 -1
- package/dist/utils/android/utils.js +17 -5
- package/dist/utils/cli/idb/check-idb.js +1 -1
- package/docs/CHANGELOG.md +18 -10
- package/eslint.config.js +2 -47
- package/package.json +7 -6
- package/src/interact/index.ts +112 -4
- package/src/manage/android.ts +22 -11
- package/src/manage/index.ts +37 -16
- package/src/manage/ios.ts +28 -15
- package/src/server/common.ts +50 -0
- package/src/server/tool-handlers.ts +136 -32
- package/src/server-core.ts +1 -1
- package/src/utils/android/utils.ts +18 -7
- package/src/utils/cli/idb/check-idb.ts +1 -1
- package/test/device/automated/observe/capture_screenshot.android.smoke.ts +1 -1
- package/test/device/automated/observe/capture_screenshot.ios.smoke.ts +1 -1
- package/test/device/automated/observe/get_logs.android.smoke.ts +1 -1
- package/test/device/automated/observe/get_logs.ios.smoke.ts +1 -1
- package/test/device/automated/observe/get_ui_tree.android.smoke.ts +1 -1
- package/test/device/automated/observe/get_ui_tree.ios.smoke.ts +1 -1
- package/test/device/manual/interact/app_lifecycle.manual.ts +3 -3
- package/test/device/manual/observe/capture_screenshot.manual.ts +2 -2
- package/test/device/manual/observe/get_logs.manual.ts +2 -2
- package/test/device/manual/observe/get_ui_tree.manual.ts +2 -2
- package/test/device/manual/observe/logstream.manual.ts +1 -1
- package/test/device/manual/observe/screen_fingerprint.manual.ts +2 -2
- package/test/unit/manage/scoped_env.test.ts +137 -0
- package/test/unit/observe/find_element.test.ts +64 -5
- package/test/unit/server/capture_screenshot.test.ts +17 -0
- package/test/unit/server/common.test.ts +18 -0
- package/test/unit/server/contract.test.ts +3 -0
- package/test/unit/server/get_logs.test.ts +17 -0
- package/test/unit/server/get_network_activity.test.ts +17 -0
- package/test/unit/server/get_ui_tree.test.ts +17 -0
- package/test/unit/server/response_shapes.test.ts +18 -0
- package/test/unit/server/start_log_stream.test.ts +37 -0
- package/.eslintignore +0 -5
- package/.eslintrc.cjs +0 -18
- package/eslint.config.cjs +0 -36
package/.github/workflows/ci.yml
CHANGED
package/README.md
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
A minimal, secure MCP server for AI-assisted mobile development. Build, install, and inspect Android/iOS apps from an MCP-compatible client.
|
|
4
4
|
|
|
5
5
|
> **Support:**
|
|
6
|
-
> *
|
|
7
|
-
> *
|
|
8
|
-
> *
|
|
9
|
-
> * Flutter
|
|
10
|
-
> * React native not tested
|
|
6
|
+
> * KMP
|
|
7
|
+
> * Android
|
|
8
|
+
> * iOS
|
|
9
|
+
> * Flutter - not tested
|
|
10
|
+
> * React native - not tested
|
|
11
11
|
|
|
12
12
|
## Requirements
|
|
13
13
|
|
|
@@ -52,8 +52,15 @@ Feature building:
|
|
|
52
52
|
|
|
53
53
|
- `npm run test:unit` runs every automated unit test under `test/unit/...`
|
|
54
54
|
- `npm run test:device` runs the automated device smoke checks under `test/device/automated/...`
|
|
55
|
+
- `npm run verify` runs the default maintainer verification sequence: lint, build, and unit tests
|
|
55
56
|
- Manual and debug-oriented device scripts live under `test/device/manual/...` and are not part of the default test commands
|
|
56
57
|
|
|
58
|
+
## Utility Scripts
|
|
59
|
+
|
|
60
|
+
- `npm run healthcheck` runs the `idb`/tooling healthcheck helper from `src/utils/cli/idb/check-idb.ts`
|
|
61
|
+
- `npm run install-idb` runs the guided `idb` installer helper from `src/utils/cli/idb/install-idb.ts`
|
|
62
|
+
- `npm run preflight-ios` runs the iOS preflight helper from `src/utils/cli/ios/preflight-ios.ts`
|
|
63
|
+
|
|
57
64
|
## Agent skills
|
|
58
65
|
|
|
59
66
|
- `skills/mcp-builder/` contains reusable build/install guidance for agents
|
package/dist/interact/index.js
CHANGED
|
@@ -7,6 +7,14 @@ import { ToolsObserve } from '../observe/index.js';
|
|
|
7
7
|
import { nextActionId } from '../server/common.js';
|
|
8
8
|
export class ToolsInteract {
|
|
9
9
|
static _maxResolvedUiElements = 256;
|
|
10
|
+
static _sliderSearchLookahead = 8;
|
|
11
|
+
static _sliderNegativeGapTolerancePx = 32;
|
|
12
|
+
static _sliderPositiveGapLimitPx = 640;
|
|
13
|
+
static _sliderTrackMinLengthPx = 220;
|
|
14
|
+
static _sliderTrackMaxThicknessPx = 180;
|
|
15
|
+
static _sliderTrackLengthRatio = 0.18;
|
|
16
|
+
static _sliderTrackThicknessRatio = 0.08;
|
|
17
|
+
static _sliderLabelWidthRatio = 1.5;
|
|
10
18
|
static _resolvedUiElements = new Map();
|
|
11
19
|
static _normalize(s) {
|
|
12
20
|
if (s === null || s === undefined)
|
|
@@ -174,6 +182,63 @@ export class ToolsInteract {
|
|
|
174
182
|
}
|
|
175
183
|
return best;
|
|
176
184
|
}
|
|
185
|
+
static _resolveNearbyActionableControl(elements, chosen, screen) {
|
|
186
|
+
if (!chosen)
|
|
187
|
+
return null;
|
|
188
|
+
const labelBounds = ToolsInteract._normalizeBounds(chosen.el.bounds);
|
|
189
|
+
if (!labelBounds)
|
|
190
|
+
return null;
|
|
191
|
+
const [labelLeft, labelTop, labelRight, labelBottom] = labelBounds;
|
|
192
|
+
const labelWidth = labelRight - labelLeft;
|
|
193
|
+
const labelHeight = labelBottom - labelTop;
|
|
194
|
+
const screenWidth = Number(screen?.width) > 0 ? Number(screen?.width) : 0;
|
|
195
|
+
const screenHeight = Number(screen?.height) > 0 ? Number(screen?.height) : 0;
|
|
196
|
+
const minTrackLengthPx = Math.max(ToolsInteract._sliderTrackMinLengthPx, screenWidth > 0 ? Math.floor(screenWidth * ToolsInteract._sliderTrackLengthRatio) : 0, screenHeight > 0 ? Math.floor(screenHeight * ToolsInteract._sliderTrackLengthRatio) : 0);
|
|
197
|
+
const maxTrackThicknessPx = Math.max(ToolsInteract._sliderTrackMaxThicknessPx, screenWidth > 0 ? Math.floor(screenWidth * ToolsInteract._sliderTrackThicknessRatio) : 0, screenHeight > 0 ? Math.floor(screenHeight * ToolsInteract._sliderTrackThicknessRatio) : 0);
|
|
198
|
+
let best = null;
|
|
199
|
+
let bestScore = Infinity;
|
|
200
|
+
for (let i = chosen.idx + 1; i < Math.min(elements.length, chosen.idx + ToolsInteract._sliderSearchLookahead); i++) {
|
|
201
|
+
const candidate = elements[i];
|
|
202
|
+
if (!candidate || !(candidate.clickable || candidate.focusable) || candidate.visible === false)
|
|
203
|
+
continue;
|
|
204
|
+
const candidateBounds = ToolsInteract._normalizeBounds(candidate.bounds);
|
|
205
|
+
if (!candidateBounds)
|
|
206
|
+
continue;
|
|
207
|
+
const [left, top, right] = candidateBounds;
|
|
208
|
+
const width = right - left;
|
|
209
|
+
const height = candidateBounds[3] - top;
|
|
210
|
+
const verticalGap = top - labelBottom;
|
|
211
|
+
if (verticalGap < -ToolsInteract._sliderNegativeGapTolerancePx || verticalGap > ToolsInteract._sliderPositiveGapLimitPx)
|
|
212
|
+
continue;
|
|
213
|
+
const horizontalOverlap = Math.min(labelRight, right) - Math.max(labelLeft, left);
|
|
214
|
+
if (horizontalOverlap < -ToolsInteract._sliderNegativeGapTolerancePx)
|
|
215
|
+
continue;
|
|
216
|
+
const candidateText = ToolsInteract._normalize(candidate.text ?? candidate.label ?? candidate.value ?? '');
|
|
217
|
+
const candidateContent = ToolsInteract._normalize(candidate.contentDescription ?? candidate.contentDesc ?? candidate.accessibilityLabel ?? '');
|
|
218
|
+
const candidateClass = ToolsInteract._normalize(candidate.type ?? candidate.class ?? '');
|
|
219
|
+
let score = verticalGap;
|
|
220
|
+
const horizontalTrackLike = width >= Math.max(minTrackLengthPx, Math.floor(labelWidth * ToolsInteract._sliderLabelWidthRatio)) &&
|
|
221
|
+
height <= maxTrackThicknessPx;
|
|
222
|
+
const verticalTrackLike = height >= Math.max(minTrackLengthPx, Math.floor(labelHeight * ToolsInteract._sliderLabelWidthRatio)) &&
|
|
223
|
+
width <= maxTrackThicknessPx;
|
|
224
|
+
const trackLike = /slider|seek|range/i.test(candidateClass) || horizontalTrackLike || verticalTrackLike;
|
|
225
|
+
if (!candidateText && !candidateContent)
|
|
226
|
+
score -= 18;
|
|
227
|
+
if (trackLike)
|
|
228
|
+
score -= 30;
|
|
229
|
+
if (/view|layout|group|frame/i.test(candidateClass))
|
|
230
|
+
score -= 10;
|
|
231
|
+
if (width > labelWidth * ToolsInteract._sliderLabelWidthRatio)
|
|
232
|
+
score -= 8;
|
|
233
|
+
if (candidateText || candidateContent)
|
|
234
|
+
score += 20;
|
|
235
|
+
if (score < bestScore) {
|
|
236
|
+
bestScore = score;
|
|
237
|
+
best = { el: candidate, idx: i, sliderLike: trackLike };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return best;
|
|
241
|
+
}
|
|
177
242
|
static async getInteractionService(platform, deviceId) {
|
|
178
243
|
const effectivePlatform = platform || 'android';
|
|
179
244
|
const resolved = await resolveTargetDevice({ platform: effectivePlatform, deviceId });
|
|
@@ -262,6 +327,7 @@ export class ToolsInteract {
|
|
|
262
327
|
return { found: false, error: 'Empty query' };
|
|
263
328
|
let best = null;
|
|
264
329
|
let bestScore = 0;
|
|
330
|
+
let lastTree = null;
|
|
265
331
|
const scoreElement = (el) => {
|
|
266
332
|
if (!el || !el.visible)
|
|
267
333
|
return 0;
|
|
@@ -305,6 +371,7 @@ export class ToolsInteract {
|
|
|
305
371
|
while (Date.now() <= deadline) {
|
|
306
372
|
try {
|
|
307
373
|
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
|
|
374
|
+
lastTree = tree;
|
|
308
375
|
if (tree && Array.isArray(tree.elements)) {
|
|
309
376
|
const elements = tree.elements;
|
|
310
377
|
for (let i = 0; i < elements.length; i++) {
|
|
@@ -342,8 +409,8 @@ export class ToolsInteract {
|
|
|
342
409
|
return { found: false, error: 'Element not found' };
|
|
343
410
|
// If the best match is not interactable, try to resolve an actionable ancestor.
|
|
344
411
|
try {
|
|
345
|
-
const
|
|
346
|
-
const
|
|
412
|
+
const elements = (lastTree && Array.isArray(lastTree.elements)) ? lastTree.elements : [];
|
|
413
|
+
const screen = lastTree?.resolution && typeof lastTree.resolution === 'object' ? lastTree.resolution : null;
|
|
347
414
|
let chosen = best;
|
|
348
415
|
const childBounds = Array.isArray(chosen?.bounds) ? chosen.bounds : null;
|
|
349
416
|
// Strategy 1: if parentId references an index, climb that chain
|
|
@@ -412,6 +479,15 @@ export class ToolsInteract {
|
|
|
412
479
|
// small score bump to reflect actionability
|
|
413
480
|
bestScore = Math.min(1, bestScore + 0.02);
|
|
414
481
|
}
|
|
482
|
+
if (best && !(best.clickable || best.focusable)) {
|
|
483
|
+
const nearbyActionable = ToolsInteract._resolveNearbyActionableControl(elements, { el: best, idx: best._index ?? elements.indexOf(best) }, screen);
|
|
484
|
+
if (nearbyActionable) {
|
|
485
|
+
best = nearbyActionable.el;
|
|
486
|
+
best._index = nearbyActionable.idx;
|
|
487
|
+
best._interactable = true;
|
|
488
|
+
best._sliderLike = nearbyActionable.sliderLike;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
415
491
|
}
|
|
416
492
|
catch (e) {
|
|
417
493
|
console.error('Error resolving ancestor:', e);
|
|
@@ -431,9 +507,19 @@ export class ToolsInteract {
|
|
|
431
507
|
tapCoordinates,
|
|
432
508
|
telemetry: {
|
|
433
509
|
matchedIndex: best?._index ?? null,
|
|
434
|
-
matchedInteractable: !!best?._interactable
|
|
510
|
+
matchedInteractable: !!best?._interactable,
|
|
511
|
+
sliderLike: !!best?._sliderLike
|
|
435
512
|
}
|
|
436
513
|
};
|
|
514
|
+
if (best?._sliderLike) {
|
|
515
|
+
const isVertical = !!boundsObj && (boundsObj.bottom - boundsObj.top) > (boundsObj.right - boundsObj.left);
|
|
516
|
+
const interactionHint = {
|
|
517
|
+
kind: 'slider',
|
|
518
|
+
axis: isVertical ? 'vertical' : 'horizontal',
|
|
519
|
+
trackBounds: boundsObj
|
|
520
|
+
};
|
|
521
|
+
outEl.interactionHint = interactionHint;
|
|
522
|
+
}
|
|
437
523
|
const scoreVal = Math.min(1, Number(bestScore.toFixed(3)));
|
|
438
524
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal };
|
|
439
525
|
}
|
package/dist/manage/android.js
CHANGED
|
@@ -2,7 +2,7 @@ import { promises as fs } from 'fs';
|
|
|
2
2
|
import { spawn } from 'child_process';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { existsSync } from 'fs';
|
|
5
|
-
import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from '../utils/android/utils.js';
|
|
5
|
+
import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk, prepareGradle } from '../utils/android/utils.js';
|
|
6
6
|
import { execAdbWithDiagnostics } from '../utils/diagnostics.js';
|
|
7
7
|
import { detectJavaHome } from '../utils/java.js';
|
|
8
8
|
import { AndroidObserve } from '../observe/android.js';
|
|
@@ -10,11 +10,15 @@ export class AndroidManage {
|
|
|
10
10
|
isTestOnlyInstallFailure(output) {
|
|
11
11
|
return typeof output === 'string' && output.includes('INSTALL_FAILED_TEST_ONLY');
|
|
12
12
|
}
|
|
13
|
-
async build(projectPath,
|
|
14
|
-
|
|
13
|
+
async build(projectPath, optionsOrVariant) {
|
|
14
|
+
const options = typeof optionsOrVariant === 'string' ? { variant: optionsOrVariant } : (optionsOrVariant || {});
|
|
15
15
|
try {
|
|
16
|
+
const env = {
|
|
17
|
+
...(options.env || {}),
|
|
18
|
+
...(options.variant ? { MCP_GRADLE_TASK: options.variant } : {})
|
|
19
|
+
};
|
|
16
20
|
// Always use the shared prepareGradle utility for consistent env/setup
|
|
17
|
-
const { execCmd, gradleArgs, spawnOpts } = await
|
|
21
|
+
const { execCmd, gradleArgs, spawnOpts } = await prepareGradle(projectPath, env);
|
|
18
22
|
await new Promise((resolve, reject) => {
|
|
19
23
|
const proc = spawn(execCmd, gradleArgs, spawnOpts);
|
|
20
24
|
let stderr = '';
|
|
@@ -44,7 +48,7 @@ export class AndroidManage {
|
|
|
44
48
|
const stat = await fs.stat(apkPath).catch(() => null);
|
|
45
49
|
if (stat && stat.isDirectory()) {
|
|
46
50
|
const detectedJavaHome = await detectJavaHome().catch(() => undefined);
|
|
47
|
-
const env =
|
|
51
|
+
const env = { ...process.env };
|
|
48
52
|
if (detectedJavaHome) {
|
|
49
53
|
if (env.JAVA_HOME !== detectedJavaHome) {
|
|
50
54
|
env.JAVA_HOME = detectedJavaHome;
|
package/dist/manage/index.js
CHANGED
|
@@ -70,31 +70,35 @@ export async function detectProjectPlatform(projectPath) {
|
|
|
70
70
|
return 'unknown';
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
+
function mergeDefinedEnv(...parts) {
|
|
74
|
+
const merged = {};
|
|
75
|
+
for (const part of parts) {
|
|
76
|
+
if (!part)
|
|
77
|
+
continue;
|
|
78
|
+
for (const [key, value] of Object.entries(part)) {
|
|
79
|
+
if (typeof value === 'undefined')
|
|
80
|
+
continue;
|
|
81
|
+
merged[key] = value;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return merged;
|
|
85
|
+
}
|
|
73
86
|
export class ToolsManage {
|
|
74
87
|
static async build_android({ projectPath, gradleTask, maxWorkers, gradleCache, forceClean }) {
|
|
75
88
|
const android = new AndroidManage();
|
|
76
|
-
// prepare gradle options via environment hints
|
|
77
|
-
if (typeof maxWorkers === 'number')
|
|
78
|
-
process.env.MCP_GRADLE_WORKERS = String(maxWorkers);
|
|
79
|
-
if (typeof gradleCache === 'boolean')
|
|
80
|
-
process.env.MCP_GRADLE_CACHE = gradleCache ? '1' : '0';
|
|
81
|
-
if (forceClean)
|
|
82
|
-
process.env.MCP_FORCE_CLEAN_ANDROID = '1';
|
|
83
89
|
const task = gradleTask || 'assembleDebug';
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
return await android.build(projectPath, {
|
|
91
|
+
variant: task,
|
|
92
|
+
env: mergeDefinedEnv({
|
|
93
|
+
MCP_GRADLE_TASK: task,
|
|
94
|
+
MCP_GRADLE_WORKERS: typeof maxWorkers === 'number' ? String(maxWorkers) : undefined,
|
|
95
|
+
MCP_GRADLE_CACHE: typeof gradleCache === 'boolean' ? (gradleCache ? '1' : '0') : undefined,
|
|
96
|
+
MCP_FORCE_CLEAN_ANDROID: forceClean ? '1' : undefined
|
|
97
|
+
})
|
|
98
|
+
});
|
|
86
99
|
}
|
|
87
100
|
static async build_ios({ projectPath, workspace: _workspace, project: _project, scheme: _scheme, destinationUDID, derivedDataPath, buildJobs, forceClean }) {
|
|
88
101
|
const ios = new iOSManage();
|
|
89
|
-
// Use provided options rather than env-only; still set env fallbacks for downstream tools
|
|
90
|
-
if (derivedDataPath)
|
|
91
|
-
process.env.MCP_DERIVED_DATA = derivedDataPath;
|
|
92
|
-
if (typeof buildJobs === 'number')
|
|
93
|
-
process.env.MCP_BUILD_JOBS = String(buildJobs);
|
|
94
|
-
if (forceClean)
|
|
95
|
-
process.env.MCP_FORCE_CLEAN_IOS = '1';
|
|
96
|
-
if (destinationUDID)
|
|
97
|
-
process.env.MCP_XCODE_DESTINATION_UDID = destinationUDID;
|
|
98
102
|
const opts = {};
|
|
99
103
|
if (_workspace)
|
|
100
104
|
opts.workspace = _workspace;
|
|
@@ -106,13 +110,23 @@ export class ToolsManage {
|
|
|
106
110
|
opts.destinationUDID = destinationUDID;
|
|
107
111
|
if (derivedDataPath)
|
|
108
112
|
opts.derivedDataPath = derivedDataPath;
|
|
109
|
-
if (
|
|
113
|
+
if (typeof buildJobs === 'number')
|
|
114
|
+
opts.buildJobs = buildJobs;
|
|
115
|
+
if (typeof forceClean === 'boolean')
|
|
110
116
|
opts.forceClean = forceClean;
|
|
111
117
|
// prefer explicit xcodebuild path from env
|
|
112
118
|
if (process.env.XCODEBUILD_PATH)
|
|
113
119
|
opts.xcodeCmd = process.env.XCODEBUILD_PATH;
|
|
114
|
-
|
|
115
|
-
|
|
120
|
+
return await ios.build(projectPath, {
|
|
121
|
+
...opts,
|
|
122
|
+
env: mergeDefinedEnv({
|
|
123
|
+
MCP_DERIVED_DATA: derivedDataPath,
|
|
124
|
+
MCP_XCODE_JOBS: typeof buildJobs === 'number' ? String(buildJobs) : undefined,
|
|
125
|
+
MCP_FORCE_CLEAN: typeof forceClean === 'boolean' ? (forceClean ? '1' : '0') : undefined,
|
|
126
|
+
MCP_XCODE_DESTINATION_UDID: destinationUDID,
|
|
127
|
+
XCODEBUILD_PATH: process.env.XCODEBUILD_PATH
|
|
128
|
+
})
|
|
129
|
+
});
|
|
116
130
|
}
|
|
117
131
|
static async build_flutter({ projectPath, platform, buildMode, maxWorkers: _maxWorkers, forceClean: _forceClean }) {
|
|
118
132
|
// Prefer using flutter CLI when available; otherwise delegate to native subproject builders
|
|
@@ -199,12 +213,12 @@ export class ToolsManage {
|
|
|
199
213
|
const chosen = platform || 'android';
|
|
200
214
|
if (chosen === 'android') {
|
|
201
215
|
const android = new AndroidManage();
|
|
202
|
-
const artifact = await android.build(projectPath, variant);
|
|
216
|
+
const artifact = await android.build(projectPath, { variant });
|
|
203
217
|
return artifact;
|
|
204
218
|
}
|
|
205
219
|
else {
|
|
206
220
|
const ios = new iOSManage();
|
|
207
|
-
const artifact = await ios.build(projectPath, variant);
|
|
221
|
+
const artifact = await ios.build(projectPath, { variant });
|
|
208
222
|
return artifact;
|
|
209
223
|
}
|
|
210
224
|
}
|
package/dist/manage/ios.js
CHANGED
|
@@ -6,11 +6,8 @@ import path from "path";
|
|
|
6
6
|
export class iOSManage {
|
|
7
7
|
async build(projectPath, optsOrVariant) {
|
|
8
8
|
// Support legacy variant string as second arg
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
opts.variant = optsOrVariant;
|
|
12
|
-
else
|
|
13
|
-
opts = optsOrVariant || {};
|
|
9
|
+
const opts = typeof optsOrVariant === 'string' ? {} : (optsOrVariant || {});
|
|
10
|
+
const env = { ...process.env, ...(opts.env || {}) };
|
|
14
11
|
try {
|
|
15
12
|
// Look for an Xcode workspace or project at the provided path. If not present, scan subdirectories (limited depth)
|
|
16
13
|
async function findProject(root, maxDepth = 4) {
|
|
@@ -66,7 +63,7 @@ export class iOSManage {
|
|
|
66
63
|
proj = projectInfo.proj;
|
|
67
64
|
}
|
|
68
65
|
// Determine destination: prefer explicit option, then env var, otherwise use booted simulator UDID
|
|
69
|
-
let destinationUDID = opts.destinationUDID ||
|
|
66
|
+
let destinationUDID = opts.destinationUDID || env.MCP_XCODE_DESTINATION_UDID || env.MCP_XCODE_DESTINATION || '';
|
|
70
67
|
if (!destinationUDID) {
|
|
71
68
|
try {
|
|
72
69
|
const meta = await getIOSDeviceMetadata('booted');
|
|
@@ -76,7 +73,7 @@ export class iOSManage {
|
|
|
76
73
|
catch { }
|
|
77
74
|
}
|
|
78
75
|
// Determine xcode command early so it can be used when detecting schemes
|
|
79
|
-
const xcodeCmd = opts.xcodeCmd ||
|
|
76
|
+
const xcodeCmd = opts.xcodeCmd || env.XCODEBUILD_PATH || 'xcodebuild';
|
|
80
77
|
// Determine available schemes by querying xcodebuild -list rather than guessing
|
|
81
78
|
async function detectScheme(xcodeCmdInner, workspacePath, projectPathFull, cwd) {
|
|
82
79
|
try {
|
|
@@ -99,11 +96,11 @@ export class iOSManage {
|
|
|
99
96
|
let buildArgs;
|
|
100
97
|
let chosenScheme = opts.scheme || null;
|
|
101
98
|
// Derived data and result bundle (agent-configurable)
|
|
102
|
-
const derivedDataPath = opts.derivedDataPath ||
|
|
99
|
+
const derivedDataPath = opts.derivedDataPath || env.MCP_DERIVED_DATA || path.join(projectRootDir, 'build', 'DerivedData');
|
|
103
100
|
// Use unique result bundle path by default to avoid collisions
|
|
104
|
-
const resultBundlePath =
|
|
105
|
-
const xcodeJobs = parseInt(
|
|
106
|
-
const forceClean = opts.forceClean
|
|
101
|
+
const resultBundlePath = env.MCP_XCODE_RESULTBUNDLE_PATH || path.join(projectRootDir, 'build', 'xcresults', `ResultBundle-${Date.now()}-${Math.random().toString(36).slice(2)}.xcresult`);
|
|
102
|
+
const xcodeJobs = typeof opts.buildJobs === 'number' ? opts.buildJobs : (parseInt(env.MCP_XCODE_JOBS || '', 10) || 4);
|
|
103
|
+
const forceClean = typeof opts.forceClean === 'boolean' ? opts.forceClean : env.MCP_FORCE_CLEAN === '1';
|
|
107
104
|
// ensure result dirs exist
|
|
108
105
|
await fs.mkdir(path.dirname(resultBundlePath), { recursive: true }).catch(() => { });
|
|
109
106
|
await fs.mkdir(derivedDataPath, { recursive: true }).catch(() => { });
|
|
@@ -144,8 +141,8 @@ export class iOSManage {
|
|
|
144
141
|
// Remove any stale results to avoid xcodebuild complaining about existing result bundles
|
|
145
142
|
await fs.rm(resultsDir, { recursive: true, force: true }).catch(() => { });
|
|
146
143
|
await fs.mkdir(resultsDir, { recursive: true }).catch(() => { });
|
|
147
|
-
const XCODEBUILD_TIMEOUT = parseInt(
|
|
148
|
-
const MAX_RETRIES = parseInt(
|
|
144
|
+
const XCODEBUILD_TIMEOUT = parseInt(env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000; // default 3 minutes
|
|
145
|
+
const MAX_RETRIES = parseInt(env.MCP_XCODEBUILD_RETRIES || '', 10) || 1;
|
|
149
146
|
const tries = MAX_RETRIES + 1;
|
|
150
147
|
let lastStdout = '';
|
|
151
148
|
let lastStderr = '';
|
|
@@ -153,7 +150,7 @@ export class iOSManage {
|
|
|
153
150
|
for (let attempt = 1; attempt <= tries; attempt++) {
|
|
154
151
|
// Run xcodebuild with a watchdog
|
|
155
152
|
const res = await new Promise((resolve) => {
|
|
156
|
-
const proc = spawn(xcodeCmd, buildArgs, { cwd: projectRootDir });
|
|
153
|
+
const proc = spawn(xcodeCmd, buildArgs, { cwd: projectRootDir, env });
|
|
157
154
|
let stdout = '';
|
|
158
155
|
let stderr = '';
|
|
159
156
|
proc.stdout?.on('data', d => stdout += d.toString());
|
|
@@ -204,7 +201,7 @@ export class iOSManage {
|
|
|
204
201
|
if (lastErr) {
|
|
205
202
|
// Include diagnostics and result bundle path when available; provide structured info useful for agents
|
|
206
203
|
const invokedCommand = `${xcodeCmd} ${buildArgs.map(a => a.includes(' ') ? `"${a}"` : a).join(' ')}`;
|
|
207
|
-
const envSnapshot = { PATH:
|
|
204
|
+
const envSnapshot = { PATH: env.PATH };
|
|
208
205
|
return { error: `xcodebuild failed: ${lastErr.message}. See build-results for logs.`, output: `stdout:\n${lastStdout}\nstderr:\n${lastStderr}`, diagnostics: { exitCode: lastErr.code || null, invokedCommand, cwd: projectRootDir, envSnapshot } };
|
|
209
206
|
}
|
|
210
207
|
// Try to locate built .app. First search project tree, then DerivedData if necessary
|
package/dist/server/common.js
CHANGED
|
@@ -7,6 +7,52 @@ export function wrapResponse(data) {
|
|
|
7
7
|
}]
|
|
8
8
|
};
|
|
9
9
|
}
|
|
10
|
+
export function getStringArg(args, key) {
|
|
11
|
+
const value = args[key];
|
|
12
|
+
return typeof value === 'string' ? value : undefined;
|
|
13
|
+
}
|
|
14
|
+
export function requireStringArg(args, key) {
|
|
15
|
+
const value = getStringArg(args, key);
|
|
16
|
+
if (value === undefined)
|
|
17
|
+
throw new Error(`Missing or invalid string argument: ${key}`);
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
export function getNumberArg(args, key) {
|
|
21
|
+
const value = args[key];
|
|
22
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
export function requireNumberArg(args, key) {
|
|
25
|
+
const value = getNumberArg(args, key);
|
|
26
|
+
if (value === undefined)
|
|
27
|
+
throw new Error(`Missing or invalid number argument: ${key}`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
export function getBooleanArg(args, key) {
|
|
31
|
+
const value = args[key];
|
|
32
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
33
|
+
}
|
|
34
|
+
export function requireBooleanArg(args, key) {
|
|
35
|
+
const value = getBooleanArg(args, key);
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
throw new Error(`Missing or invalid boolean argument: ${key}`);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
export function getObjectArg(args, key) {
|
|
41
|
+
const value = args[key];
|
|
42
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
43
|
+
return undefined;
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
export function requireObjectArg(args, key) {
|
|
47
|
+
const value = getObjectArg(args, key);
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
throw new Error(`Missing or invalid object argument: ${key}`);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
export function getArrayArg(args, key) {
|
|
53
|
+
const value = args[key];
|
|
54
|
+
return Array.isArray(value) ? value : undefined;
|
|
55
|
+
}
|
|
10
56
|
let actionSequence = 0;
|
|
11
57
|
export function nextActionId(actionType, timestamp) {
|
|
12
58
|
actionSequence += 1;
|